Skip to content
Merged
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/curvy-tips-fold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/tanstack-ai": patch
---

Avoid duplicate tool call IDs by generating unique IDs per tool call index instead of trusting backend-provided IDs
144 changes: 115 additions & 29 deletions examples/tanstack-ai/src/panels/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,104 @@ import { fetchHttpStream, useChat } from "@tanstack/ai-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useConfig } from "../config";
import type { ProviderDef } from "../providers";
import type { ToolCallPart, ToolResultPart } from "@tanstack/ai";

function ToolCallDisplay({
toolName,
args,
result,
isUserMessage,
}: {
toolName: string;
args?: string;
result?: string;
isUserMessage: boolean;
}) {
const [isExpanded, setIsExpanded] = useState(false);

const argsStr = args ? formatJson(args) : null;
const resultStr = result ? formatJson(result) : null;

return (
<div
className={`text-xs rounded-lg px-2.5 py-1.5 font-mono ${
isUserMessage
? "bg-gray-800 text-gray-300"
: "bg-gray-50 text-gray-500 border border-gray-100"
}`}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center min-w-0">
<span className="font-semibold">{toolName}</span>
{!isExpanded && resultStr && (
<span className="ml-1.5 opacity-75 truncate">
{resultStr.length > 50 ? `${resultStr.slice(0, 50)}...` : resultStr}
</span>
)}
</div>
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className={`shrink-0 p-0.5 rounded hover:bg-gray-200 transition-colors ${
isUserMessage ? "hover:bg-gray-700" : ""
}`}
title={isExpanded ? "Collapse" : "Expand"}
>
<svg
className={`w-3.5 h-3.5 transition-transform ${isExpanded ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<title>{isExpanded ? "Collapse" : "Expand"}</title>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
{isExpanded && (
<div className="mt-2 space-y-2">
{argsStr && (
<div>
<p className="text-[10px] font-medium opacity-60 uppercase tracking-wide mb-1">
Request
</p>
<pre
className={`p-2 rounded text-[10px] whitespace-pre-wrap overflow-x-auto max-h-48 overflow-y-auto ${
isUserMessage ? "bg-gray-700" : "bg-gray-100 text-gray-700"
}`}
>
{argsStr}
</pre>
</div>
)}
{resultStr && (
<div>
<p className="text-[10px] font-medium opacity-60 uppercase tracking-wide mb-1">
Result
</p>
<pre
className={`p-2 rounded text-[10px] whitespace-pre-wrap overflow-x-auto max-h-48 overflow-y-auto ${
isUserMessage ? "bg-gray-700" : "bg-gray-100 text-gray-700"
}`}
>
{resultStr}
</pre>
</div>
)}
</div>
)}
</div>
);
}

function formatJson(str: string): string {
try {
return JSON.stringify(JSON.parse(str), null, 2);
} catch {
return str;
}
}

export function ChatPanel({ provider }: { provider: ProviderDef }) {
const [workersAiModel, setWorkersAiModel] = useState(provider.chatModels?.[0]?.id ?? "");
Expand Down Expand Up @@ -180,39 +278,27 @@ function ChatView({ provider, workersAiModel }: { provider: ProviderDef; workers
</div>
);
}
if (
part.type === "tool-call" ||
part.type === "tool-result"
) {
if (part.type === "tool-call") {
const toolCall = part as ToolCallPart;
const toolResult = message.parts.find(
(p): p is ToolResultPart =>
p.type === "tool-result" &&
(p as ToolResultPart).toolCallId ===
toolCall.id,
);
return (
<div
<ToolCallDisplay
key={key}
className={`text-xs rounded-lg px-2.5 py-1.5 font-mono ${
message.role === "user"
? "bg-gray-800 text-gray-300"
: "bg-gray-50 text-gray-500 border border-gray-100"
}`}
>
<span className="font-semibold">
{"toolName" in part
? (part as { toolName: string })
.toolName
: "tool"}
</span>
{"result" in part && (
<span className="ml-1.5 opacity-75">
{JSON.stringify(
(
part as {
result?: unknown;
}
).result,
)}
</span>
)}
</div>
toolName={toolCall.name ?? "tool"}
args={toolCall.arguments}
result={toolResult?.content}
isUserMessage={message.role === "user"}
/>
);
}
if (part.type === "tool-result") {
return null;
}
return null;
})}
</div>
Expand Down
2 changes: 1 addition & 1 deletion examples/tanstack-ai/src/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const WORKERS_AI_CHAT_MODELS = [
{ id: "@cf/meta/llama-3.3-70b-instruct-fp8-fast", label: "Llama 3.3 70B" },
{ id: "@cf/openai/gpt-oss-120b", label: "GPT-OSS 120B" },
{ id: "@cf/qwen/qwq-32b", label: "QwQ 32B" },
// { id: "@cf/moonshotai/kimi-k2.5", label: "Kimi K2.5" },
{ id: "@cf/moonshotai/kimi-k2.5", label: "Kimi K2.5" },
{ id: "@cf/qwen/qwen3-30b-a3b-fp8", label: "Qwen3 30B" },
{ id: "@cf/openai/gpt-oss-20b", label: "GPT-OSS 20B" },
{ id: "@cf/google/gemma-3-12b-it", label: "Gemma 3 12B" },
Expand Down
3 changes: 2 additions & 1 deletion packages/tanstack-ai/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@
"build": "rm -rf dist && tsup --config tsup.config.ts",
"format": "biome format --write",
"type-check": "tsc --noEmit",
"test": "vitest",
"test": "vitest --run",
"test:watch": "vitest",
"test:ci": "vitest --watch=false",
"test:e2e": "vitest --config vitest.e2e.config.ts --watch=false",
"test:e2e:rest": "vitest --config vitest.e2e.config.ts --watch=false test/e2e/workers-ai-rest.e2e.test.ts",
Expand Down
21 changes: 12 additions & 9 deletions packages/tanstack-ai/src/adapters/workers-ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,8 @@ function buildOpenAITools(
// ID generation
// ---------------------------------------------------------------------------

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

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

const timestamp = Date.now();
const runId = generateId("workers-ai");
const messageId = generateId("workers-ai");
const runId = generateId();
const messageId = generateId();
let hasEmittedRunStarted = false;
let hasEmittedTextMessageStart = false;
let accumulatedContent = "";
let hasEmittedStepStarted = false;
let accumulatedReasoning = "";
const stepId = generateId("workers-ai-step");
const stepId = generateId();
let hasReceivedFinishReason = false;
const toolCallsInProgress = new Map<
number,
Expand Down Expand Up @@ -446,8 +446,11 @@ export class WorkersAiTextAdapter<TModel extends WorkersAiTextModel> extends Bas
const index = toolCallDelta.index;

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

const toolCall = toolCallsInProgress.get(index)!;

if (toolCallDelta.id) {
toolCall.id = toolCallDelta.id;
}
// Only update name if provided (ID is already set at creation time
// and should not be overwritten by subsequent chunks that may have
// duplicate/shared IDs from the backend)
if (toolCallDelta.function?.name) {
toolCall.name = toolCallDelta.function.name;
}
Expand Down
76 changes: 23 additions & 53 deletions packages/tanstack-ai/src/utils/create-fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,10 +275,6 @@ export function createGatewayFetch(
*
* The binding has strict schema validation that may differ from the OpenAI API:
* - `content` must be a string (not null)
* - `tool_call_id` must match `[a-zA-Z0-9]{9}` pattern
*
* This function patches these fields so that the full tool-call round-trip works
* even though the binding's own generated IDs may not pass its validation.
*/
function normalizeMessagesForBinding(
messages: Record<string, unknown>[],
Expand All @@ -291,45 +287,10 @@ function normalizeMessagesForBinding(
normalized.content = "";
}

// Normalize tool_call_id on tool messages
if (normalized.tool_call_id && typeof normalized.tool_call_id === "string") {
normalized.tool_call_id = sanitizeToolCallId(normalized.tool_call_id);
}

// Normalize tool_calls[].id on assistant messages
if (Array.isArray(normalized.tool_calls)) {
normalized.tool_calls = (normalized.tool_calls as Record<string, unknown>[]).map(
(tc) => {
if (tc.id && typeof tc.id === "string") {
return { ...tc, id: sanitizeToolCallId(tc.id) };
}
return tc;
},
);
}

return normalized;
});
}

/**
* Strip non-alphanumeric characters and ensure the ID is exactly 9 chars,
* matching Workers AI's `[a-zA-Z0-9]{9}` validation pattern.
*
* **Why this exists:** The Workers AI binding validates `tool_call_id` with
* a strict `[a-zA-Z0-9]{9}` regex, but it *generates* IDs like
* `chatcmpl-tool-875d3ec6179676ae` (with dashes, >9 chars). Those IDs are
* then rejected when sent back in a follow-up request. This is a known
* Workers AI issue — see workers-ai.md (Issue 3). Once the Workers AI team
* fixes the validation, this function becomes an idempotent no-op for
* IDs that already match the pattern.
*/
function sanitizeToolCallId(id: string): string {
const alphanumeric = id.replace(/[^a-zA-Z0-9]/g, "");
// Pad with zeros if too short, truncate if too long
return alphanumeric.slice(0, 9).padEnd(9, "0");
}

/**
* Creates a fetch function that intercepts OpenAI SDK requests and translates them
* to Workers AI binding calls (env.AI.run). This allows the WorkersAiTextAdapter
Expand Down Expand Up @@ -422,7 +383,7 @@ export function createWorkersAiBindingFetch(
arguments: unknown;
function?: { name: string; arguments?: unknown };
}) => ({
id: sanitizeToolCallId(tc.id || crypto.randomUUID()),
id: tc.id || crypto.randomUUID(),
type: "function",
function: {
name: tc.function?.name || tc.name || "",
Expand Down Expand Up @@ -479,9 +440,9 @@ function transformWorkersAiStream(
// like Qwen3, Kimi K2.5 stream OpenAI-compatible SSE through the binding).
// In that case, flush() should only emit [DONE] and skip the finish chunk.
let isOpenAiFormat = false;
// Track which tool call indices we've already emitted an `id` for,
// so subsequent argument deltas don't duplicate the id/type/name fields.
const emittedToolCallStart = new Set<number>();
// Track tool call state per index: store the generated/assigned ID so that
// subsequent argument deltas use the same ID (matching the working streaming.ts pattern).
const toolCallState = new Map<number, { id: string; name: string }>();

return source.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
Expand All @@ -505,15 +466,26 @@ function transformWorkersAiStream(
// directly through the binding, with `choices[].delta.content` and
// optional `reasoning_content`. Detect this and pass through as-is.
if (parsed.choices !== undefined) {
// Already OpenAI format — pass through with only tool_call_id
// sanitization for any tool calls present.
// Already OpenAI format — pass through but ensure each tool call
// index gets a unique, stable ID across all chunks.
isOpenAiFormat = true;
const choice = parsed.choices?.[0];
if (choice?.delta?.tool_calls) {
hasToolCalls = true;
for (const tc of choice.delta.tool_calls) {
if (tc.id && typeof tc.id === "string") {
tc.id = sanitizeToolCallId(tc.id);
const tcIndex = tc.index ?? 0;
if (!toolCallState.has(tcIndex)) {
// First chunk for this index — generate/store unique ID
const id = tc.id || `call${streamId}${tcIndex}`;
toolCallState.set(tcIndex, {
id,
name: tc.function?.name || "",
});
tc.id = id;
} else {
// Subsequent chunk — reuse stored ID, remove id from delta
// (OpenAI format only sends id in first chunk)
delete tc.id;
}
}
}
Expand Down Expand Up @@ -572,13 +544,11 @@ function transformWorkersAiStream(
index: tcIndex,
};

if (!emittedToolCallStart.has(tcIndex)) {
if (!toolCallState.has(tcIndex)) {
// First chunk for this tool call index — emit id, type, name.
// Use sanitizeToolCallId so the ID survives round-trip through
// the binding's strict `[a-zA-Z0-9]{9}` validation.
emittedToolCallStart.add(tcIndex);
const rawId = tcId || `call${streamId}${tcIndex}`;
toolCallDelta.id = sanitizeToolCallId(rawId);
const id = tcId || `call${streamId}${tcIndex}`;
toolCallState.set(tcIndex, { id, name: tcName || "" });
toolCallDelta.id = id;
toolCallDelta.type = "function";
toolCallDelta.function = {
name: tcName || "",
Expand Down
Loading
Loading