Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,8 @@ 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)
- 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`.
Expand Down
19 changes: 14 additions & 5 deletions src/providers/providerFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -334,7 +339,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<string>();
private readonly byIndex = new Map<string, { original: string; assigned: string }>();
Expand All @@ -343,9 +348,13 @@ class ToolCallIds {
assign(choice: NonNullable<ToolCallPayload['choices']>[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;
Expand Down
37 changes: 37 additions & 0 deletions src/providers/providerFetchWatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -124,4 +138,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();
});
});
Loading