fix(ai): coalesce malformed streamed tool calls closes #3576

This commit is contained in:
Mario Zechner
2026-04-23 16:05:21 +02:00
parent 2ddb27b055
commit 01509156b3
4 changed files with 162 additions and 25 deletions

View File

@@ -441,6 +441,117 @@ describe("openai-completions tool_choice", () => {
expect(response.content).toEqual([{ type: "text", text: "OK" }]);
});
it("coalesces tool call deltas by stable index when provider mutates ids mid-stream", async () => {
mockState.chunks = [
{
id: "chatcmpl-kimi-bad-stream",
choices: [
{
delta: {
tool_calls: [
{
index: 0,
id: "functions.read:0",
type: "function",
function: { name: "read", arguments: "" },
},
],
},
finish_reason: null,
},
],
},
{
id: "chatcmpl-kimi-bad-stream",
choices: [
{
delta: {
tool_calls: [
{
index: 0,
id: "chatcmpl-tool-a",
type: "function",
function: { name: null, arguments: '{"path":"README' },
},
],
},
finish_reason: null,
},
],
},
{
id: "chatcmpl-kimi-bad-stream",
choices: [
{
delta: {
tool_calls: [
{
index: 0,
id: "chatcmpl-tool-b",
type: "function",
function: { name: null, arguments: '.md"}' },
},
],
},
finish_reason: "tool_calls",
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 5,
prompt_tokens_details: { cached_tokens: 0 },
completion_tokens_details: { reasoning_tokens: 0 },
},
},
];
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!;
const model = { ...baseModel, api: "openai-completions" } as const;
const tool: Tool = {
name: "read",
description: "Read a file",
parameters: Type.Object({
path: Type.String(),
}),
};
const s = streamSimple(
model,
{
messages: [
{
role: "user",
content: "Read README.md",
timestamp: Date.now(),
},
],
tools: [tool],
},
{ apiKey: "test" },
);
const toolCallContentIndexes: number[] = [];
for await (const event of s) {
if (event.type === "toolcall_start" || event.type === "toolcall_delta" || event.type === "toolcall_end") {
toolCallContentIndexes.push(event.contentIndex);
}
}
const response = await s.result();
expect(response.stopReason).toBe("toolUse");
expect(toolCallContentIndexes).toEqual([0, 0, 0, 0, 0]);
expect(response.content).toHaveLength(1);
const toolCall = response.content[0];
expect(toolCall.type).toBe("toolCall");
if (toolCall.type !== "toolCall") {
throw new Error("Expected toolCall content");
}
expect(toolCall.id).toBe("functions.read:0");
expect(toolCall.name).toBe("read");
expect(toolCall.arguments).toEqual({ path: "README.md" });
expect(toolCall).not.toHaveProperty("streamIndex");
expect(toolCall).not.toHaveProperty("partialArgs");
});
it("preserves prompt_tokens_details.cache_write_tokens from chunk usage", async () => {
mockState.chunks = [
{

View File

@@ -1092,8 +1092,8 @@ describe("Generate E2E Tests", () => {
});
});
describe("OpenAI Codex Provider (gpt-5.2-codex)", () => {
const llm = getModel("openai-codex", "gpt-5.2-codex");
describe("OpenAI Codex Provider (gpt-5.4)", () => {
const llm = getModel("openai-codex", "gpt-5.4");
it.skipIf(!openaiCodexToken)("should complete basic text generation", { retry: 3 }, async () => {
await basicTextGeneration(llm, { apiKey: openaiCodexToken });