diff --git a/changelog.d/fixes/6276-toolcall-args-logs.md b/changelog.d/fixes/6276-toolcall-args-logs.md new file mode 100644 index 0000000000..90b6bde61b --- /dev/null +++ b/changelog.d/fixes/6276-toolcall-args-logs.md @@ -0,0 +1 @@ +- fix(api): merge tool_call continuation deltas that carry only `id` (no `index`) so tool-call arguments are no longer split/lost in request/response logs (#6276) diff --git a/open-sse/utils/streamPayloadCollector.ts b/open-sse/utils/streamPayloadCollector.ts index 26e9f4418e..63cb901179 100644 --- a/open-sse/utils/streamPayloadCollector.ts +++ b/open-sse/utils/streamPayloadCollector.ts @@ -129,13 +129,29 @@ function buildOpenAISummary(events: StructuredSSEEvent[], fallbackModel?: string function: { name: string; arguments: string }; }; const toolCalls = new Map(); + // Aliases every `idx:N` key we've seen to the `id:X` it was first observed with (and + // vice versa), so a later delta chunk that only carries one of the two dimensions + // (e.g. a continuation chunk with `id` but no `index` — a known quirk of some + // OpenAI-compatible proxies) still resolves to the SAME accumulator entry instead of + // splitting one logical tool call into two (#6276). + const keyAliases = new Map(); let unknownToolCallSeq = 0; let finishReason = "stop"; let usage: JsonRecord | null = null; const getToolCallKey = (toolCall: JsonRecord) => { - if (Number.isInteger(toolCall.index)) return `idx:${toolCall.index}`; - if (toolCall.id) return `id:${toolCall.id}`; + const idKey = typeof toolCall.id === "string" && toolCall.id ? `id:${toolCall.id}` : null; + const idxKey = Number.isInteger(toolCall.index) ? `idx:${toolCall.index}` : null; + + const resolvedKey = (idKey && keyAliases.get(idKey)) || (idxKey && keyAliases.get(idxKey)); + const key = resolvedKey || idKey || idxKey; + + if (key) { + if (idKey) keyAliases.set(idKey, key); + if (idxKey) keyAliases.set(idxKey, key); + return key; + } + unknownToolCallSeq += 1; return `seq:${unknownToolCallSeq}`; }; diff --git a/tests/unit/stream-payload-collector.test.ts b/tests/unit/stream-payload-collector.test.ts index 1cb9e58b85..669edcdd1e 100644 --- a/tests/unit/stream-payload-collector.test.ts +++ b/tests/unit/stream-payload-collector.test.ts @@ -75,3 +75,143 @@ test("createStructuredSSECollector collector has expected methods", () => { const keys = Object.keys(c); assert.ok(keys.length > 0); }); + +// #6276 — tool_call arguments lost in request/response logs when a continuation +// delta omits `index` (some OpenAI-compatible proxies only send `index` on the +// FIRST tool_call delta chunk, then only `id` on subsequent chunks). + +type ToolCallSummary = { + choices: Array<{ + message: { + tool_calls: Array<{ function: { name: string; arguments: string } }>; + }; + }>; +}; + +function toolCallEvent(delta: Record, finishReason?: string) { + return { + index: 0, + data: { + id: "chatcmpl-1", + object: "chat.completion.chunk", + created: 1, + model: "deepseek-v4-flash-free", + choices: [{ index: 0, delta, ...(finishReason ? { finish_reason: finishReason } : {}) }], + }, + }; +} + +test("buildStreamSummaryFromEvents merges tool_call deltas when every chunk carries `index` (happy path)", () => { + const events = [ + toolCallEvent({ + role: "assistant", + tool_calls: [ + { index: 0, id: "call_a", type: "function", function: { name: "Bash", arguments: "" } }, + ], + }), + toolCallEvent({ + tool_calls: [{ index: 0, id: "call_a", type: "function", function: { arguments: '{"x":1}' } }], + }), + toolCallEvent({}, "tool_calls"), + ]; + + const summary = collector.buildStreamSummaryFromEvents( + events, + "openai", + "deepseek-v4-flash-free" + ) as ToolCallSummary; + const toolCalls = summary.choices[0].message.tool_calls; + + assert.equal(toolCalls.length, 1); + assert.equal(toolCalls[0].function.name, "Bash"); + assert.equal(toolCalls[0].function.arguments, '{"x":1}'); +}); + +test("buildStreamSummaryFromEvents merges a continuation delta that carries only `id` (no `index`) into the initiating tool_call (#6276)", () => { + const events = [ + toolCallEvent({ + role: "assistant", + tool_calls: [ + { + index: 0, + id: "call_00_xasdOvEWoeldzXAqFPQP2849", + type: "function", + function: { name: "Bash", arguments: "" }, + }, + ], + }), + // Continuation chunk omits `index`, carries only `id` + arguments fragment. + toolCallEvent({ + tool_calls: [ + { + id: "call_00_xasdOvEWoeldzXAqFPQP2849", + type: "function", + function: { arguments: '{"command": "date' }, + }, + ], + }), + toolCallEvent({ + tool_calls: [ + { + id: "call_00_xasdOvEWoeldzXAqFPQP2849", + type: "function", + function: { arguments: '"}' }, + }, + ], + }), + toolCallEvent({}, "tool_calls"), + ]; + + const summary = collector.buildStreamSummaryFromEvents( + events, + "openai", + "deepseek-v4-flash-free" + ) as ToolCallSummary; + const toolCalls = summary.choices[0].message.tool_calls; + + assert.equal( + toolCalls.length, + 1, + `expected 1 tool_call, got ${toolCalls.length}: ${JSON.stringify(toolCalls)}` + ); + assert.equal(toolCalls[0].function.name, "Bash"); + assert.equal(toolCalls[0].function.arguments, '{"command": "date"}'); +}); + +test("buildStreamSummaryFromEvents keeps two genuinely different interleaved tool_calls separate", () => { + const events = [ + toolCallEvent({ + role: "assistant", + tool_calls: [ + { index: 0, id: "call_a", type: "function", function: { name: "Bash", arguments: "" } }, + { index: 1, id: "call_b", type: "function", function: { name: "Read", arguments: "" } }, + ], + }), + toolCallEvent({ + tool_calls: [ + { index: 0, id: "call_a", type: "function", function: { arguments: '{"cmd":"a"' } }, + { index: 1, id: "call_b", type: "function", function: { arguments: '{"path":"b"' } }, + ], + }), + toolCallEvent({ + tool_calls: [ + { index: 0, id: "call_a", type: "function", function: { arguments: "}" } }, + { index: 1, id: "call_b", type: "function", function: { arguments: "}" } }, + ], + }), + toolCallEvent({}, "tool_calls"), + ]; + + const summary = collector.buildStreamSummaryFromEvents( + events, + "openai", + "deepseek-v4-flash-free" + ) as ToolCallSummary; + const toolCalls = summary.choices[0].message.tool_calls; + + assert.equal(toolCalls.length, 2); + assert.equal(toolCalls[0].function.name, "Bash"); + assert.equal(toolCalls[0].function.arguments, '{"cmd":"a"}'); + assert.equal(toolCalls[1].function.name, "Read"); + assert.equal(toolCalls[1].function.arguments, '{"path":"b"}'); +});