From b88d0039fc767ef541ccdc9c59eaede8a971e5ab Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Sat, 23 May 2026 15:12:27 +0300 Subject: [PATCH] Fixed OpenAI proxy streaming crash on single-chunk responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced manual JSON string escaping in StreamingState build* methods with proper Jackson ObjectNode serialization. The old code missed \b, \f and control characters U+0000–U+001F, producing invalid JSON when model output (especially reasoning/thinking) contained these characters. Backends that return the full response as one SSE chunk (pseudo-streaming) triggered the bug reliably, causing Claude Code to exit immediately. Also added: - stream_options: {include_usage: true} in outgoing streaming requests - Defensive buildDoneEvents() call if backend closes without [DONE] - doneReceived / isMessageStarted flags on StreamingState - 8 regression tests covering control-char escaping, single-chunk scenario, stream_options, and the doneReceived flag Co-Authored-By: Claude Sonnet 4.6 --- pom.xml | 2 +- .../AnthropicToOpenAITranslator.java | 152 +++++++++++------- .../openaiproxy/OpenAIProxyServlet.java | 4 + .../AnthropicToOpenAITranslatorTest.java | 106 ++++++++++++ 4 files changed, 209 insertions(+), 55 deletions(-) diff --git a/pom.xml b/pom.xml index 75b32a3e..aebe314a 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbplugins netbeans-plugin-claude-code-gui - 1.2.8-SNAPSHOT + 1.2.9-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 00d743e0..0f13a54b 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslator.java @@ -71,7 +71,11 @@ public static ObjectNode translateRequest(JsonNode anthropicRequest) { // stream if (anthropicRequest.has("stream")) { - openai.put("stream", anthropicRequest.get("stream").asBoolean()); + boolean stream = anthropicRequest.get("stream").asBoolean(); + openai.put("stream", stream); + if (stream) { + openai.putObject("stream_options").put("include_usage", true); + } } // temperature, top_p (pass through if present) @@ -422,6 +426,7 @@ static String mapFinishReason(String openaiReason) { public static final class StreamingState { private boolean messageStarted = false; + private boolean doneReceived = false; private boolean thinkingBlockOpen = false; private int thinkingBlockIndex = -1; private boolean textBlockOpen = false; @@ -443,6 +448,9 @@ private static final class ToolCallState { int blockIndex; } + public boolean isMessageStarted() { return messageStarted; } + public boolean isDoneReceived() { return doneReceived; } + /** * Process one OpenAI SSE data line and return zero or more Anthropic SSE events. * @@ -451,6 +459,7 @@ private static final class ToolCallState { */ public String processChunk(String dataLine) { if ("[DONE]".equals(dataLine)) { + doneReceived = true; return buildDoneEvents(); } @@ -582,21 +591,18 @@ public String processChunk(String dataLine) { return out.toString(); } - private String buildDoneEvents() { + String buildDoneEvents() { StringBuilder out = new StringBuilder(); if (thinkingBlockOpen) { out.append(sseEvent("content_block_delta", buildSignatureDelta(thinkingBlockIndex))); - out.append(sseEvent("content_block_stop", - "{\"type\":\"content_block_stop\",\"index\":" + thinkingBlockIndex + "}")); + out.append(sseEvent("content_block_stop", blockStop(thinkingBlockIndex))); } if (textBlockOpen) { - out.append(sseEvent("content_block_stop", - "{\"type\":\"content_block_stop\",\"index\":" + textBlockIndex + "}")); + out.append(sseEvent("content_block_stop", blockStop(textBlockIndex))); } for (ToolCallState state : toolCalls.values()) { if (state.started) { - out.append(sseEvent("content_block_stop", - "{\"type\":\"content_block_stop\",\"index\":" + state.blockIndex + "}")); + out.append(sseEvent("content_block_stop", blockStop(state.blockIndex))); } } out.append(sseEvent("message_delta", buildMessageDelta())); @@ -604,77 +610,115 @@ private String buildDoneEvents() { return out.toString(); } + private static String blockStop(int index) { + return "{\"type\":\"content_block_stop\",\"index\":" + index + "}"; + } + private String buildMessageStart() { - return String.format( - "{\"type\":\"message_start\",\"message\":{\"id\":\"%s\",\"type\":\"message\"," + - "\"role\":\"assistant\",\"content\":[],\"model\":\"%s\"," + - "\"stop_reason\":null,\"stop_sequence\":null," + - "\"usage\":{\"input_tokens\":%d,\"output_tokens\":0}}}", - messageId, model, inputTokens); + try { + ObjectNode msg = MAPPER.createObjectNode(); + msg.put("id", messageId); + msg.put("type", "message"); + msg.put("role", "assistant"); + msg.putArray("content"); + msg.put("model", model); + msg.putNull("stop_reason"); + msg.putNull("stop_sequence"); + msg.putObject("usage") + .put("input_tokens", inputTokens) + .put("output_tokens", 0); + ObjectNode root = MAPPER.createObjectNode(); + root.put("type", "message_start"); + root.set("message", msg); + return MAPPER.writeValueAsString(root); + } catch (Exception e) { + return "{\"type\":\"message_start\",\"message\":{\"type\":\"message\"," + + "\"role\":\"assistant\",\"content\":[],\"stop_reason\":null," + + "\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}}"; + } } private static String buildTextBlockStart(int index) { - return String.format( - "{\"type\":\"content_block_start\",\"index\":%d," + - "\"content_block\":{\"type\":\"text\",\"text\":\"\"}}", - index); + ObjectNode root = MAPPER.createObjectNode(); + root.put("type", "content_block_start"); + root.put("index", index); + root.putObject("content_block").put("type", "text").put("text", ""); + return toJson(root); } private static String buildTextDelta(int index, String text) { - String escaped = text.replace("\\", "\\\\").replace("\"", "\\\"") - .replace("\n", "\\n").replace("\r", "\\r") - .replace("\t", "\\t"); - return String.format( - "{\"type\":\"content_block_delta\",\"index\":%d," + - "\"delta\":{\"type\":\"text_delta\",\"text\":\"%s\"}}", - index, escaped); + ObjectNode root = MAPPER.createObjectNode(); + root.put("type", "content_block_delta"); + root.put("index", index); + root.putObject("delta").put("type", "text_delta").put("text", text); + return toJson(root); } private static String buildToolUseBlockStart(int index, String id, String name) { - return String.format( - "{\"type\":\"content_block_start\",\"index\":%d," + - "\"content_block\":{\"type\":\"tool_use\",\"id\":\"%s\",\"name\":\"%s\",\"input\":{}}}", - index, id, name); + ObjectNode root = MAPPER.createObjectNode(); + root.put("type", "content_block_start"); + root.put("index", index); + ObjectNode cb = root.putObject("content_block"); + cb.put("type", "tool_use"); + cb.put("id", id); + cb.put("name", name); + cb.putObject("input"); + return toJson(root); } private static String buildInputJsonDelta(int index, String partialJson) { - String escaped = partialJson.replace("\\", "\\\\").replace("\"", "\\\"") - .replace("\n", "\\n").replace("\r", "\\r"); - return String.format( - "{\"type\":\"content_block_delta\",\"index\":%d," + - "\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"%s\"}}", - index, escaped); + ObjectNode root = MAPPER.createObjectNode(); + root.put("type", "content_block_delta"); + root.put("index", index); + root.putObject("delta").put("type", "input_json_delta").put("partial_json", partialJson); + return toJson(root); } private static String buildThinkingBlockStart(int index) { - return String.format( - "{\"type\":\"content_block_start\",\"index\":%d," + - "\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}", - index); + ObjectNode root = MAPPER.createObjectNode(); + root.put("type", "content_block_start"); + root.put("index", index); + root.putObject("content_block").put("type", "thinking").put("thinking", ""); + return toJson(root); } private static String buildThinkingDelta(int index, String thinking) { - String escaped = thinking.replace("\\", "\\\\").replace("\"", "\\\"") - .replace("\n", "\\n").replace("\r", "\\r") - .replace("\t", "\\t"); - return String.format( - "{\"type\":\"content_block_delta\",\"index\":%d," + - "\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"%s\"}}", - index, escaped); + ObjectNode root = MAPPER.createObjectNode(); + root.put("type", "content_block_delta"); + root.put("index", index); + root.putObject("delta").put("type", "thinking_delta").put("thinking", thinking); + return toJson(root); } private static String buildSignatureDelta(int index) { - return String.format( - "{\"type\":\"content_block_delta\",\"index\":%d," + - "\"delta\":{\"type\":\"signature_delta\",\"signature\":\"proxy_thinking_sig\"}}", - index); + ObjectNode root = MAPPER.createObjectNode(); + root.put("type", "content_block_delta"); + root.put("index", index); + root.putObject("delta").put("type", "signature_delta").put("signature", "proxy_thinking_sig"); + return toJson(root); } private String buildMessageDelta() { - return String.format( - "{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"%s\"," + - "\"stop_sequence\":null},\"usage\":{\"output_tokens\":%d}}", - stopReason, outputTokens); + try { + ObjectNode root = MAPPER.createObjectNode(); + root.put("type", "message_delta"); + ObjectNode delta = root.putObject("delta"); + delta.put("stop_reason", stopReason); + delta.putNull("stop_sequence"); + root.putObject("usage").put("output_tokens", outputTokens); + return MAPPER.writeValueAsString(root); + } catch (Exception e) { + return "{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"," + + "\"stop_sequence\":null},\"usage\":{\"output_tokens\":0}}"; + } + } + + private static String toJson(ObjectNode node) { + try { + return MAPPER.writeValueAsString(node); + } catch (Exception e) { + return "{}"; + } } } diff --git a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyServlet.java b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyServlet.java index 0e528e96..6230fdcc 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyServlet.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/openaiproxy/OpenAIProxyServlet.java @@ -231,6 +231,10 @@ private void handleStreaming(HttpClient httpClient, HttpRequest httpReq, String } } + if (state.isMessageStarted() && !state.isDoneReceived()) { + writer.write(state.buildDoneEvents()); + } + if (debug) { LOG.info("OpenAI proxy: streaming done, total chunks=" + chunkCount); } 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 9c89a4d8..8bdbde48 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/openaiproxy/AnthropicToOpenAITranslatorTest.java @@ -274,4 +274,110 @@ void sseEvent_format() { String event = AnthropicToOpenAITranslator.sseEvent("ping", "{\"type\":\"ping\"}"); assertEquals("event: ping\ndata: {\"type\":\"ping\"}\n\n", event); } + + // ------------------------------------------------------------------------- + // Control character escaping regression tests (fix: use Jackson instead of manual escaping) + // ------------------------------------------------------------------------- + + @Test + void streaming_textWithFormFeedProducesValidJson() throws Exception { + var s = state(); + s.processChunk("{\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"index\":0}]}"); + + String textWithControlChars = "line1\fline2line3line4"; + String chunk = s.processChunk( + "{\"id\":\"c1\",\"choices\":[{\"delta\":{\"content\":" + + AnthropicToOpenAITranslator.MAPPER.writeValueAsString(textWithControlChars) + + "},\"index\":0}]}"); + + // The SSE data line must be valid JSON + String dataLine = extractSseDataLine(chunk, "content_block_delta"); + assertNotNull(dataLine, "Expected content_block_delta event in: " + chunk); + JsonNode parsed = AnthropicToOpenAITranslator.MAPPER.readTree(dataLine); + assertEquals(textWithControlChars, parsed.path("delta").path("text").asText()); + } + + @Test + void streaming_thinkingWithControlCharsProducesValidJson() throws Exception { + var s = state(); + s.processChunk("{\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"index\":0}]}"); + + String thinkingWithCtrl = "thinking\n\tcode:"; + String chunk = s.processChunk( + "{\"id\":\"c1\",\"choices\":[{\"delta\":{\"reasoning_content\":" + + AnthropicToOpenAITranslator.MAPPER.writeValueAsString(thinkingWithCtrl) + + "},\"index\":0}]}"); + + String dataLine = extractSseDataLine(chunk, "content_block_delta"); + assertNotNull(dataLine, "Expected content_block_delta event in: " + chunk); + JsonNode parsed = AnthropicToOpenAITranslator.MAPPER.readTree(dataLine); + assertEquals(thinkingWithCtrl, parsed.path("delta").path("thinking").asText()); + } + + @Test + void streaming_singleChunkFullResponseProducesCompleteStream() throws Exception { + var s = state(); + + // Single-chunk pseudo-streaming: full response + finish_reason in one chunk + String single = s.processChunk( + "{\"id\":\"c1\",\"model\":\"deepseek\",\"choices\":[{\"delta\":{" + + "\"role\":\"assistant\",\"content\":\"Hello world\"},\"finish_reason\":\"stop\",\"index\":0}]," + + "\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":3}}"); + + assertTrue(single.contains("message_start"), "missing message_start: " + single); + assertTrue(single.contains("content_block_start"), "missing content_block_start: " + single); + assertTrue(single.contains("text_delta"), "missing text_delta: " + single); + + String done = s.processChunk("[DONE]"); + assertTrue(done.contains("content_block_stop"), "missing content_block_stop: " + done); + assertTrue(done.contains("message_delta"), "missing message_delta: " + done); + assertTrue(done.contains("message_stop"), "missing message_stop: " + done); + assertTrue(done.contains("end_turn"), "missing end_turn: " + done); + // Usage from the chunk should be in message_delta + JsonNode msgDelta = AnthropicToOpenAITranslator.MAPPER.readTree( + extractSseDataLine(done, "message_delta")); + assertEquals(3, msgDelta.path("usage").path("output_tokens").asInt()); + } + + @Test + void translateRequest_streamOptionsAddedWhenStreamTrue() throws Exception { + ObjectNode result = AnthropicToOpenAITranslator.translateRequest(load("req_simple_text.json")); + // req_simple_text.json has no stream field; stream_options should not be added + assertFalse(result.has("stream_options"), "stream_options must not appear for non-streaming"); + } + + @Test + void translateRequest_streamOptionsAddedForStreamingRequest() throws Exception { + ObjectNode req = (ObjectNode) load("req_simple_text.json"); + req.put("stream", true); + ObjectNode result = AnthropicToOpenAITranslator.translateRequest(req); + assertTrue(result.has("stream_options"), "stream_options missing"); + assertTrue(result.path("stream_options").path("include_usage").asBoolean(), "include_usage not true"); + } + + @Test + void streaming_missingDoneHandledByIsMessageStartedAndIsDoneReceived() { + var s = state(); + assertFalse(s.isMessageStarted()); + assertFalse(s.isDoneReceived()); + + s.processChunk("{\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"delta\":{\"content\":\"hi\"},\"index\":0}]}"); + assertTrue(s.isMessageStarted()); + assertFalse(s.isDoneReceived()); + + s.processChunk("[DONE]"); + assertTrue(s.isDoneReceived()); + } + + /** Extract the JSON data line for the first SSE event with the given name from an event block string. */ + private static String extractSseDataLine(String events, String eventName) { + for (String block : events.split("\n\n")) { + if (block.contains("event: " + eventName)) { + for (String line : block.split("\n")) { + if (line.startsWith("data: ")) return line.substring(6); + } + } + } + return null; + } }