Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/tidy-moons-tap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/tanstack-ai": patch
---

Generate unique toolCallIds in the workers-ai adapter's non-streaming fallback path. Workers AI models can return the same deterministic tool_call id for multiple calls in a step; the streaming path already replaces provider ids with generated ones, and the fallback now does the same.
8 changes: 6 additions & 2 deletions packages/tanstack-ai/src/adapters/workers-ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,9 +449,13 @@ export class WorkersAiTextAdapter<TModel extends WorkersAiTextModel> extends Bas
} catch {
parsedInput = {};
}
// Always generate a unique ID per tool call.
// The backend may send the same ID for multiple tool calls,
// so we cannot trust tc.id to be unique.
const toolCallId = generateId("chatcmpl-tool");
yield {
type: EventType.TOOL_CALL_START,
toolCallId: tc.id,
toolCallId,
toolCallName: fn.name,
toolName: fn.name,
model: nonStreamResult.model || model || this.model,
Expand All @@ -460,7 +464,7 @@ export class WorkersAiTextAdapter<TModel extends WorkersAiTextModel> extends Bas
} satisfies StreamChunk;
yield {
type: EventType.TOOL_CALL_END,
toolCallId: tc.id,
toolCallId,
toolName: fn.name,
model: nonStreamResult.model || model || this.model,
timestamp,
Expand Down
78 changes: 78 additions & 0 deletions packages/tanstack-ai/test/workers-ai-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1267,6 +1267,84 @@ describe("WorkersAiTextAdapter modelOptions passthrough", () => {
}
warnSpy.mockRestore();
});

it("should generate unique toolCallIds in the non-streaming fallback path", async () => {
// Workers AI models can emit the same deterministic tool_call id for
// every call in a step (issues #526/#527). The streaming path already
// replaces provider ids with generated ones; the non-streaming
// fallback must do the same or multi-tool-call responses collide.
const adapter = new WorkersAiTextAdapter("@cf/openai/gpt-oss-120b" as WorkersAiTextModel, {
binding: {
run: vi.fn(),
gateway: () => ({ run: () => Promise.resolve(new Response("ok")) }),
},
});

const createMock = vi
.fn()
// First call (streaming) throws
.mockRejectedValueOnce(new Error("streaming not supported"))
// Second call (non-streaming fallback) returns two tool calls
// sharing the same deterministic backend id
.mockResolvedValueOnce({
model: "@cf/openai/gpt-oss-120b",
choices: [
{
message: {
role: "assistant",
content: null,
tool_calls: [
{
id: "chatcmpl-tool-b93bf5371e6efcfba7a45b8d84fb0d6f",
type: "function",
function: {
name: "get_weather",
arguments: '{"city":"Austin"}',
},
},
{
id: "chatcmpl-tool-b93bf5371e6efcfba7a45b8d84fb0d6f",
type: "function",
function: {
name: "get_weather",
arguments: '{"city":"Dallas"}',
},
},
],
},
finish_reason: "tool_calls",
},
],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
});

(adapter as any).client = {
chat: { completions: { create: createMock } },
};

const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

const chunks = await collectChunks(
adapter.chatStream({
model: "@cf/openai/gpt-oss-120b" as WorkersAiTextModel,
messages: [{ role: "user", content: "Hi" }],
} as any),
);

const starts = chunks.filter((c: any) => c.type === "TOOL_CALL_START");
const ends = chunks.filter((c: any) => c.type === "TOOL_CALL_END");
expect(starts).toHaveLength(2);
expect(ends).toHaveLength(2);

// The duplicate backend id must not be forwarded
const startIds = starts.map((c: any) => c.toolCallId);
expect(new Set(startIds).size).toBe(2);

// START and END of the same call must agree on the generated id
expect(ends.map((c: any) => c.toolCallId)).toEqual(startIds);

warnSpy.mockRestore();
});
});

// ---------------------------------------------------------------------------
Expand Down