diff --git a/changelog.d/fixes/9168-streamed-responses-tool-null.plan.md b/changelog.d/fixes/9168-streamed-responses-tool-null.plan.md new file mode 100644 index 0000000000..d847e0af94 --- /dev/null +++ b/changelog.d/fixes/9168-streamed-responses-tool-null.plan.md @@ -0,0 +1 @@ +- fix(translator): buffer and normalize upstream tool-call argument deltas so optional null values are stripped before reaching the client (#9168) diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 4533b30958..d459350d35 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -874,6 +874,7 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { if (state.currentToolCallId) state.toolCallIdsSeen.add(state.currentToolCallId); const toolName = normalizeToolName(item.name); + state.currentToolName = toolName; // track for schema lookup at done time 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. @@ -919,26 +920,9 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { state.currentToolCallArgsBuffer = (state.currentToolCallArgsBuffer || "") + argsDelta; if (state.currentToolCallDeferred) return null; - return { - id: state.chatId, - object: "chat.completion.chunk", - created: state.created, - model: state.model || "gpt-4", - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: state.toolCallIndex, - function: { arguments: argsDelta }, - }, - ], - }, - finish_reason: null, - }, - ], - }; + // #9168: buffer arguments until output_item.done for schema-aware null normalization + // Previously emitted raw null values for optional enum fields (e.g. isolation: null). + return null; } // Function call done — emit args chunk from item.arguments when no deltas were received, @@ -1011,6 +995,35 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { if (item.arguments != null && !buffered) { const argsToEmit = stripEmptyOptionalToolArgs(item.arguments, toolName, toolSchema); + const argsStr = typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit); + 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, + }, + ], + }; + } + } else if (buffered) { + // #9168: deltas were buffered — normalize against the original client schema + // and emit the cleaned arguments once, stripping optional null values that + // would otherwise reach the client raw. + const argsToEmit = stripEmptyOptionalToolArgs(buffered, toolName, toolSchema); + const argsStr = typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit); if (argsStr) { return { diff --git a/tests/unit/responses-chat-assistant-role-first-chunk.test.ts b/tests/unit/responses-chat-assistant-role-first-chunk.test.ts index de243d6c2d..ee012d97b4 100644 --- a/tests/unit/responses-chat-assistant-role-first-chunk.test.ts +++ b/tests/unit/responses-chat-assistant-role-first-chunk.test.ts @@ -40,13 +40,21 @@ test("Responses->Chat: first tool_call chunk announces role=assistant", () => { ); assert.equal(first.choices[0].delta.tool_calls[0].function.name, "get_weather"); - // Subsequent argument deltas must NOT repeat the role announcement. + // #9168: arguments deltas are buffered until output_item.done for schema normalization. const next = openaiResponsesToOpenAIResponse( { type: "response.function_call_arguments.delta", delta: '{"x":1}' }, state ); - assert.ok(next, "should emit a chunk for arguments.delta"); - assert.equal(next.choices[0].delta.role, undefined, "only the first delta announces the role"); + assert.equal(next, null, "arguments delta should buffer until output_item.done"); + + // The args are emitted at output_item.done, and the role is not re-announced. + const done = openaiResponsesToOpenAIResponse( + { type: "response.output_item.done", item: { type: "function_call", call_id: "call_abc", name: "get_weather" } }, + state + ); + assert.ok(done, "should emit a chunk for output_item.done"); + assert.equal(done.choices[0].delta.role, undefined, "role announcement already happened on first chunk"); + assert.equal(done.choices[0].delta.tool_calls[0].function.arguments, '{"x":1}'); }); test("Responses->Chat: first text chunk announces role=assistant", () => { diff --git a/tests/unit/translator-resp-openai-responses-completed-synthesis.test.ts b/tests/unit/translator-resp-openai-responses-completed-synthesis.test.ts index b7f1436096..fbbf3d6324 100644 --- a/tests/unit/translator-resp-openai-responses-completed-synthesis.test.ts +++ b/tests/unit/translator-resp-openai-responses-completed-synthesis.test.ts @@ -203,7 +203,8 @@ test("Responses -> OpenAI: incremental tool call events + response.completed sna }, state ); - assert.ok(args, "should emit args delta chunk"); + // #9168: arguments deltas are buffered until output_item.done for schema normalization + assert.equal(args, null, "args delta should buffer until output_item.done"); openaiResponsesToOpenAIResponse( { diff --git a/tests/unit/translator-resp-openai-responses.test.ts b/tests/unit/translator-resp-openai-responses.test.ts index 3239412464..3eeef460b8 100644 --- a/tests/unit/translator-resp-openai-responses.test.ts +++ b/tests/unit/translator-resp-openai-responses.test.ts @@ -474,7 +474,7 @@ test("Responses -> OpenAI: tool-call delta, reasoning delta and completed usage }, state ); - openaiResponsesToOpenAIResponse( + const done = openaiResponsesToOpenAIResponse( { type: "response.output_item.done", item: { type: "function_call", call_id: "call_2", name: "weather" }, @@ -497,7 +497,10 @@ test("Responses -> OpenAI: tool-call delta, reasoning delta and completed usage ); assert.equal(added.choices[0].delta.tool_calls[0].function.name, "weather"); - assert.equal(args.choices[0].delta.tool_calls[0].function.arguments, '{"city":"SP"}'); + // #9168: function_call_arguments.delta is buffered and returns null; + // arguments are emitted by output_item.done instead. + assert.equal(args, null); + assert.equal(done.choices[0].delta.tool_calls[0].function.arguments, '{"city":"SP"}'); assert.equal(reasoning.choices[0].delta.reasoning_content, "Need weather info."); assert.equal(completed.choices[0].finish_reason, "tool_calls"); const comp = completed as {