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
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.2.8-SNAPSHOT</version>
<version>1.2.9-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 @@ -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)
Expand Down Expand Up @@ -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;
Expand All @@ -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.
*
Expand All @@ -451,6 +459,7 @@ private static final class ToolCallState {
*/
public String processChunk(String dataLine) {
if ("[DONE]".equals(dataLine)) {
doneReceived = true;
return buildDoneEvents();
}

Expand Down Expand Up @@ -582,99 +591,134 @@ 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()));
out.append(sseEvent("message_stop", "{\"type\":\"message_stop\"}"));
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 "{}";
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Loading