Skip to content

Commit 76e5b0c

Browse files
Merge pull request #424 from cloudflare/fix-tanstack-ai-tool-call-id
fix(tanstack-ai): avoid duplicate tool call ids
2 parents 813b10a + 60dd5db commit 76e5b0c

10 files changed

Lines changed: 176 additions & 122 deletions

File tree

.changeset/curvy-tips-fold.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudflare/tanstack-ai": patch
3+
---
4+
5+
Avoid duplicate tool call IDs by generating unique IDs per tool call index instead of trusting backend-provided IDs

examples/tanstack-ai/src/panels/ChatPanel.tsx

Lines changed: 115 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,104 @@ import { fetchHttpStream, useChat } from "@tanstack/ai-react";
22
import { useEffect, useMemo, useRef, useState } from "react";
33
import { useConfig } from "../config";
44
import type { ProviderDef } from "../providers";
5+
import type { ToolCallPart, ToolResultPart } from "@tanstack/ai";
6+
7+
function ToolCallDisplay({
8+
toolName,
9+
args,
10+
result,
11+
isUserMessage,
12+
}: {
13+
toolName: string;
14+
args?: string;
15+
result?: string;
16+
isUserMessage: boolean;
17+
}) {
18+
const [isExpanded, setIsExpanded] = useState(false);
19+
20+
const argsStr = args ? formatJson(args) : null;
21+
const resultStr = result ? formatJson(result) : null;
22+
23+
return (
24+
<div
25+
className={`text-xs rounded-lg px-2.5 py-1.5 font-mono ${
26+
isUserMessage
27+
? "bg-gray-800 text-gray-300"
28+
: "bg-gray-50 text-gray-500 border border-gray-100"
29+
}`}
30+
>
31+
<div className="flex items-center justify-between gap-2">
32+
<div className="flex items-center min-w-0">
33+
<span className="font-semibold">{toolName}</span>
34+
{!isExpanded && resultStr && (
35+
<span className="ml-1.5 opacity-75 truncate">
36+
{resultStr.length > 50 ? `${resultStr.slice(0, 50)}...` : resultStr}
37+
</span>
38+
)}
39+
</div>
40+
<button
41+
type="button"
42+
onClick={() => setIsExpanded(!isExpanded)}
43+
className={`shrink-0 p-0.5 rounded hover:bg-gray-200 transition-colors ${
44+
isUserMessage ? "hover:bg-gray-700" : ""
45+
}`}
46+
title={isExpanded ? "Collapse" : "Expand"}
47+
>
48+
<svg
49+
className={`w-3.5 h-3.5 transition-transform ${isExpanded ? "rotate-180" : ""}`}
50+
fill="none"
51+
viewBox="0 0 24 24"
52+
stroke="currentColor"
53+
strokeWidth={2}
54+
>
55+
<title>{isExpanded ? "Collapse" : "Expand"}</title>
56+
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
57+
</svg>
58+
</button>
59+
</div>
60+
{isExpanded && (
61+
<div className="mt-2 space-y-2">
62+
{argsStr && (
63+
<div>
64+
<p className="text-[10px] font-medium opacity-60 uppercase tracking-wide mb-1">
65+
Request
66+
</p>
67+
<pre
68+
className={`p-2 rounded text-[10px] whitespace-pre-wrap overflow-x-auto max-h-48 overflow-y-auto ${
69+
isUserMessage ? "bg-gray-700" : "bg-gray-100 text-gray-700"
70+
}`}
71+
>
72+
{argsStr}
73+
</pre>
74+
</div>
75+
)}
76+
{resultStr && (
77+
<div>
78+
<p className="text-[10px] font-medium opacity-60 uppercase tracking-wide mb-1">
79+
Result
80+
</p>
81+
<pre
82+
className={`p-2 rounded text-[10px] whitespace-pre-wrap overflow-x-auto max-h-48 overflow-y-auto ${
83+
isUserMessage ? "bg-gray-700" : "bg-gray-100 text-gray-700"
84+
}`}
85+
>
86+
{resultStr}
87+
</pre>
88+
</div>
89+
)}
90+
</div>
91+
)}
92+
</div>
93+
);
94+
}
95+
96+
function formatJson(str: string): string {
97+
try {
98+
return JSON.stringify(JSON.parse(str), null, 2);
99+
} catch {
100+
return str;
101+
}
102+
}
5103

6104
export function ChatPanel({ provider }: { provider: ProviderDef }) {
7105
const [workersAiModel, setWorkersAiModel] = useState(provider.chatModels?.[0]?.id ?? "");
@@ -180,39 +278,27 @@ function ChatView({ provider, workersAiModel }: { provider: ProviderDef; workers
180278
</div>
181279
);
182280
}
183-
if (
184-
part.type === "tool-call" ||
185-
part.type === "tool-result"
186-
) {
281+
if (part.type === "tool-call") {
282+
const toolCall = part as ToolCallPart;
283+
const toolResult = message.parts.find(
284+
(p): p is ToolResultPart =>
285+
p.type === "tool-result" &&
286+
(p as ToolResultPart).toolCallId ===
287+
toolCall.id,
288+
);
187289
return (
188-
<div
290+
<ToolCallDisplay
189291
key={key}
190-
className={`text-xs rounded-lg px-2.5 py-1.5 font-mono ${
191-
message.role === "user"
192-
? "bg-gray-800 text-gray-300"
193-
: "bg-gray-50 text-gray-500 border border-gray-100"
194-
}`}
195-
>
196-
<span className="font-semibold">
197-
{"toolName" in part
198-
? (part as { toolName: string })
199-
.toolName
200-
: "tool"}
201-
</span>
202-
{"result" in part && (
203-
<span className="ml-1.5 opacity-75">
204-
{JSON.stringify(
205-
(
206-
part as {
207-
result?: unknown;
208-
}
209-
).result,
210-
)}
211-
</span>
212-
)}
213-
</div>
292+
toolName={toolCall.name ?? "tool"}
293+
args={toolCall.arguments}
294+
result={toolResult?.content}
295+
isUserMessage={message.role === "user"}
296+
/>
214297
);
215298
}
299+
if (part.type === "tool-result") {
300+
return null;
301+
}
216302
return null;
217303
})}
218304
</div>

examples/tanstack-ai/src/providers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const WORKERS_AI_CHAT_MODELS = [
2121
{ id: "@cf/meta/llama-3.3-70b-instruct-fp8-fast", label: "Llama 3.3 70B" },
2222
{ id: "@cf/openai/gpt-oss-120b", label: "GPT-OSS 120B" },
2323
{ id: "@cf/qwen/qwq-32b", label: "QwQ 32B" },
24-
// { id: "@cf/moonshotai/kimi-k2.5", label: "Kimi K2.5" },
24+
{ id: "@cf/moonshotai/kimi-k2.5", label: "Kimi K2.5" },
2525
{ id: "@cf/qwen/qwen3-30b-a3b-fp8", label: "Qwen3 30B" },
2626
{ id: "@cf/openai/gpt-oss-20b", label: "GPT-OSS 20B" },
2727
{ id: "@cf/google/gemma-3-12b-it", label: "Gemma 3 12B" },

packages/tanstack-ai/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@
9191
"build": "rm -rf dist && tsup --config tsup.config.ts",
9292
"format": "biome format --write",
9393
"type-check": "tsc --noEmit",
94-
"test": "vitest",
94+
"test": "vitest --run",
95+
"test:watch": "vitest",
9596
"test:ci": "vitest --watch=false",
9697
"test:e2e": "vitest --config vitest.e2e.config.ts --watch=false",
9798
"test:e2e:rest": "vitest --config vitest.e2e.config.ts --watch=false test/e2e/workers-ai-rest.e2e.test.ts",

packages/tanstack-ai/src/adapters/workers-ai.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -210,8 +210,8 @@ function buildOpenAITools(
210210
// ID generation
211211
// ---------------------------------------------------------------------------
212212

213-
function generateId(prefix: string): string {
214-
return `${prefix}-${crypto.randomUUID()}`;
213+
function generateId(prefix = "chatcmpl"): string {
214+
return `${prefix}-${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
215215
}
216216

217217
// ---------------------------------------------------------------------------
@@ -244,14 +244,14 @@ export class WorkersAiTextAdapter<TModel extends WorkersAiTextModel> extends Bas
244244
const openAITools = buildOpenAITools(tools);
245245

246246
const timestamp = Date.now();
247-
const runId = generateId("workers-ai");
248-
const messageId = generateId("workers-ai");
247+
const runId = generateId();
248+
const messageId = generateId();
249249
let hasEmittedRunStarted = false;
250250
let hasEmittedTextMessageStart = false;
251251
let accumulatedContent = "";
252252
let hasEmittedStepStarted = false;
253253
let accumulatedReasoning = "";
254-
const stepId = generateId("workers-ai-step");
254+
const stepId = generateId();
255255
let hasReceivedFinishReason = false;
256256
const toolCallsInProgress = new Map<
257257
number,
@@ -446,8 +446,11 @@ export class WorkersAiTextAdapter<TModel extends WorkersAiTextModel> extends Bas
446446
const index = toolCallDelta.index;
447447

448448
if (!toolCallsInProgress.has(index)) {
449+
// Always generate a unique ID per tool call index.
450+
// The backend may send the same ID for multiple tool calls,
451+
// so we cannot trust toolCallDelta.id to be unique.
449452
toolCallsInProgress.set(index, {
450-
id: toolCallDelta.id || "",
453+
id: generateId("chatcmpl-tool"),
451454
name: toolCallDelta.function?.name || "",
452455
arguments: "",
453456
started: false,
@@ -456,9 +459,9 @@ export class WorkersAiTextAdapter<TModel extends WorkersAiTextModel> extends Bas
456459

457460
const toolCall = toolCallsInProgress.get(index)!;
458461

459-
if (toolCallDelta.id) {
460-
toolCall.id = toolCallDelta.id;
461-
}
462+
// Only update name if provided (ID is already set at creation time
463+
// and should not be overwritten by subsequent chunks that may have
464+
// duplicate/shared IDs from the backend)
462465
if (toolCallDelta.function?.name) {
463466
toolCall.name = toolCallDelta.function.name;
464467
}

packages/tanstack-ai/src/utils/create-fetcher.ts

Lines changed: 23 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -275,10 +275,6 @@ export function createGatewayFetch(
275275
*
276276
* The binding has strict schema validation that may differ from the OpenAI API:
277277
* - `content` must be a string (not null)
278-
* - `tool_call_id` must match `[a-zA-Z0-9]{9}` pattern
279-
*
280-
* This function patches these fields so that the full tool-call round-trip works
281-
* even though the binding's own generated IDs may not pass its validation.
282278
*/
283279
function normalizeMessagesForBinding(
284280
messages: Record<string, unknown>[],
@@ -291,45 +287,10 @@ function normalizeMessagesForBinding(
291287
normalized.content = "";
292288
}
293289

294-
// Normalize tool_call_id on tool messages
295-
if (normalized.tool_call_id && typeof normalized.tool_call_id === "string") {
296-
normalized.tool_call_id = sanitizeToolCallId(normalized.tool_call_id);
297-
}
298-
299-
// Normalize tool_calls[].id on assistant messages
300-
if (Array.isArray(normalized.tool_calls)) {
301-
normalized.tool_calls = (normalized.tool_calls as Record<string, unknown>[]).map(
302-
(tc) => {
303-
if (tc.id && typeof tc.id === "string") {
304-
return { ...tc, id: sanitizeToolCallId(tc.id) };
305-
}
306-
return tc;
307-
},
308-
);
309-
}
310-
311290
return normalized;
312291
});
313292
}
314293

315-
/**
316-
* Strip non-alphanumeric characters and ensure the ID is exactly 9 chars,
317-
* matching Workers AI's `[a-zA-Z0-9]{9}` validation pattern.
318-
*
319-
* **Why this exists:** The Workers AI binding validates `tool_call_id` with
320-
* a strict `[a-zA-Z0-9]{9}` regex, but it *generates* IDs like
321-
* `chatcmpl-tool-875d3ec6179676ae` (with dashes, >9 chars). Those IDs are
322-
* then rejected when sent back in a follow-up request. This is a known
323-
* Workers AI issue — see workers-ai.md (Issue 3). Once the Workers AI team
324-
* fixes the validation, this function becomes an idempotent no-op for
325-
* IDs that already match the pattern.
326-
*/
327-
function sanitizeToolCallId(id: string): string {
328-
const alphanumeric = id.replace(/[^a-zA-Z0-9]/g, "");
329-
// Pad with zeros if too short, truncate if too long
330-
return alphanumeric.slice(0, 9).padEnd(9, "0");
331-
}
332-
333294
/**
334295
* Creates a fetch function that intercepts OpenAI SDK requests and translates them
335296
* to Workers AI binding calls (env.AI.run). This allows the WorkersAiTextAdapter
@@ -422,7 +383,7 @@ export function createWorkersAiBindingFetch(
422383
arguments: unknown;
423384
function?: { name: string; arguments?: unknown };
424385
}) => ({
425-
id: sanitizeToolCallId(tc.id || crypto.randomUUID()),
386+
id: tc.id || crypto.randomUUID(),
426387
type: "function",
427388
function: {
428389
name: tc.function?.name || tc.name || "",
@@ -479,9 +440,9 @@ function transformWorkersAiStream(
479440
// like Qwen3, Kimi K2.5 stream OpenAI-compatible SSE through the binding).
480441
// In that case, flush() should only emit [DONE] and skip the finish chunk.
481442
let isOpenAiFormat = false;
482-
// Track which tool call indices we've already emitted an `id` for,
483-
// so subsequent argument deltas don't duplicate the id/type/name fields.
484-
const emittedToolCallStart = new Set<number>();
443+
// Track tool call state per index: store the generated/assigned ID so that
444+
// subsequent argument deltas use the same ID (matching the working streaming.ts pattern).
445+
const toolCallState = new Map<number, { id: string; name: string }>();
485446

486447
return source.pipeThrough(
487448
new TransformStream<Uint8Array, Uint8Array>({
@@ -505,15 +466,26 @@ function transformWorkersAiStream(
505466
// directly through the binding, with `choices[].delta.content` and
506467
// optional `reasoning_content`. Detect this and pass through as-is.
507468
if (parsed.choices !== undefined) {
508-
// Already OpenAI format — pass through with only tool_call_id
509-
// sanitization for any tool calls present.
469+
// Already OpenAI format — pass through but ensure each tool call
470+
// index gets a unique, stable ID across all chunks.
510471
isOpenAiFormat = true;
511472
const choice = parsed.choices?.[0];
512473
if (choice?.delta?.tool_calls) {
513474
hasToolCalls = true;
514475
for (const tc of choice.delta.tool_calls) {
515-
if (tc.id && typeof tc.id === "string") {
516-
tc.id = sanitizeToolCallId(tc.id);
476+
const tcIndex = tc.index ?? 0;
477+
if (!toolCallState.has(tcIndex)) {
478+
// First chunk for this index — generate/store unique ID
479+
const id = tc.id || `call${streamId}${tcIndex}`;
480+
toolCallState.set(tcIndex, {
481+
id,
482+
name: tc.function?.name || "",
483+
});
484+
tc.id = id;
485+
} else {
486+
// Subsequent chunk — reuse stored ID, remove id from delta
487+
// (OpenAI format only sends id in first chunk)
488+
delete tc.id;
517489
}
518490
}
519491
}
@@ -572,13 +544,11 @@ function transformWorkersAiStream(
572544
index: tcIndex,
573545
};
574546

575-
if (!emittedToolCallStart.has(tcIndex)) {
547+
if (!toolCallState.has(tcIndex)) {
576548
// First chunk for this tool call index — emit id, type, name.
577-
// Use sanitizeToolCallId so the ID survives round-trip through
578-
// the binding's strict `[a-zA-Z0-9]{9}` validation.
579-
emittedToolCallStart.add(tcIndex);
580-
const rawId = tcId || `call${streamId}${tcIndex}`;
581-
toolCallDelta.id = sanitizeToolCallId(rawId);
549+
const id = tcId || `call${streamId}${tcIndex}`;
550+
toolCallState.set(tcIndex, { id, name: tcName || "" });
551+
toolCallDelta.id = id;
582552
toolCallDelta.type = "function";
583553
toolCallDelta.function = {
584554
name: tcName || "",

0 commit comments

Comments
 (0)