fix(api): merge id-only tool_call continuation deltas in stream summary (#6276) (#6905)

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-11 11:22:22 -03:00
committed by GitHub
parent adb1fc5b27
commit 0a358c02ab
3 changed files with 159 additions and 2 deletions

View File

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

View File

@@ -129,13 +129,29 @@ function buildOpenAISummary(events: StructuredSSEEvent[], fallbackModel?: string
function: { name: string; arguments: string };
};
const toolCalls = new Map<string, ToolCall>();
// 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<string, string>();
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}`;
};

View File

@@ -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<string, unknown>, 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"}');
});