fix: buffer and normalize Responses tool-call argument deltas, stripping optional null before reaching the client (#9168)

Refs: base-red #9737
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-08 12:07:24 -03:00
committed by GitHub
parent e7d9055314
commit 492f9ddc4a
5 changed files with 52 additions and 26 deletions

View File

@@ -0,0 +1 @@
- fix(translator): buffer and normalize upstream tool-call argument deltas so optional null values are stripped before reaching the client (#9168)

View File

@@ -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 {

View File

@@ -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", () => {

View File

@@ -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(
{

View File

@@ -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 {