From 1230718e64735cdc8c3fcb41af90f00f4d5b3e30 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 20 Oct 2026 18:18:37 +0300 Subject: [PATCH 1/5] Fixed OpenAI-compatible models with strict chat templates failing with "System message must be at the beginning" Mid-conversation system messages (Claude Code >= 2.1.288) were forwarded in place as role:system. They are now merged into the leading system message, or turned into user text later in the conversation. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + pom.xml | 2 +- .../AnthropicToOpenAITranslator.java | 57 ++++++++++++--- .../AnthropicToOpenAITranslatorTest.java | 70 +++++++++++++++++-- 4 files changed, 113 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74eb9b28..566d3330 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/pom.xml b/pom.xml index be93bb1b..14a46a5b 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbplugins netbeans-plugin-claude-code-gui - 1.3.61-SNAPSHOT + 1.3.63-SNAPSHOT nbm Netbeans Plugin Claude Code GUI NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE. diff --git a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java index f3ffa8e1..38d089f7 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java @@ -19,7 +19,9 @@ * *

Request (Anthropic → OpenAI)

*
    - *
  • {@code system} field → prepended as {@code {"role":"system",...}} message
  • + *
  • {@code system} field → prepended as {@code {"role":"system",...}} message; + * mid-conversation {@code role:"system"} messages are merged into it or turned into + * {@code } user text (strict templates require system first)
  • *
  • User text content blocks → flattened to string
  • *
  • User tool_result blocks → converted to {@code role:"tool"} messages
  • *
  • Assistant tool_use blocks → converted to {@code tool_calls}
  • @@ -272,18 +274,55 @@ private static void convertMessages(JsonNode anthropicMessages, ArrayNode openai } else if ("assistant".equals(role)) { convertAssistantMessage(content, openaiMessages); } 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); - } + addMidConversationSystem(extractSystemText(content), openaiMessages); } } } + /** + * Adds a mid-conversation system message (Claude Code ≥ 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 } block in a user message + * (appended to the preceding plain-text user message when there is one). + */ + private static void addMidConversationSystem(String text, ArrayNode out) { + if (text.isBlank()) return; + + 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; + } + ObjectNode lead = (ObjectNode) out.get(0); + if (lead.path("content").isTextual()) { + lead.put("content", lead.path("content").asText() + "\n\n" + text); + return; + } + // content-part array (explicit cache breakpoint): leave it intact, use a user message + } + + String reminder = "\n" + text + "\n"; + 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); + } else { + ObjectNode userMsg = out.addObject(); + userMsg.put("role", "user"); + userMsg.put("content", reminder); + } + } + private static void convertUserMessage(JsonNode content, ArrayNode out) { if (content.isTextual()) { ObjectNode msg = out.addObject(); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java b/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java index 78efc566..747f7346 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java @@ -46,19 +46,75 @@ 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\n# Environment\nWorking directory: /tmp/x\n", + messages.get(1).path("content").asText()); + assertEquals("assistant", messages.get(2).path("role").asText()); + assertEquals("user", messages.get(3).path("role").asText()); + assertEquals("\nReminder A\n\nReminder B\n", + 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\nEnv\n", 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()); + assertOnlyLeadingSystem(messages); + } + + 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 From 1a814a3bcb3e8d9e0205a463be3a4f562038cd3f Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 20 Oct 2026 19:10:20 +0300 Subject: [PATCH 2/5] Addressed review comments on the system-message fix Mid-conversation system text after a multipart (image) user turn is now added as a text part of that turn instead of a second consecutive user message. Co-Authored-By: Claude Sonnet 5.5 --- pom.xml | 2 +- .../AnthropicToOpenAITranslator.java | 7 ++++++- .../AnthropicToOpenAITranslatorTest.java | 19 +++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 14a46a5b..f790b262 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbplugins netbeans-plugin-claude-code-gui - 1.3.63-SNAPSHOT + 1.3.64-SNAPSHOT nbm Netbeans Plugin Claude Code GUI NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE. diff --git a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java index 38d089f7..ac696f84 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java @@ -285,7 +285,7 @@ private static void convertMessages(JsonNode anthropicMessages, ArrayNode openai * 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 } block in a user message - * (appended to the preceding plain-text user message when there is one). + * (appended to the preceding user message, as text or an extra text part, when there is one). */ private static void addMidConversationSystem(String text, ArrayNode out) { if (text.isBlank()) return; @@ -316,6 +316,11 @@ private static void addMidConversationSystem(String text, ArrayNode out) { 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); + } 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); } else { ObjectNode userMsg = out.addObject(); userMsg.put("role", "user"); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java b/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java index 747f7346..f7bef89f 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java @@ -110,6 +110,25 @@ void translateRequest_midConversationSystemMessage_keepsExplicitCacheBreakpoint( assertOnlyLeadingSystem(messages); } + @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("\nEnv\n", parts.get(2).path("text").asText()); + } + private static void assertOnlyLeadingSystem(JsonNode messages) { for (int i = 1; i < messages.size(); i++) { assertNotEquals("system", messages.get(i).path("role").asText(), From 44a8818440bf12eb4ef7b8b01f6018ae05efb5e5 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 20 Oct 2026 20:49:30 +0300 Subject: [PATCH 3/5] Addressed review comments on the system-message fix (2) A mid-conversation system message that precedes the first user turn (explicit cache breakpoint case) is now folded into that user turn instead of producing two consecutive user messages. Co-Authored-By: Claude Sonnet 5.5 --- pom.xml | 2 +- .../AnthropicToOpenAITranslator.java | 43 ++++++++++++++++--- .../AnthropicToOpenAITranslatorTest.java | 36 ++++++++++++++++ 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/pom.xml b/pom.xml index f790b262..44333178 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbplugins netbeans-plugin-claude-code-gui - 1.3.64-SNAPSHOT + 1.3.65-SNAPSHOT nbm Netbeans Plugin Claude Code GUI NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE. diff --git a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java index ac696f84..b9eb357b 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java @@ -265,20 +265,50 @@ 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); + int n = openaiMessages.size(); + if (pendingReminder != null && n >= 2 && openaiMessages.get(n - 2) == pendingReminder + && "user".equals(openaiMessages.get(n - 1).path("role").asText())) { + mergeReminderIntoUser(pendingReminder, (ObjectNode) openaiMessages.get(n - 1)); + openaiMessages.remove(n - 2); + } + pendingReminder = null; } else if ("assistant".equals(role)) { convertAssistantMessage(content, openaiMessages); + pendingReminder = null; } else if ("system".equals(role)) { - addMidConversationSystem(extractSystemText(content), openaiMessages); + ObjectNode created = addMidConversationSystem(extractSystemText(content), openaiMessages); + if (created != null) { + pendingReminder = created; + } } } } + /** Moves the text of a standalone reminder user message to the start of {@code target}. */ + private static void mergeReminderIntoUser(ObjectNode reminder, ObjectNode target) { + String reminderText = reminder.path("content").asText(); + JsonNode targetContent = target.path("content"); + if (targetContent.isTextual()) { + target.put("content", reminderText + "\n\n" + targetContent.asText()); + } else if (targetContent.isArray()) { + ArrayNode parts = MAPPER.createArrayNode(); + ObjectNode part = parts.addObject(); + part.put("type", "text"); + part.put("text", reminderText); + parts.addAll((ArrayNode) targetContent); + target.set("content", parts); + } + } + /** * Adds a mid-conversation system message (Claude Code ≥ 2.1.288, e.g. the * "# Environment" block). Chat Completions providers with strict chat templates (vLLM) @@ -287,8 +317,8 @@ private static void convertMessages(JsonNode anthropicMessages, ArrayNode openai * message; later it becomes a {@code } block in a user message * (appended to the preceding user message, as text or an extra text part, when there is one). */ - private static void addMidConversationSystem(String text, ArrayNode out) { - if (text.isBlank()) return; + private static ObjectNode addMidConversationSystem(String text, ArrayNode out) { + if (text.isBlank()) return null; boolean onlySystemSoFar = true; for (JsonNode m : out) { @@ -302,12 +332,12 @@ private static void addMidConversationSystem(String text, ArrayNode out) { ObjectNode sysMsg = out.addObject(); sysMsg.put("role", "system"); sysMsg.put("content", text); - return; + return null; } ObjectNode lead = (ObjectNode) out.get(0); if (lead.path("content").isTextual()) { lead.put("content", lead.path("content").asText() + "\n\n" + text); - return; + return null; } // content-part array (explicit cache breakpoint): leave it intact, use a user message } @@ -316,15 +346,18 @@ private static void addMidConversationSystem(String text, ArrayNode out) { 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 null; } 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 null; } else { ObjectNode userMsg = out.addObject(); userMsg.put("role", "user"); userMsg.put("content", reminder); + return userMsg; } } diff --git a/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java b/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java index f7bef89f..9a5af3bf 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java @@ -107,9 +107,45 @@ void translateRequest_midConversationSystemMessage_keepsExplicitCacheBreakpoint( 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("\nEnv\n\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("\nEnv\n", 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( From 481102e29294636485c64838029c042bc4536fec Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 20 Oct 2026 22:02:52 +0300 Subject: [PATCH 4/5] Addressed review comments on the system-message fix (3) A reminder created between an assistant tool_use and its tool_result turn is now placed after the tool messages so the call/result pair stays adjacent. Co-Authored-By: Claude Sonnet 5.5 --- pom.xml | 2 +- .../AnthropicToOpenAITranslator.java | 36 ++++++++++++++++--- .../AnthropicToOpenAITranslatorTest.java | 21 +++++++++++ 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/pom.xml b/pom.xml index 44333178..e6d614be 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbplugins netbeans-plugin-claude-code-gui - 1.3.65-SNAPSHOT + 1.3.66-SNAPSHOT nbm Netbeans Plugin Claude Code GUI NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE. diff --git a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java index b9eb357b..69f01280 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java @@ -274,11 +274,8 @@ private static void convertMessages(JsonNode anthropicMessages, ArrayNode openai if ("user".equals(role)) { convertUserMessage(content, openaiMessages); - int n = openaiMessages.size(); - if (pendingReminder != null && n >= 2 && openaiMessages.get(n - 2) == pendingReminder - && "user".equals(openaiMessages.get(n - 1).path("role").asText())) { - mergeReminderIntoUser(pendingReminder, (ObjectNode) openaiMessages.get(n - 1)); - openaiMessages.remove(n - 2); + if (pendingReminder != null) { + placePendingReminder(pendingReminder, openaiMessages); } pendingReminder = null; } else if ("assistant".equals(role)) { @@ -293,6 +290,35 @@ private static void convertMessages(JsonNode anthropicMessages, ArrayNode openai } } + /** + * Called right after a user turn was converted while {@code reminder} (a standalone user + * message) sits before it. Folds the reminder into the user message that follows it, or, + * when the turn produced {@code tool} messages (answers to the preceding assistant + * {@code tool_calls}), moves the reminder after them so the call/result pair stays adjacent. + */ + private static void placePendingReminder(ObjectNode reminder, ArrayNode out) { + int idx = -1; + for (int i = out.size() - 1; i >= 0; i--) { + if (out.get(i) == reminder) { + 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())) { + mergeReminderIntoUser(reminder, (ObjectNode) last); + out.remove(idx); + 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(reminder); + } + /** Moves the text of a standalone reminder user message to the start of {@code target}. */ private static void mergeReminderIntoUser(ObjectNode reminder, ObjectNode target) { String reminderText = reminder.path("content").asText(); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java b/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java index 9a5af3bf..ca556d91 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java @@ -165,6 +165,27 @@ void translateRequest_midConversationSystemMessage_afterImagePrompt_staysInSameU assertEquals("\nEnv\n", 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("\nEnv\n", messages.get(4).path("content").asText()); + assertOnlyLeadingSystem(messages); + } + private static void assertOnlyLeadingSystem(JsonNode messages) { for (int i = 1; i < messages.size(); i++) { assertNotEquals("system", messages.get(i).path("role").asText(), From c1f199d3d01d403f12f4ed35e81d8d28fe2dbd93 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 20 Oct 2026 23:12:55 +0300 Subject: [PATCH 5/5] Addressed review comments on the system-message fix (4) A user turn that follows a user message which received a mid-conversation system text is now coalesced into it, so no two user messages are adjacent. Co-Authored-By: Claude Sonnet 5.5 --- pom.xml | 2 +- .../AnthropicToOpenAITranslator.java | 57 +++++++++++-------- .../AnthropicToOpenAITranslatorTest.java | 36 ++++++++++++ 3 files changed, 70 insertions(+), 25 deletions(-) diff --git a/pom.xml b/pom.xml index e6d614be..53b79107 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbplugins netbeans-plugin-claude-code-gui - 1.3.66-SNAPSHOT + 1.3.67-SNAPSHOT nbm Netbeans Plugin Claude Code GUI NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE. diff --git a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java index 69f01280..8096db12 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java @@ -291,15 +291,17 @@ private static void convertMessages(JsonNode anthropicMessages, ArrayNode openai } /** - * Called right after a user turn was converted while {@code reminder} (a standalone user - * message) sits before it. Folds the reminder into the user message that follows it, or, - * when the turn produced {@code tool} messages (answers to the preceding assistant - * {@code tool_calls}), moves the reminder after them so the call/result pair stays adjacent. + * 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 reminder, ArrayNode out) { + private static void placePendingReminder(ObjectNode carrier, ArrayNode out) { int idx = -1; for (int i = out.size() - 1; i >= 0; i--) { - if (out.get(i) == reminder) { + if (out.get(i) == carrier) { idx = i; break; } @@ -308,31 +310,38 @@ private static void placePendingReminder(ObjectNode reminder, ArrayNode out) { JsonNode last = out.get(out.size() - 1); if (idx == out.size() - 2 && "user".equals(last.path("role").asText())) { - mergeReminderIntoUser(reminder, (ObjectNode) last); - out.remove(idx); + 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(reminder); + out.add(carrier); } - /** Moves the text of a standalone reminder user message to the start of {@code target}. */ - private static void mergeReminderIntoUser(ObjectNode reminder, ObjectNode target) { - String reminderText = reminder.path("content").asText(); - JsonNode targetContent = target.path("content"); - if (targetContent.isTextual()) { - target.put("content", reminderText + "\n\n" + targetContent.asText()); - } else if (targetContent.isArray()) { - ArrayNode parts = MAPPER.createArrayNode(); - ObjectNode part = parts.addObject(); - part.put("type", "text"); - part.put("text", reminderText); - parts.addAll((ArrayNode) targetContent); - target.set("content", parts); + /** 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); } /** @@ -372,13 +381,13 @@ private static ObjectNode addMidConversationSystem(String text, ArrayNode out) { 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 null; + 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 null; + return (ObjectNode) last; } else { ObjectNode userMsg = out.addObject(); userMsg.put("role", "user"); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java b/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java index ca556d91..fe0a7b67 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java @@ -186,6 +186,42 @@ void translateRequest_reminderBetweenToolUseAndToolResult_movedAfterToolMessages 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\nS\n\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\nS\n", 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(),