Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
- Fixed OpenAI-compatible models with strict chat templates (e.g. vLLM behind LiteLLM) failing every request with "System message must be at the beginning"
# 1.3.39 (2026-10-18)
- Fixed the edit-mode selector with Claude Code 2.1.x: it no longer jumps to "Ask on Edit" whenever Claude goes idle, and choosing a mode no longer makes Claude cycle through modes and stop on the wrong one; choosing another mode while a switch is still running now switches to that mode instead of cycling
- Fixed "OpenAI-compatible API" connection type being reset to "Claude-compatible API" on the Default profile (https://github.com/nbplugins/NetbeansPluginClaudeCodeGui/issues/191)
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>io.github.nbplugins</groupId>
<artifactId>netbeans-plugin-claude-code-gui</artifactId>
<version>1.3.61-SNAPSHOT</version>
<version>1.3.67-SNAPSHOT</version>
<packaging>nbm</packaging>
<name>Netbeans Plugin Claude Code GUI</name>
<description>NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE.</description>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
*
* <h2>Request (Anthropic → OpenAI)</h2>
* <ul>
* <li>{@code system} field → prepended as {@code {"role":"system",...}} message</li>
* <li>{@code system} field → prepended as {@code {"role":"system",...}} message;
* mid-conversation {@code role:"system"} messages are merged into it or turned into
* {@code <system-reminder>} user text (strict templates require system first)</li>
* <li>User text content blocks → flattened to string</li>
* <li>User tool_result blocks → converted to {@code role:"tool"} messages</li>
* <li>Assistant tool_use blocks → converted to {@code tool_calls}</li>
Expand Down Expand Up @@ -263,27 +265,137 @@ private static String extractSystemText(JsonNode systemNode) {
}

private static void convertMessages(JsonNode anthropicMessages, ArrayNode openaiMessages) {
// Standalone user message created from a mid-conversation system message; folded into
// the next user message so that two user turns never follow each other.
ObjectNode pendingReminder = null;
for (JsonNode msg : anthropicMessages) {
String role = msg.path("role").asText();
JsonNode content = msg.path("content");

if ("user".equals(role)) {
convertUserMessage(content, openaiMessages);
if (pendingReminder != null) {
placePendingReminder(pendingReminder, openaiMessages);
}
pendingReminder = null;
} else if ("assistant".equals(role)) {
convertAssistantMessage(content, openaiMessages);
pendingReminder = null;
} else if ("system".equals(role)) {
// Mid-conversation system message (Claude Code >= 2.1.288, e.g. the
// "# Environment" block) — kept in place instead of being dropped.
String text = extractSystemText(content);
if (!text.isBlank()) {
ObjectNode sysMsg = openaiMessages.addObject();
sysMsg.put("role", "system");
sysMsg.put("content", text);
ObjectNode created = addMidConversationSystem(extractSystemText(content), openaiMessages);
if (created != null) {
pendingReminder = created;
}
}
}
}

/**
* Called right after a user turn was converted while {@code carrier} (the user message that
* received a mid-conversation system text, possibly a standalone reminder) sits before it.
* Folds the following user message into the carrier so two user turns never follow each
* other; when the turn produced only {@code tool} messages (answers to the preceding
* assistant {@code tool_calls}), moves a standalone reminder after them so the
* call/result pair stays adjacent.
*/
private static void placePendingReminder(ObjectNode carrier, ArrayNode out) {
int idx = -1;
for (int i = out.size() - 1; i >= 0; i--) {
if (out.get(i) == carrier) {
idx = i;
break;
}
}
if (idx < 0 || idx == out.size() - 1) return;

JsonNode last = out.get(out.size() - 1);
if (idx == out.size() - 2 && "user".equals(last.path("role").asText())) {
appendUserContent(carrier, (ObjectNode) last);
out.remove(out.size() - 1);
return;
}
boolean afterToolCalls = idx > 0 && out.get(idx - 1).has("tool_calls");
if (!afterToolCalls) return;
for (int i = idx + 1; i < out.size(); i++) {
if (!"tool".equals(out.get(i).path("role").asText())) return;
}
out.remove(idx);
out.add(carrier);
}

/** Appends the content of user message {@code next} to user message {@code target}. */
private static void appendUserContent(ObjectNode target, ObjectNode next) {
JsonNode a = target.path("content");
JsonNode b = next.path("content");
if (a.isTextual() && b.isTextual()) {
target.put("content", a.asText() + "\n\n" + b.asText());
return;
}
ArrayNode parts = MAPPER.createArrayNode();
for (JsonNode side : new JsonNode[] {a, b}) {
if (side.isArray()) {
parts.addAll((ArrayNode) side);
} else {
ObjectNode part = parts.addObject();
part.put("type", "text");
part.put("text", side.asText());
}
}
target.set("content", parts);
}

/**
* Adds a mid-conversation system message (Claude Code &ge; 2.1.288, e.g. the
* "# Environment" block). Chat Completions providers with strict chat templates (vLLM)
* reject a {@code system} message that is not at the very beginning, so it is never
* emitted in place: before any other message it is merged into the leading system
* message; later it becomes a {@code <system-reminder>} block in a user message
* (appended to the preceding user message, as text or an extra text part, when there is one).
*/
private static ObjectNode addMidConversationSystem(String text, ArrayNode out) {
if (text.isBlank()) return null;

boolean onlySystemSoFar = true;
for (JsonNode m : out) {
if (!"system".equals(m.path("role").asText())) {
onlySystemSoFar = false;
break;
}
}
if (onlySystemSoFar) {
if (out.isEmpty()) {
ObjectNode sysMsg = out.addObject();
sysMsg.put("role", "system");
sysMsg.put("content", text);
return null;
}
ObjectNode lead = (ObjectNode) out.get(0);
if (lead.path("content").isTextual()) {
lead.put("content", lead.path("content").asText() + "\n\n" + text);
return null;
}
// content-part array (explicit cache breakpoint): leave it intact, use a user message
}

String reminder = "<system-reminder>\n" + text + "\n</system-reminder>";
JsonNode last = out.isEmpty() ? null : out.get(out.size() - 1);
if (last != null && "user".equals(last.path("role").asText()) && last.path("content").isTextual()) {
((ObjectNode) last).put("content", last.path("content").asText() + "\n\n" + reminder);
return (ObjectNode) last;
} else if (last != null && "user".equals(last.path("role").asText()) && last.path("content").isArray()) {
// multipart (image) user turn: add a text part instead of a second consecutive user turn
ObjectNode part = ((ArrayNode) last.path("content")).addObject();
part.put("type", "text");
part.put("text", reminder);
return (ObjectNode) last;
} else {
ObjectNode userMsg = out.addObject();
userMsg.put("role", "user");
Comment thread
oleg68 marked this conversation as resolved.
Resolved
userMsg.put("content", reminder);
Comment thread
oleg68 marked this conversation as resolved.
Resolved
return userMsg;
}
}

private static void convertUserMessage(JsonNode content, ArrayNode out) {
if (content.isTextual()) {
ObjectNode msg = out.addObject();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,19 +46,187 @@ void translateRequest_simpleTextMessage() throws Exception {
}

@Test
void translateRequest_midConversationSystemMessage_keptInPlaceAsSystemMessage() throws Exception {
void translateRequest_midConversationSystemMessage_becomesSystemReminderInUserMessage() throws Exception {
ObjectNode result = AnthropicToOpenAITranslator.translateRequest(load("req_mid_conversation_system.json"));

JsonNode messages = result.path("messages");
assertEquals(5, messages.size());
assertEquals(4, messages.size());
assertEquals("system", messages.get(0).path("role").asText());
assertEquals("You are helpful.", messages.get(0).path("content").asText());
assertEquals("user", messages.get(1).path("role").asText());
assertEquals("system", messages.get(2).path("role").asText());
assertEquals("# Environment\nWorking directory: /tmp/x", messages.get(2).path("content").asText());
assertEquals("assistant", messages.get(3).path("role").asText());
assertEquals("system", messages.get(4).path("role").asText());
assertEquals("Reminder A\n\nReminder B", messages.get(4).path("content").asText());
assertEquals("Hello\n\n<system-reminder>\n# Environment\nWorking directory: /tmp/x\n</system-reminder>",
messages.get(1).path("content").asText());
assertEquals("assistant", messages.get(2).path("role").asText());
assertEquals("user", messages.get(3).path("role").asText());
assertEquals("<system-reminder>\nReminder A\n\nReminder B\n</system-reminder>",
messages.get(3).path("content").asText());
assertOnlyLeadingSystem(messages);
}

@Test
void translateRequest_leadingMidConversationSystemMessage_mergedIntoLeadingSystemMessage() throws Exception {
JsonNode req = AnthropicToOpenAITranslator.MAPPER.readTree(
"{\"model\":\"m\",\"system\":\"Base\",\"messages\":["
+ "{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Env\"}]},"
+ "{\"role\":\"user\",\"content\":\"Hi\"}]}");

JsonNode messages = AnthropicToOpenAITranslator.translateRequest(req).path("messages");

assertEquals(2, messages.size());
assertEquals("system", messages.get(0).path("role").asText());
assertEquals("Base\n\nEnv", messages.get(0).path("content").asText());
assertEquals("user", messages.get(1).path("role").asText());
assertOnlyLeadingSystem(messages);
}

@Test
void translateRequest_trailingMidConversationSystemMessage_requestEndsWithUserTurn() throws Exception {
JsonNode req = AnthropicToOpenAITranslator.MAPPER.readTree(
"{\"model\":\"m\",\"system\":\"Base\",\"messages\":["
+ "{\"role\":\"user\",\"content\":\"Hi\"},"
+ "{\"role\":\"system\",\"content\":\"Env\"}]}");

JsonNode messages = AnthropicToOpenAITranslator.translateRequest(req).path("messages");

assertEquals(2, messages.size());
assertEquals("user", messages.get(1).path("role").asText());
assertEquals("Hi\n\n<system-reminder>\nEnv\n</system-reminder>", messages.get(1).path("content").asText());
assertOnlyLeadingSystem(messages);
}

@Test
void translateRequest_midConversationSystemMessage_keepsExplicitCacheBreakpoint() throws Exception {
JsonNode req = AnthropicToOpenAITranslator.MAPPER.readTree(
"{\"model\":\"gpt-5.6-terra\",\"system\":\"Base\",\"messages\":["
+ "{\"role\":\"system\",\"content\":\"Env\"},"
+ "{\"role\":\"user\",\"content\":\"Hi\"}]}");

JsonNode messages = AnthropicToOpenAITranslator.translateRequest(req, null, true).path("messages");

assertEquals("system", messages.get(0).path("role").asText());
assertEquals("explicit", messages.get(0).path("content").get(0)
.path("prompt_cache_breakpoint").path("mode").asText());
assertEquals(1, messages.get(0).path("content").size());
assertEquals(2, messages.size());
assertEquals("user", messages.get(1).path("role").asText());
assertEquals("<system-reminder>\nEnv\n</system-reminder>\n\nHi", messages.get(1).path("content").asText());
assertOnlyLeadingSystem(messages);
}

@Test
void translateRequest_reminderBeforeImagePrompt_mergedIntoThatUserTurn() throws Exception {
JsonNode req = AnthropicToOpenAITranslator.MAPPER.readTree(
"{\"model\":\"gpt-5.6-terra\",\"system\":\"Base\",\"messages\":["
+ "{\"role\":\"system\",\"content\":\"Env\"},"
+ "{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Look\"},"
+ "{\"type\":\"image\",\"source\":{\"type\":\"base64\",\"media_type\":\"image/png\",\"data\":\"AAAA\"}}]}]}");

JsonNode messages = AnthropicToOpenAITranslator.translateRequest(req, null, true).path("messages");

assertEquals(2, messages.size());
JsonNode parts = messages.get(1).path("content");
assertTrue(parts.isArray());
assertEquals(3, parts.size());
assertEquals("<system-reminder>\nEnv\n</system-reminder>", parts.get(0).path("text").asText());
assertEquals("Look", parts.get(1).path("text").asText());
assertEquals("image_url", parts.get(2).path("type").asText());
}

@Test
void translateRequest_reminderFollowedByAssistant_staysStandaloneUserMessage() throws Exception {
JsonNode req = AnthropicToOpenAITranslator.MAPPER.readTree(
"{\"model\":\"gpt-5.6-terra\",\"system\":\"Base\",\"messages\":["
+ "{\"role\":\"system\",\"content\":\"Env\"},"
+ "{\"role\":\"assistant\",\"content\":\"Hi\"}]}");

JsonNode messages = AnthropicToOpenAITranslator.translateRequest(req, null, true).path("messages");

assertEquals(3, messages.size());
assertEquals("user", messages.get(1).path("role").asText());
assertEquals("assistant", messages.get(2).path("role").asText());
}

@Test
void translateRequest_midConversationSystemMessage_afterImagePrompt_staysInSameUserTurn() throws Exception {
JsonNode req = AnthropicToOpenAITranslator.MAPPER.readTree(
"{\"model\":\"m\",\"messages\":["
+ "{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Look\"},"
+ "{\"type\":\"image\",\"source\":{\"type\":\"base64\",\"media_type\":\"image/png\",\"data\":\"AAAA\"}}]},"
+ "{\"role\":\"system\",\"content\":\"Env\"}]}");

JsonNode messages = AnthropicToOpenAITranslator.translateRequest(req).path("messages");

assertEquals(1, messages.size());
JsonNode parts = messages.get(0).path("content");
assertTrue(parts.isArray());
assertEquals(3, parts.size());
assertEquals("image_url", parts.get(1).path("type").asText());
assertEquals("text", parts.get(2).path("type").asText());
assertEquals("<system-reminder>\nEnv\n</system-reminder>", parts.get(2).path("text").asText());
}

@Test
void translateRequest_reminderBetweenToolUseAndToolResult_movedAfterToolMessages() throws Exception {
JsonNode req = AnthropicToOpenAITranslator.MAPPER.readTree(
"{\"model\":\"m\",\"system\":\"Base\",\"messages\":["
+ "{\"role\":\"user\",\"content\":\"Hi\"},"
+ "{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"t1\",\"name\":\"Read\",\"input\":{}}]},"
+ "{\"role\":\"system\",\"content\":\"Env\"},"
+ "{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"t1\",\"content\":\"ok\"}]}]}");

JsonNode messages = AnthropicToOpenAITranslator.translateRequest(req).path("messages");

assertEquals(5, messages.size());
assertEquals("assistant", messages.get(2).path("role").asText());
assertTrue(messages.get(2).has("tool_calls"));
assertEquals("tool", messages.get(3).path("role").asText());
assertEquals("t1", messages.get(3).path("tool_call_id").asText());
assertEquals("user", messages.get(4).path("role").asText());
assertEquals("<system-reminder>\nEnv\n</system-reminder>", messages.get(4).path("content").asText());
assertOnlyLeadingSystem(messages);
}

@Test
void translateRequest_reminderBetweenTwoUserTurns_coalescedIntoOneUserTurn() throws Exception {
JsonNode req = AnthropicToOpenAITranslator.MAPPER.readTree(
"{\"model\":\"m\",\"system\":\"Base\",\"messages\":["
+ "{\"role\":\"user\",\"content\":\"A\"},"
+ "{\"role\":\"system\",\"content\":\"S\"},"
+ "{\"role\":\"user\",\"content\":\"B\"}]}");

JsonNode messages = AnthropicToOpenAITranslator.translateRequest(req).path("messages");

assertEquals(2, messages.size());
assertEquals("user", messages.get(1).path("role").asText());
assertEquals("A\n\n<system-reminder>\nS\n</system-reminder>\n\nB",
messages.get(1).path("content").asText());
}

@Test
void translateRequest_reminderBetweenTextAndImageUserTurns_coalescedIntoMultipartTurn() throws Exception {
JsonNode req = AnthropicToOpenAITranslator.MAPPER.readTree(
"{\"model\":\"m\",\"system\":\"Base\",\"messages\":["
+ "{\"role\":\"user\",\"content\":\"A\"},"
+ "{\"role\":\"system\",\"content\":\"S\"},"
+ "{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Look\"},"
+ "{\"type\":\"image\",\"source\":{\"type\":\"base64\",\"media_type\":\"image/png\",\"data\":\"AAAA\"}}]}]}");

JsonNode messages = AnthropicToOpenAITranslator.translateRequest(req).path("messages");

assertEquals(2, messages.size());
JsonNode parts = messages.get(1).path("content");
assertTrue(parts.isArray());
assertEquals(3, parts.size());
assertEquals("A\n\n<system-reminder>\nS\n</system-reminder>", parts.get(0).path("text").asText());
assertEquals("Look", parts.get(1).path("text").asText());
assertEquals("image_url", parts.get(2).path("type").asText());
}

private static void assertOnlyLeadingSystem(JsonNode messages) {
for (int i = 1; i < messages.size(); i++) {
assertNotEquals("system", messages.get(i).path("role").asText(),
"system message at index " + i + " must not follow other messages");
}
}

@Test
Expand Down
Loading