From e529117eb3ef58e52f0d6e529766c95b5598e6f9 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Mon, 12 Oct 2026 17:14:47 +0300 Subject: [PATCH 1/2] fix(providers): keep a streamed tool call's id across deltas that repeat it without `index` (Eve2-F7) The tool-call id rewrite keys a repeated streamed id by ${choice.index}:${call.index}; deltas that repeat id but omit index (the OpenAI-compatible / Gemini-proxy servers the rewrite exists for) minted call_1_2, call_1_3, ... - one logical call became N phantom calls with fragmented arguments. A delta's slot in tool_calls now stands in for the missing index, so a repeated raw id maps back to its first assignment; distinct ids still get distinct ids and indexed behavior is unchanged. --- CHANGELOG.md | 1 + src/providers/providerFetch.ts | 10 +++++++--- src/providers/providerFetchWatch.test.ts | 23 +++++++++++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51d1969f..fff529fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -294,6 +294,7 @@ This section lists what is on `main` and not yet on npm. - The judge score parser reads `Score: 0.9`, `**0.9**`, `9/10`, `7 out of 10`, `85%` and bare whole numbers out of 10 or 100 (`8` is 0.8). Before, the first two scored 0 and `9/10` only reached 1 by clamping. - Registering an agent's tools in another order no longer breaks replay with a mismatch pointing at `tools[0].description`: tools are matched by name, also against cassettes recorded before. - Parallel tool calls that finish behind a slower earlier call are now checkpointed as soon as they settle, keyed by `toolCallId`. Previously only the in-order prefix of finished calls was checkpointed, so a crash while an earlier call was still running made `resume()` re-execute every finished call behind it — side-effecting tools (charges, emails) ran twice. The transcript still records results in the model's call order. +- A streamed tool call whose deltas repeat its `id` while omitting `index` (the OpenAI-compatible / Gemini-proxy servers the tool-call id rewrite exists for) stays one call: each repeat no longer mints a `call_1_2`, `call_1_3`, … id that split the arguments JSON across phantom calls none of which parses; the call's slot in the delta's `tool_calls` stands in for the missing `index`. (Eve2-F7) ### Security - The `@modelcontextprotocol/sdk` peer range is now `^1.31.0` (was `^1.30.1`): 1.30.x is affected by GHSA-6qxp-vccf-f47h (its OAuth client could send credentials to an authorization server chosen by the MCP server), which MCP OAuth (`oauth` on an HTTP server entry) relies on. Upgrade with `npm install @modelcontextprotocol/sdk@^1.31.0`. diff --git a/src/providers/providerFetch.ts b/src/providers/providerFetch.ts index eb7d414c..fe90ddc9 100644 --- a/src/providers/providerFetch.ts +++ b/src/providers/providerFetch.ts @@ -334,7 +334,7 @@ interface ToolCallPayload { choices?: Array<{ index?: unknown; message?: { tool_calls?: unknown }; delta?: { tool_calls?: unknown } }>; } -/** The tool-call ids of one response: each one used, and a stream call's id by its index. */ +/** The tool-call ids of one response: each one used, and a stream call's id by its `index` (or its slot in the delta's `tool_calls` when `index` is omitted). */ class ToolCallIds { private readonly used = new Set(); private readonly byIndex = new Map(); @@ -343,9 +343,13 @@ class ToolCallIds { assign(choice: NonNullable[number]): boolean { const calls = choice?.message?.tool_calls ?? choice?.delta?.tool_calls; let changed = false; - for (const call of Array.isArray(calls) ? (calls as ChatToolCall[]) : []) { + const list = Array.isArray(calls) ? (calls as ChatToolCall[]) : []; + for (const [position, call] of list.entries()) { if (typeof call?.id !== 'string' || call.id === '') continue; - const key = choice.delta !== undefined && call.index !== undefined ? `${String(choice.index)}:${String(call.index)}` : undefined; + // A stream call is keyed by its choice and its `index`; a delta that omits + // `index` still repeats the call at the same slot of its tool_calls, so + // the array position stands in for it. + const key = choice.delta !== undefined ? `${String(choice.index)}:${String(call.index ?? position)}` : undefined; const assigned = this.idFor(call.id, key); if (assigned !== call.id) { call.id = assigned; diff --git a/src/providers/providerFetchWatch.test.ts b/src/providers/providerFetchWatch.test.ts index 2eb14580..8167b147 100644 --- a/src/providers/providerFetchWatch.test.ts +++ b/src/providers/providerFetchWatch.test.ts @@ -124,4 +124,27 @@ describe('uniqueToolCallIds', () => { expect(JSON.parse(stream(delta(1, 'c'))!).choices[0].delta.tool_calls[0].id).toBe('c_2'); expect(JSON.parse(stream(delta(1, 'c'))!).choices[0].delta.tool_calls[0].id).toBe('c_2'); }); + + it("keeps a stream call's id across deltas that repeat it without `index`; a new id at the same slot stays its own call", () => { + const stream = uniqueToolCallIds(); + const delta = (id: string) => JSON.stringify({ choices: [{ index: 0, delta: { tool_calls: [{ id, function: { arguments: 'x' } }] } }] }); + const idOf = (text: string, at = 0) => (JSON.parse(text) as { choices: [{ delta: { tool_calls: Array<{ id: string }> } }] }).choices[0].delta.tool_calls[at].id; + + // The same call repeating its id on each delta, with `index` omitted (quirky OpenAI-compatible / Gemini-proxy servers). + expect(stream(delta('call_1'))).toBeUndefined(); + expect(stream(delta('call_1'))).toBeUndefined(); + expect(stream(delta('call_1'))).toBeUndefined(); + + // A distinct raw id is a distinct call, index or not. + expect(stream(delta('call_2'))).toBeUndefined(); + + // A repeated id inside one delta is still two calls (its array slot stands in for `index`). + const pair = uniqueToolCallIds(); + const two = JSON.stringify({ choices: [{ index: 0, delta: { tool_calls: [{ id: 'x' }, { id: 'x' }] } }] }); + const rewritten = pair(two)!; + expect([idOf(rewritten, 0), idOf(rewritten, 1)]).toEqual(['x', 'x_2']); + // ...and a later delta's unindexed slot-0 `x` is the first of them again, not a third. + const next = JSON.stringify({ choices: [{ index: 0, delta: { tool_calls: [{ id: 'x' }] } }] }); + expect(pair(next)).toBeUndefined(); + }); }); From 1738a5db014034f9f669be54df08065a6c86d23c Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Mon, 12 Oct 2026 17:15:28 +0300 Subject: [PATCH 2/2] fix(providers): detect a streaming request by the parsed top-level `stream` field (Eve2-F8) isStreamingRequest() regexed the raw request body for /"stream"\s*:\s*true/, so a nested field like providerOptions.gateway.stream:true made a non-streamed call look streamed and watchedResponse() rewrote its JSON reply into SSE the consumer cannot parse. The check now JSON.parses the body once and reads the top-level stream field; a malformed body is not a stream. --- CHANGELOG.md | 1 + src/providers/providerFetch.ts | 9 +++++++-- src/providers/providerFetchWatch.test.ts | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fff529fa..3044c8ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -295,6 +295,7 @@ This section lists what is on `main` and not yet on npm. - Registering an agent's tools in another order no longer breaks replay with a mismatch pointing at `tools[0].description`: tools are matched by name, also against cassettes recorded before. - Parallel tool calls that finish behind a slower earlier call are now checkpointed as soon as they settle, keyed by `toolCallId`. Previously only the in-order prefix of finished calls was checkpointed, so a crash while an earlier call was still running made `resume()` re-execute every finished call behind it — side-effecting tools (charges, emails) ran twice. The transcript still records results in the model's call order. - A streamed tool call whose deltas repeat its `id` while omitting `index` (the OpenAI-compatible / Gemini-proxy servers the tool-call id rewrite exists for) stays one call: each repeat no longer mints a `call_1_2`, `call_1_3`, … id that split the arguments JSON across phantom calls none of which parses; the call's slot in the delta's `tool_calls` stands in for the missing `index`. (Eve2-F7) +- A non-streamed request whose body carries a nested `"stream": true` (a `providerOptions` passthrough, tool arguments, gateway metadata) no longer has its JSON reply rewritten into an SSE stream the consumer cannot parse: whether a request asked for streaming is read from the parsed top-level `stream` field instead of a regex over the raw body. (Eve2-F8) ### Security - The `@modelcontextprotocol/sdk` peer range is now `^1.31.0` (was `^1.30.1`): 1.30.x is affected by GHSA-6qxp-vccf-f47h (its OAuth client could send credentials to an authorization server chosen by the MCP server), which MCP OAuth (`oauth` on an HTTP server entry) relies on. Upgrade with `npm install @modelcontextprotocol/sdk@^1.31.0`. diff --git a/src/providers/providerFetch.ts b/src/providers/providerFetch.ts index fe90ddc9..94be01fe 100644 --- a/src/providers/providerFetch.ts +++ b/src/providers/providerFetch.ts @@ -213,9 +213,14 @@ export function providerFetch({ send, timeoutMs, watch }: ProviderFetchOptions): }; } -/** Whether a request body asks for an event stream (`"stream": true`, as every OpenAI-compatible API spells it). */ +/** Whether a request body asks for an event stream (a top-level `"stream": true`, as every OpenAI-compatible API spells it). */ function isStreamingRequest(init: RequestInit | undefined): boolean { - return typeof init?.body === 'string' && /"stream"\s*:\s*true/.test(init.body); + if (typeof init?.body !== 'string') return false; + try { + return (JSON.parse(init.body) as { stream?: unknown } | null)?.stream === true; + } catch { + return false; + } } /** The URL a `fetch` input names. */ diff --git a/src/providers/providerFetchWatch.test.ts b/src/providers/providerFetchWatch.test.ts index 8167b147..00aa8fbf 100644 --- a/src/providers/providerFetchWatch.test.ts +++ b/src/providers/providerFetchWatch.test.ts @@ -71,6 +71,20 @@ describe('providerFetch', () => { expect(await plain.text()).toBe(completion); }); + it('decides streaming by the parsed top-level `stream` field: a nested `"stream":true` or a malformed body keeps its JSON reply', async () => { + const completion = JSON.stringify({ object: 'chat.completion', choices: [{ index: 0, message: { content: 'hi' }, finish_reason: 'stop' }] }); + const fetch = providerFetch({ send: sendOf(completion), watch: chatCompletionWatch }); + + // A nested flag (providerOptions passthrough, tool args) is not the request's own `stream`. + const nested = await fetch('https://x/v1/chat/completions', { method: 'POST', body: JSON.stringify({ providerOptions: { gateway: { stream: true } } }) }); + expect(nested.headers.get('content-type')).toBe('application/json'); + expect(await nested.text()).toBe(completion); + + const malformed = await fetch('https://x/v1/chat/completions', { method: 'POST', body: '{"stream":true' }); + expect(malformed.headers.get('content-type')).toBe('application/json'); + expect(await malformed.text()).toBe(completion); + }); + it("turns a JSON reply with finish_reason 'error' into a 502 error response", async () => { const fetch = providerFetch({ send: sendOf(JSON.stringify({ choices: [{ finish_reason: 'error', message: { content: 'cut' } }] })), watch: chatCompletionWatch }); const response = await fetch('https://x/v1/chat/completions');