From e2b40dfc8198589f548f409d6986875655168fc4 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 14 Apr 2026 18:37:06 +0200 Subject: [PATCH] fix(ai): strip partialJson from responses tool calls Mutate persisted tool-call blocks in place on function_call completion, remove partialJson, and emit the same reference on toolcall_end. Add regression coverage for persisted block cleanup and event identity. fixes #3078 --- packages/ai/src/providers/amazon-bedrock.ts | 3 + packages/ai/src/providers/anthropic.ts | 10 +- .../src/providers/azure-openai-responses.ts | 6 +- packages/ai/src/providers/mistral.ts | 6 ++ .../src/providers/openai-codex-responses.ts | 4 + .../ai/src/providers/openai-completions.ts | 8 +- .../src/providers/openai-responses-shared.ts | 22 ++-- packages/ai/src/providers/openai-responses.ts | 6 +- ...nai-responses-partial-json-cleanup.test.ts | 101 ++++++++++++++++++ 9 files changed, 155 insertions(+), 11 deletions(-) create mode 100644 packages/ai/test/openai-responses-partial-json-cleanup.test.ts diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index f706bc0f..a0aa55c3 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -211,6 +211,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt } catch (error) { for (const block of output.content) { delete (block as Block).index; + // partialJson is only a streaming scratch buffer; never persist it. delete (block as Block).partialJson; } output.stopReason = options.signal?.aborted ? "aborted" : "error"; @@ -413,6 +414,8 @@ function handleContentBlockStop( break; case "toolCall": block.arguments = parseStreamingJson(block.partialJson); + // Finalize in-place and strip the scratch buffer so replay only + // carries parsed arguments. delete (block as Block).partialJson; stream.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: output }); break; diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 9b78f98c..e7bf7019 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -384,7 +384,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti }); } else if (block.type === "toolCall") { block.arguments = parseStreamingJson(block.partialJson); - delete (block as any).partialJson; + // Finalize in-place and strip the scratch buffer so replay only + // carries parsed arguments. + delete (block as { partialJson?: string }).partialJson; stream.push({ type: "toolcall_end", contentIndex: index, @@ -429,7 +431,11 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti stream.push({ type: "done", reason: output.stopReason, message: output }); stream.end(); } catch (error) { - for (const block of output.content) delete (block as any).index; + for (const block of output.content) { + delete (block as { index?: number }).index; + // partialJson is only a streaming scratch buffer; never persist it. + delete (block as { partialJson?: string }).partialJson; + } output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); stream.push({ type: "error", reason: output.stopReason, error: output }); diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts index f72cad1c..9dc5261e 100644 --- a/packages/ai/src/providers/azure-openai-responses.ts +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -109,7 +109,11 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses" stream.push({ type: "done", reason: output.stopReason, message: output }); stream.end(); } catch (error) { - for (const block of output.content) delete (block as { index?: number }).index; + for (const block of output.content) { + delete (block as { index?: number }).index; + // partialJson is only a streaming scratch buffer; never persist it. + delete (block as { partialJson?: string }).partialJson; + } output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); stream.push({ type: "error", reason: output.stopReason, error: output }); diff --git a/packages/ai/src/providers/mistral.ts b/packages/ai/src/providers/mistral.ts index c70e72cf..9685a296 100644 --- a/packages/ai/src/providers/mistral.ts +++ b/packages/ai/src/providers/mistral.ts @@ -89,6 +89,10 @@ export const streamMistral: StreamFunction<"mistral-conversations", MistralOptio stream.push({ type: "done", reason: output.stopReason, message: output }); stream.end(); } catch (error) { + for (const block of output.content) { + // partialArgs is only a streaming scratch buffer; never persist it. + delete (block as { partialArgs?: string }).partialArgs; + } output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.errorMessage = formatMistralError(error); stream.push({ type: "error", reason: output.stopReason, error: output }); @@ -424,6 +428,8 @@ async function consumeChatStream( if (block.type !== "toolCall") continue; const toolBlock = block as ToolCall & { partialArgs?: string }; toolBlock.arguments = parseStreamingJson>(toolBlock.partialArgs); + // Finalize in-place and strip the scratch buffer so replay only + // carries parsed arguments. delete toolBlock.partialArgs; stream.push({ type: "toolcall_end", diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 1574e178..fd8ba1f0 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -268,6 +268,10 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" stream.push({ type: "done", reason: output.stopReason as "stop" | "length" | "toolUse", message: output }); stream.end(); } catch (error) { + for (const block of output.content) { + // partialJson is only a streaming scratch buffer; never persist it. + delete (block as { partialJson?: string }).partialJson; + } output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.errorMessage = error instanceof Error ? error.message : String(error); stream.push({ type: "error", reason: output.stopReason, error: output }); diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index ef4968dd..6656540f 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -115,6 +115,8 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA }); } else if (block.type === "toolCall") { block.arguments = parseStreamingJson(block.partialArgs); + // Finalize in-place and strip the scratch buffer so replay only + // carries parsed arguments. delete block.partialArgs; stream.push({ type: "toolcall_end", @@ -289,7 +291,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA stream.push({ type: "done", reason: output.stopReason, message: output }); stream.end(); } catch (error) { - for (const block of output.content) delete (block as any).index; + for (const block of output.content) { + delete (block as { index?: number }).index; + // partialArgs is only a streaming scratch buffer; never persist it. + delete (block as { partialArgs?: string }).partialArgs; + } output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); // Some providers via OpenRouter give additional information in this field. diff --git a/packages/ai/src/providers/openai-responses-shared.ts b/packages/ai/src/providers/openai-responses-shared.ts index 8d9f39a2..bb245382 100644 --- a/packages/ai/src/providers/openai-responses-shared.ts +++ b/packages/ai/src/providers/openai-responses-shared.ts @@ -452,12 +452,22 @@ export async function processResponsesStream( currentBlock?.type === "toolCall" && currentBlock.partialJson ? parseStreamingJson(currentBlock.partialJson) : parseStreamingJson(item.arguments || "{}"); - const toolCall: ToolCall = { - type: "toolCall", - id: `${item.call_id}|${item.id}`, - name: item.name, - arguments: args, - }; + + let toolCall: ToolCall; + if (currentBlock?.type === "toolCall") { + // Finalize in-place and strip the scratch buffer so replay only + // carries parsed arguments. + currentBlock.arguments = args; + delete (currentBlock as { partialJson?: string }).partialJson; + toolCall = currentBlock; + } else { + toolCall = { + type: "toolCall", + id: `${item.call_id}|${item.id}`, + name: item.name, + arguments: args, + }; + } currentBlock = null; stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output }); diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 51c1a170..26abf50e 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -116,7 +116,11 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses", OpenAIRes stream.push({ type: "done", reason: output.stopReason, message: output }); stream.end(); } catch (error) { - for (const block of output.content) delete (block as { index?: number }).index; + for (const block of output.content) { + delete (block as { index?: number }).index; + // partialJson is only a streaming scratch buffer; never persist it. + delete (block as { partialJson?: string }).partialJson; + } output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); stream.push({ type: "error", reason: output.stopReason, error: output }); diff --git a/packages/ai/test/openai-responses-partial-json-cleanup.test.ts b/packages/ai/test/openai-responses-partial-json-cleanup.test.ts new file mode 100644 index 00000000..c34350de --- /dev/null +++ b/packages/ai/test/openai-responses-partial-json-cleanup.test.ts @@ -0,0 +1,101 @@ +import type { ResponseStreamEvent } from "openai/resources/responses/responses.js"; +import { describe, expect, it, vi } from "vitest"; +import { processResponsesStream } from "../src/providers/openai-responses-shared.js"; +import type { AssistantMessage, AssistantMessageEvent, Model } from "../src/types.js"; +import { AssistantMessageEventStream } from "../src/utils/event-stream.js"; + +function createOutput(model: Model<"openai-responses">): AssistantMessage { + return { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; +} + +async function* createFunctionCallEvents(argumentsJson: string): AsyncIterable { + yield { + type: "response.output_item.added", + item: { + type: "function_call", + id: "fc_test", + call_id: "call_test", + name: "edit", + arguments: "", + }, + } as ResponseStreamEvent; + yield { + type: "response.function_call_arguments.delta", + delta: '{"path":"README.md"', + } as ResponseStreamEvent; + yield { + type: "response.function_call_arguments.delta", + delta: ',"content":"updated"}', + } as ResponseStreamEvent; + yield { + type: "response.function_call_arguments.done", + arguments: argumentsJson, + } as ResponseStreamEvent; + yield { + type: "response.output_item.done", + item: { + type: "function_call", + id: "fc_test", + call_id: "call_test", + name: "edit", + arguments: argumentsJson, + }, + } as ResponseStreamEvent; +} + +describe("openai responses partialJson cleanup", () => { + it("removes partialJson from persisted tool-call blocks at output_item.done", async () => { + const model: Model<"openai-responses"> = { + id: "gpt-5-mini", + name: "GPT-5 Mini", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }; + const output = createOutput(model); + const stream = new AssistantMessageEventStream(); + const pushSpy = vi.spyOn(stream, "push"); + const argumentsJson = '{"path":"README.md","content":"updated"}'; + + await processResponsesStream(createFunctionCallEvents(argumentsJson), output, stream, model); + + expect(output.content).toHaveLength(1); + const persistedToolCall = output.content[0]; + expect(persistedToolCall?.type).toBe("toolCall"); + if (!persistedToolCall || persistedToolCall.type !== "toolCall") { + throw new Error("Expected toolCall block"); + } + expect(persistedToolCall.arguments).toEqual({ path: "README.md", content: "updated" }); + expect("partialJson" in persistedToolCall).toBe(false); + + const emittedEvents = pushSpy.mock.calls.map(([event]) => event as AssistantMessageEvent); + const toolCallEnd = emittedEvents.find((event) => event.type === "toolcall_end"); + expect(toolCallEnd).toBeDefined(); + if (!toolCallEnd || toolCallEnd.type !== "toolcall_end") { + throw new Error("Expected toolcall_end event"); + } + expect(toolCallEnd.toolCall).toBe(persistedToolCall); + expect("partialJson" in toolCallEnd.toolCall).toBe(false); + }); +});