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
This commit is contained in:
@@ -211,6 +211,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
for (const block of output.content) {
|
for (const block of output.content) {
|
||||||
delete (block as Block).index;
|
delete (block as Block).index;
|
||||||
|
// partialJson is only a streaming scratch buffer; never persist it.
|
||||||
delete (block as Block).partialJson;
|
delete (block as Block).partialJson;
|
||||||
}
|
}
|
||||||
output.stopReason = options.signal?.aborted ? "aborted" : "error";
|
output.stopReason = options.signal?.aborted ? "aborted" : "error";
|
||||||
@@ -413,6 +414,8 @@ function handleContentBlockStop(
|
|||||||
break;
|
break;
|
||||||
case "toolCall":
|
case "toolCall":
|
||||||
block.arguments = parseStreamingJson(block.partialJson);
|
block.arguments = parseStreamingJson(block.partialJson);
|
||||||
|
// Finalize in-place and strip the scratch buffer so replay only
|
||||||
|
// carries parsed arguments.
|
||||||
delete (block as Block).partialJson;
|
delete (block as Block).partialJson;
|
||||||
stream.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: output });
|
stream.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: output });
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -384,7 +384,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
|
|||||||
});
|
});
|
||||||
} else if (block.type === "toolCall") {
|
} else if (block.type === "toolCall") {
|
||||||
block.arguments = parseStreamingJson(block.partialJson);
|
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({
|
stream.push({
|
||||||
type: "toolcall_end",
|
type: "toolcall_end",
|
||||||
contentIndex: index,
|
contentIndex: index,
|
||||||
@@ -429,7 +431,11 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
|
|||||||
stream.push({ type: "done", reason: output.stopReason, message: output });
|
stream.push({ type: "done", reason: output.stopReason, message: output });
|
||||||
stream.end();
|
stream.end();
|
||||||
} catch (error) {
|
} 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.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
||||||
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
|
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
|
||||||
stream.push({ type: "error", reason: output.stopReason, error: output });
|
stream.push({ type: "error", reason: output.stopReason, error: output });
|
||||||
|
|||||||
@@ -109,7 +109,11 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses"
|
|||||||
stream.push({ type: "done", reason: output.stopReason, message: output });
|
stream.push({ type: "done", reason: output.stopReason, message: output });
|
||||||
stream.end();
|
stream.end();
|
||||||
} catch (error) {
|
} 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.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
||||||
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
|
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
|
||||||
stream.push({ type: "error", reason: output.stopReason, error: output });
|
stream.push({ type: "error", reason: output.stopReason, error: output });
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ export const streamMistral: StreamFunction<"mistral-conversations", MistralOptio
|
|||||||
stream.push({ type: "done", reason: output.stopReason, message: output });
|
stream.push({ type: "done", reason: output.stopReason, message: output });
|
||||||
stream.end();
|
stream.end();
|
||||||
} catch (error) {
|
} 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.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
||||||
output.errorMessage = formatMistralError(error);
|
output.errorMessage = formatMistralError(error);
|
||||||
stream.push({ type: "error", reason: output.stopReason, error: output });
|
stream.push({ type: "error", reason: output.stopReason, error: output });
|
||||||
@@ -424,6 +428,8 @@ async function consumeChatStream(
|
|||||||
if (block.type !== "toolCall") continue;
|
if (block.type !== "toolCall") continue;
|
||||||
const toolBlock = block as ToolCall & { partialArgs?: string };
|
const toolBlock = block as ToolCall & { partialArgs?: string };
|
||||||
toolBlock.arguments = parseStreamingJson<Record<string, unknown>>(toolBlock.partialArgs);
|
toolBlock.arguments = parseStreamingJson<Record<string, unknown>>(toolBlock.partialArgs);
|
||||||
|
// Finalize in-place and strip the scratch buffer so replay only
|
||||||
|
// carries parsed arguments.
|
||||||
delete toolBlock.partialArgs;
|
delete toolBlock.partialArgs;
|
||||||
stream.push({
|
stream.push({
|
||||||
type: "toolcall_end",
|
type: "toolcall_end",
|
||||||
|
|||||||
@@ -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.push({ type: "done", reason: output.stopReason as "stop" | "length" | "toolUse", message: output });
|
||||||
stream.end();
|
stream.end();
|
||||||
} catch (error) {
|
} 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.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
||||||
output.errorMessage = error instanceof Error ? error.message : String(error);
|
output.errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
stream.push({ type: "error", reason: output.stopReason, error: output });
|
stream.push({ type: "error", reason: output.stopReason, error: output });
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA
|
|||||||
});
|
});
|
||||||
} else if (block.type === "toolCall") {
|
} else if (block.type === "toolCall") {
|
||||||
block.arguments = parseStreamingJson(block.partialArgs);
|
block.arguments = parseStreamingJson(block.partialArgs);
|
||||||
|
// Finalize in-place and strip the scratch buffer so replay only
|
||||||
|
// carries parsed arguments.
|
||||||
delete block.partialArgs;
|
delete block.partialArgs;
|
||||||
stream.push({
|
stream.push({
|
||||||
type: "toolcall_end",
|
type: "toolcall_end",
|
||||||
@@ -289,7 +291,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA
|
|||||||
stream.push({ type: "done", reason: output.stopReason, message: output });
|
stream.push({ type: "done", reason: output.stopReason, message: output });
|
||||||
stream.end();
|
stream.end();
|
||||||
} catch (error) {
|
} 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.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
||||||
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
|
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
|
||||||
// Some providers via OpenRouter give additional information in this field.
|
// Some providers via OpenRouter give additional information in this field.
|
||||||
|
|||||||
@@ -452,12 +452,22 @@ export async function processResponsesStream<TApi extends Api>(
|
|||||||
currentBlock?.type === "toolCall" && currentBlock.partialJson
|
currentBlock?.type === "toolCall" && currentBlock.partialJson
|
||||||
? parseStreamingJson(currentBlock.partialJson)
|
? parseStreamingJson(currentBlock.partialJson)
|
||||||
: parseStreamingJson(item.arguments || "{}");
|
: parseStreamingJson(item.arguments || "{}");
|
||||||
const toolCall: ToolCall = {
|
|
||||||
type: "toolCall",
|
let toolCall: ToolCall;
|
||||||
id: `${item.call_id}|${item.id}`,
|
if (currentBlock?.type === "toolCall") {
|
||||||
name: item.name,
|
// Finalize in-place and strip the scratch buffer so replay only
|
||||||
arguments: args,
|
// 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;
|
currentBlock = null;
|
||||||
stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
|
stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
|
||||||
|
|||||||
@@ -116,7 +116,11 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses", OpenAIRes
|
|||||||
stream.push({ type: "done", reason: output.stopReason, message: output });
|
stream.push({ type: "done", reason: output.stopReason, message: output });
|
||||||
stream.end();
|
stream.end();
|
||||||
} catch (error) {
|
} 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.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
||||||
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
|
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
|
||||||
stream.push({ type: "error", reason: output.stopReason, error: output });
|
stream.push({ type: "error", reason: output.stopReason, error: output });
|
||||||
|
|||||||
101
packages/ai/test/openai-responses-partial-json-cleanup.test.ts
Normal file
101
packages/ai/test/openai-responses-partial-json-cleanup.test.ts
Normal file
@@ -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<ResponseStreamEvent> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user