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..53b79107 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.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 f3ffa8e1..8096db12 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}
@@ -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 ≥ 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 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 = "\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);
+ 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");
+ userMsg.put("content", reminder);
+ return userMsg;
+ }
+ }
+
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..fe0a7b67 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,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\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());
+ 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(
+ "{\"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());
+ }
+
+ @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);
+ }
+
+ @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(),
+ "system message at index " + i + " must not follow other messages");
+ }
}
@Test