From c8240c51d664f7cfb790b6d60679adfe0490b5c9 Mon Sep 17 00:00:00 2001 From: Hako <25837994+devswha@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:42:15 +0900 Subject: [PATCH] fix(adapters): retain late chat tool-call index aliases --- .../src/content/docs/reference/adapters.md | 5 + src/adapters/openai-chat.ts | 15 ++- .../openai-chat-parallel-stream.test.ts | 126 +++++++++++++++++- 3 files changed, 135 insertions(+), 11 deletions(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 1db98357d3..930c4d8d15 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -49,6 +49,11 @@ provider — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (local), and mor tiers, accepts reasoning deltas from either `delta.reasoning_content` or `delta.reasoning`, requests streamed usage with `stream_options.include_usage`, and reads usage from non-stream response envelopes. +Streaming tool calls retain their identity when a provider first sends an ID, +then associates that ID with an index, and later sends index-only argument +fragments. Those fragments assemble into one call with the original name and +complete arguments; parallel calls retain separate identities. + ## `ollama-native` **Targets:** Ollama's own **Chat API** (`POST /api/chat`) rather than its OpenAI-compatible diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 8b7d9c8614..2620ce36f6 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1660,6 +1660,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd let bufferBytes = 0; interface PendingToolCall { key: string; + indexKey?: string; id: string; name: string; args: string; @@ -1853,12 +1854,12 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd // streamers repeat an already-sent field as a non-string placeholder on a // continuation delta; judging first meant the whole stream died with a 502 even // though the value being repeated was already held in canonical form. - const key = typeof rawIndex === "number" - ? `i:${rawIndex}` - : idDelta - ? `id:${idDelta}` - : pendingToolCalls[pendingToolCalls.length - 1]?.key; + const indexKey = typeof rawIndex === "number" ? `i:${rawIndex}` : undefined; + const key = indexKey ?? (idDelta + ? `id:${idDelta}` + : pendingToolCalls[pendingToolCalls.length - 1]?.key); let call = key !== undefined ? pendingToolCalls.find(c => c.key === key) : undefined; + if (!call && indexKey !== undefined) call = pendingToolCalls.find(c => c.indexKey === indexKey); if (!call && idDelta) call = pendingToolCalls.find(c => c.id === idDelta); if (!call) { call = { @@ -1872,6 +1873,10 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd pendingToolCalls.push(call); budget.openCall(call.key); } + // An ID-only call may learn its index from a later ID+index fragment. Retain that + // alias without changing the key that owns its argument budget. Only the first + // observed index binds: a repeated ID on a different index must not alias both. + if (indexKey !== undefined && call.indexKey === undefined) call.indexKey = indexKey; // Tolerance is per FIELD, keyed on that field's own provenance. A canonical name // says nothing about whether `arguments` was ever sent as a string, so it cannot diff --git a/tests/adapters/openai/openai-chat-parallel-stream.test.ts b/tests/adapters/openai/openai-chat-parallel-stream.test.ts index 238faa9082..ec1e3d55cd 100644 --- a/tests/adapters/openai/openai-chat-parallel-stream.test.ts +++ b/tests/adapters/openai/openai-chat-parallel-stream.test.ts @@ -1,16 +1,17 @@ import { describe, expect, test } from "bun:test"; import { createOpenAIChatAdapter as createOpenAIChatAdapterProduction } from "../../../src/adapters/openai-chat"; +import type { TranslatorBudget } from "../../../src/lib/translator-budget"; import type { AdapterEvent } from "../../../src/types"; -import { withTestTranslatorBudget } from "../../helpers/translator-budget"; +import { createTestTranslatorBudget, withTestTranslatorBudget } from "../../helpers/translator-budget"; const createOpenAIChatAdapter = (...args: Parameters) => withTestTranslatorBudget(createOpenAIChatAdapterProduction(...args)); const provider = { adapter: "openai-chat", baseUrl: "https://example.test/v1", apiKey: "key" }; -async function collect(body: string): Promise { +async function collect(body: string, budget?: TranslatorBudget): Promise { const out: AdapterEvent[] = []; - for await (const e of createOpenAIChatAdapter(provider).parseStream(new Response(body))) out.push(e); + for await (const e of createOpenAIChatAdapter(provider).parseStream(new Response(body), budget)) out.push(e); return out; } @@ -211,12 +212,125 @@ describe("openai-chat parallel tool call stream assembly", () => { expect(assembled(events)).toEqual([{ id: "call_a", name: "shell", args: "{\"cmd\":\"ls\"}" }]); }); - test("T9b: id-only first chunk followed by index+id continuation stays ONE call", async () => { + test("T9b: id-only call retains a later index for index-only continuation", async () => { + const budget = createTestTranslatorBudget(); const events = await collect(sse([ chunkOf([{ id: "call_b", function: { name: "read", arguments: "{\"p\"" } }]), - chunkOf([{ index: 0, id: "call_b", function: { arguments: ":\"x\"}" } }]), + chunkOf([{ index: 0, id: "call_b", function: { arguments: ":\"x\"" } }]), + chunkOf([{ index: 0, function: { arguments: "}" } }]), chunkOf([], "tool_calls"), - ])); + ]), budget); expect(assembled(events)).toEqual([{ id: "call_b", name: "read", args: "{\"p\":\"x\"}" }]); + expect(events.at(-1)?.type).toBe("done"); + expect(budget.snapshot()).toMatchObject({ activeCalls: 0, currentBytes: 0, overflows: 0 }); + }); + + test("late indexes keep interleaved calls separate without adding budget owners", async () => { + const budget = createTestTranslatorBudget(); + const response = new Response(sse([ + chunkOf([ + { id: "call_a", function: { name: "read", arguments: "{\"p\":" } }, + { id: "call_b", function: { name: "write", arguments: "{\"p\":" } }, + ]), + chunkOf([{ index: 9, id: "call_b", function: { arguments: "\"b\"" } }]), + chunkOf([{ index: 4, id: "call_a", function: { arguments: "\"a\"" } }]), + chunkOf([{ index: 9, function: { arguments: "}" } }]), + chunkOf([{ index: 4, function: { arguments: "}" } }]), + chunkOf([{ id: "call_a", function: { arguments: " " } }]), + chunkOf([], "tool_calls"), + ])); + const events: AdapterEvent[] = []; + let maxActiveCalls = 0; + for await (const event of createOpenAIChatAdapter(provider).parseStream(response, budget)) { + events.push(event); + maxActiveCalls = Math.max(maxActiveCalls, budget.snapshot().activeCalls); + } + expect(assembled(events)).toEqual([ + { id: "call_a", name: "read", args: "{\"p\":\"a\"} " }, + { id: "call_b", name: "write", args: "{\"p\":\"b\"}" }, + ]); + expect(events.at(-1)?.type).toBe("done"); + expect(maxActiveCalls).toBe(2); + expect(budget.snapshot()).toMatchObject({ activeCalls: 0, currentBytes: 0, overflows: 0 }); + }); + + test("index-only fragments do not guess an association between unindexed calls", async () => { + const events = await collect(sse([ + chunkOf([ + { id: "call_a", function: { name: "read", arguments: "{\"p\":" } }, + { id: "call_b", function: { name: "write", arguments: "{\"p\":" } }, + ]), + chunkOf([{ index: 0, function: { arguments: "\"a\"}" } }]), + chunkOf([{ index: 1, function: { arguments: "\"b\"}" } }]), + chunkOf([], "tool_calls"), + ])); + expect(events.at(-1)?.type).toBe("error"); + expect(events.some(event => event.type === "done")).toBe(false); + }); + + test("an observed index wins over a conflicting ID without rebinding either call", async () => { + const events = await collect(sse([ + chunkOf([{ id: "call_a", function: { name: "read", arguments: "{\"p\":" } }]), + chunkOf([{ index: 1, id: "call_b", function: { name: "write", arguments: "{\"p\":" } }]), + chunkOf([{ index: 0, id: "call_a", function: { arguments: "\"a\"" } }]), + chunkOf([{ index: 1, id: "call_a", function: { arguments: "\"b\"}" } }]), + chunkOf([{ index: 0, id: "call_b", function: { arguments: "}" } }]), + chunkOf([{ index: 0, function: { arguments: " " } }]), + chunkOf([], "tool_calls"), + ])); + expect(assembled(events)).toEqual([ + { id: "call_a", name: "read", args: "{\"p\":\"a\"} " }, + { id: "call_b", name: "write", args: "{\"p\":\"b\"}" }, + ]); + expect(events.at(-1)?.type).toBe("done"); + }); + + test("duplicate IDs on established indexed calls keep first-match ID fallback", async () => { + const events = await collect(sse([ + chunkOf([ + { index: 0, function: { name: "read", arguments: "{\"p\":" } }, + { index: 1, function: { name: "write", arguments: "{\"p\":" } }, + ]), + chunkOf([{ index: 0, id: "shared", function: { arguments: "\"a\"" } }]), + chunkOf([{ index: 1, id: "shared", function: { arguments: "\"b\"" } }]), + chunkOf([{ id: "shared", function: { arguments: "}" } }]), + chunkOf([{ index: 1, function: { arguments: "}" } }]), + chunkOf([], "tool_calls"), + ])); + expect(assembled(events)).toEqual([ + { id: "shared", name: "read", args: "{\"p\":\"a\"}" }, + { id: "shared", name: "write", args: "{\"p\":\"b\"}" }, + ]); + expect(events.at(-1)?.type).toBe("done"); + }); + + test("a repeated ID on a different index does not replace the first observed alias", async () => { + const events = await collect(sse([ + chunkOf([{ id: "call_a", function: { name: "read", arguments: "{\"p\":" } }]), + chunkOf([{ index: 0, id: "call_a", function: { arguments: "\"a\"" } }]), + chunkOf([{ index: 1, id: "call_a", function: { arguments: "" } }]), + chunkOf([{ index: 0, function: { arguments: "}" } }]), + chunkOf([], "tool_calls"), + ])); + expect(assembled(events)).toEqual([{ id: "call_a", name: "read", args: "{\"p\":\"a\"}" }]); + expect(events.at(-1)?.type).toBe("done"); + }); + + test.each([9, 10])("late index preserves a %i-byte argument limit across all fragments", async limit => { + const budget = createTestTranslatorBudget({ maxCallArgumentBytes: limit }); + const events = await collect(sse([ + chunkOf([{ id: "call_a", function: { name: "read", arguments: "{\"p\":" } }]), + chunkOf([{ index: 0, id: "call_a", function: { arguments: "\"é\"" } }]), + chunkOf([{ index: 0, function: { arguments: "}" } }]), + chunkOf([], "tool_calls"), + ]), budget); + if (limit === 9) { + expect(events.at(-1)).toMatchObject({ type: "error", code: "translation_buffer_limit" }); + expect(events.some(event => event.type === "tool_call_start")).toBe(false); + } else { + expect(assembled(events)).toEqual([{ id: "call_a", name: "read", args: "{\"p\":\"é\"}" }]); + expect(events.at(-1)?.type).toBe("done"); + } + expect(budget.snapshot()).toMatchObject({ activeCalls: 0, currentBytes: 0, overflows: limit === 9 ? 1 : 0 }); }); });