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\fline2line3 line4";
+ 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;
+ }
}