diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 40ec923bd4..5c9953147a 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -5,6 +5,10 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; +function normalizeToolName(value) { + return typeof value === "string" ? value.trim() : ""; +} + /** * Translate OpenAI chunk to Responses API events * @returns {Array} Array of events with { event, data } structure @@ -477,6 +481,16 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { if (eventType === "response.output_item.added" && data.item?.type === "function_call") { const item = data.item; state.currentToolCallId = item.call_id || `call_${Date.now()}`; + state.currentToolCallArgsBuffer = ""; // reset per-call arg buffer + state.currentToolCallDeferred = false; + + const toolName = normalizeToolName(item.name); + if (!toolName) { + // Some Responses providers briefly emit placeholder/empty tool names. + // Defer emission until output_item.done in case the final name is populated there. + state.currentToolCallDeferred = true; + return null; + } return { id: state.chatId, @@ -493,7 +507,7 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { id: state.currentToolCallId, type: "function", function: { - name: item.name || "", + name: toolName, arguments: "", }, }, @@ -513,6 +527,9 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { const argsDelta = data.delta || ""; if (!argsDelta) return null; + state.currentToolCallArgsBuffer = (state.currentToolCallArgsBuffer || "") + argsDelta; + if (state.currentToolCallDeferred) return null; + return { id: state.chatId, object: "chat.completion.chunk", @@ -535,9 +552,93 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { }; } - // Function call done + // Function call done — emit args chunk from item.arguments when no deltas were received, + // then advance the tool-call index. This handles Codex Responses API payloads that + // carry the complete arguments only in output_item.done (no preceding delta events). if (eventType === "response.output_item.done" && data.item?.type === "function_call") { + const item = data.item; + const buffered = state.currentToolCallArgsBuffer || ""; + const currentIndex = state.toolCallIndex; // capture before increment + const callId = item.call_id || state.currentToolCallId || `call_${Date.now()}`; + const toolName = normalizeToolName(item.name); + + if (state.currentToolCallDeferred) { + state.currentToolCallDeferred = false; + state.currentToolCallArgsBuffer = ""; + state.currentToolCallId = null; + + if (!toolName) { + return null; + } + + state.toolCallIndex++; + + const argsStr = + item.arguments != null + ? typeof item.arguments === "string" + ? item.arguments + : JSON.stringify(item.arguments) + : buffered; + + return { + id: state.chatId, + object: "chat.completion.chunk", + created: state.created, + model: state.model || "gpt-4", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: currentIndex, + id: callId, + type: "function", + function: { + name: toolName, + arguments: argsStr || "", + }, + }, + ], + }, + finish_reason: null, + }, + ], + }; + } + state.toolCallIndex++; + state.currentToolCallArgsBuffer = ""; // reset for next tool call + state.currentToolCallId = null; + + // Only emit if arguments exist in the done event AND they weren't already streamed via deltas + if (item.arguments != null && !buffered) { + const argsStr = + typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments); + if (argsStr) { + return { + id: state.chatId, + object: "chat.completion.chunk", + created: state.created, + model: state.model || "gpt-4", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: currentIndex, + function: { arguments: argsStr }, + }, + ], + }, + finish_reason: null, + }, + ], + }; + } + } + return null; } @@ -611,11 +712,13 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { object: "chat.completion.chunk", created: state.created, model: state.model || "gpt-4", - choices: [{ - index: 0, - delta: { reasoning_content: reasoningDelta }, - finish_reason: null, - }], + choices: [ + { + index: 0, + delta: { reasoning_content: reasoningDelta }, + finish_reason: null, + }, + ], }; } diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index e5f264f34c..d2da946adf 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -10,7 +10,13 @@ import { filterUsageForFormat, COLORS, } from "./usageTracking.ts"; -import { parseSSELine, hasValuableContent, fixInvalidId, formatSSE, unwrapGeminiChunk } from "./streamHelpers.ts"; +import { + parseSSELine, + hasValuableContent, + fixInvalidId, + formatSSE, + unwrapGeminiChunk, +} from "./streamHelpers.ts"; import { createStructuredSSECollector, buildStreamSummaryFromEvents, @@ -585,9 +591,12 @@ export function createSSEStream(options: StreamOptions = {}) { // Content for call log is accumulated only from parsed (above) to avoid double-counting; // do not add again from item here. - // #723, #727: Sanitize intermediate stream chunks if target is OpenAI format loop + // #723, #727: Sanitize only when the client-facing stream is OpenAI Chat format. + // When translating Responses -> Claude, `item` is already a Claude SSE event; + // sanitizing it as an OpenAI chunk strips message_start/content_block_delta/message_stop + // and causes Claude Code to drop the assistant message. let itemSanitized: Record = item; - if (targetFormat === FORMATS.OPENAI || targetFormat === FORMATS.OPENAI_RESPONSES) { + if (sourceFormat === FORMATS.OPENAI) { itemSanitized = sanitizeStreamingChunk(itemSanitized) as Record; // Extract reasoning tags from content if translation generated them diff --git a/tests/unit/claude-code-rendering-fixes.test.mjs b/tests/unit/claude-code-rendering-fixes.test.mjs new file mode 100644 index 0000000000..09cce6d217 --- /dev/null +++ b/tests/unit/claude-code-rendering-fixes.test.mjs @@ -0,0 +1,195 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiResponsesToOpenAIResponse } = await import( + "../../open-sse/translator/response/openai-responses.ts" +); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); +const { createSSETransformStreamWithLogger } = await import("../../open-sse/utils/stream.ts"); + +test("Responses->Chat: output_item.done emits arguments when no delta chunks were sent", () => { + const state = { + started: true, + chatId: "chatcmpl-test", + created: 1234567890, + toolCallIndex: 0, + finishReasonSent: false, + currentToolCallId: "call_abc", + currentToolCallArgsBuffer: "", + }; + + const chunk = { + type: "response.output_item.done", + item: { + type: "function_call", + call_id: "call_abc", + name: "search_tasks", + status: "completed", + arguments: '{"query":"select:TaskCreate,TaskUpdate","max_results":10}', + }, + }; + + const result = openaiResponsesToOpenAIResponse(chunk, state); + + assert.ok(result); + assert.equal( + result.choices[0].delta.tool_calls[0].function.arguments, + '{"query":"select:TaskCreate,TaskUpdate","max_results":10}' + ); + assert.equal(state.toolCallIndex, 1); +}); + +test("Responses->Chat: output_item.done does not re-emit arguments already streamed via deltas", () => { + const state = { + started: true, + chatId: "chatcmpl-test", + created: 1234567890, + toolCallIndex: 0, + finishReasonSent: false, + currentToolCallId: "call_abc", + currentToolCallArgsBuffer: '{"query":"search"}', + }; + + const chunk = { + type: "response.output_item.done", + item: { + type: "function_call", + call_id: "call_abc", + name: "search", + status: "completed", + arguments: '{"query":"search"}', + }, + }; + + const result = openaiResponsesToOpenAIResponse(chunk, state); + + assert.equal(result, null); + assert.equal(state.toolCallIndex, 1); +}); + +test("Responses->Chat: empty-name tool call is deferred until done provides a valid name", () => { + const state = { + started: true, + chatId: "chatcmpl-test", + created: 1234567890, + toolCallIndex: 0, + finishReasonSent: false, + currentToolCallArgsBuffer: "", + currentToolCallDeferred: false, + }; + + const added = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.added", + item: { type: "function_call", call_id: "call_deferred", name: " " }, + }, + state + ); + assert.equal(added, null); + + const delta = openaiResponsesToOpenAIResponse( + { + type: "response.function_call_arguments.delta", + delta: '{"query":"deferred"}', + }, + state + ); + assert.equal(delta, null); + + const done = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.done", + item: { + type: "function_call", + call_id: "call_deferred", + name: "search_tasks", + arguments: '{"query":"deferred"}', + }, + }, + state + ); + + assert.ok(done); + assert.equal(done.choices[0].delta.tool_calls[0].function.name, "search_tasks"); + assert.equal(done.choices[0].delta.tool_calls[0].function.arguments, '{"query":"deferred"}'); +}); + +test("Responses->Chat: empty-name tool call is dropped when done still has no valid name", () => { + const state = { + started: true, + chatId: "chatcmpl-test", + created: 1234567890, + toolCallIndex: 0, + finishReasonSent: false, + currentToolCallArgsBuffer: "", + currentToolCallDeferred: false, + }; + + openaiResponsesToOpenAIResponse( + { + type: "response.output_item.added", + item: { type: "function_call", call_id: "call_empty", name: "" }, + }, + state + ); + + const done = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.done", + item: { + type: "function_call", + call_id: "call_empty", + name: " ", + arguments: '{"ignored":true}', + }, + }, + state + ); + + assert.equal(done, null); + assert.equal(state.toolCallIndex, 0); +}); + +test("Responses->Claude: translated Claude SSE is not sanitized into empty OpenAI chunks", async () => { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const stream = createSSETransformStreamWithLogger( + FORMATS.OPENAI_RESPONSES, + FORMATS.CLAUDE, + "codex", + null, + null, + "gpt-5.4", + "conn-test", + { messages: [{ role: "user", content: "hi" }] }, + null, + null + ); + + const writer = stream.writable.getWriter(); + await writer.write( + encoder.encode('data: {"type":"response.output_text.delta","delta":"hello"}\n\n') + ); + await writer.write( + encoder.encode( + 'data: {"type":"response.completed","response":{"usage":{"input_tokens":12,"output_tokens":3}}}\n\n' + ) + ); + await writer.close(); + + const reader = stream.readable.getReader(); + let output = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + output += decoder.decode(value, { stream: true }); + } + output += decoder.decode(); + + assert.match(output, /event: message_start/); + assert.match(output, /event: content_block_start/); + assert.match(output, /event: content_block_delta/); + assert.match(output, /event: message_delta/); + assert.match(output, /event: message_stop/); + assert.doesNotMatch(output, /data: \{"object":"chat\.completion\.chunk"\}\n\n/); +});