From 6679a83b327911574624499cdaf1979b59bca5e7 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 4 Sep 2025 05:17:08 +0200 Subject: [PATCH] fix(ai): Sanitize tool call IDs for Anthropic API compatibility - Anthropic API requires tool call IDs to match pattern ^[a-zA-Z0-9_-]+$ - OpenAI Responses API generates IDs with pipe character (|) which breaks Anthropic - Added sanitizeToolCallId() to replace invalid characters with underscores - Fixes cross-provider handoffs from OpenAI Responses to Anthropic - Added test to verify the fix works --- packages/ai/src/models.generated.ts | 118 +++++++++--------- packages/ai/src/providers/anthropic.ts | 10 +- .../ai/test/cross-provider-toolcall.test.ts | 113 +++++++++++++++++ 3 files changed, 180 insertions(+), 61 deletions(-) create mode 100644 packages/ai/test/cross-provider-toolcall.test.ts diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 3b2263f4..5242d3da 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -2610,13 +2610,13 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.003, - output: 0.006, + input: 0.012, + output: 0.024, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 20000, - maxTokens: 20000, + contextWindow: 131072, + maxTokens: 16384, } satisfies Model<"openai-completions">, "qwen/qwen-2.5-72b-instruct": { id: "qwen/qwen-2.5-72b-instruct", @@ -2652,23 +2652,6 @@ export const MODELS = { contextWindow: 32768, maxTokens: 4096, } satisfies Model<"openai-completions">, - "cohere/command-r-plus-08-2024": { - id: "cohere/command-r-plus-08-2024", - name: "Cohere: Command R+ (08-2024)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2.5, - output: 10, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4000, - } satisfies Model<"openai-completions">, "cohere/command-r-08-2024": { id: "cohere/command-r-08-2024", name: "Cohere: Command R (08-2024)", @@ -2686,6 +2669,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4000, } satisfies Model<"openai-completions">, + "cohere/command-r-plus-08-2024": { + id: "cohere/command-r-plus-08-2024", + name: "Cohere: Command R+ (08-2024)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2.5, + output: 10, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4000, + } satisfies Model<"openai-completions">, "microsoft/phi-3.5-mini-128k-instruct": { id: "microsoft/phi-3.5-mini-128k-instruct", name: "Microsoft: Phi-3.5 Mini 128K Instruct", @@ -2720,23 +2720,6 @@ export const MODELS = { contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-8b-instruct": { - id: "meta-llama/llama-3.1-8b-instruct", - name: "Meta: Llama 3.1 8B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.015, - output: 0.02, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-405b-instruct": { id: "meta-llama/llama-3.1-405b-instruct", name: "Meta: Llama 3.1 405B Instruct", @@ -2754,6 +2737,23 @@ export const MODELS = { contextWindow: 32768, maxTokens: 16384, } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-8b-instruct": { + id: "meta-llama/llama-3.1-8b-instruct", + name: "Meta: Llama 3.1 8B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.015, + output: 0.02, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-70b-instruct": { id: "meta-llama/llama-3.1-70b-instruct", name: "Meta: Llama 3.1 70B Instruct", @@ -2780,13 +2780,13 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.0075, - output: 0.049999999999999996, + input: 0.01, + output: 0.0400032, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 32000, - maxTokens: 4096, + contextWindow: 131072, + maxTokens: 128000, } satisfies Model<"openai-completions">, "mistralai/mistral-7b-instruct:free": { id: "mistralai/mistral-7b-instruct:free", @@ -2873,23 +2873,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "meta-llama/llama-3-8b-instruct": { - id: "meta-llama/llama-3-8b-instruct", - name: "Meta: Llama 3 8B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.03, - output: 0.06, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "meta-llama/llama-3-70b-instruct": { id: "meta-llama/llama-3-70b-instruct", name: "Meta: Llama 3 70B Instruct", @@ -2907,6 +2890,23 @@ export const MODELS = { contextWindow: 8192, maxTokens: 16384, } satisfies Model<"openai-completions">, + "meta-llama/llama-3-8b-instruct": { + id: "meta-llama/llama-3-8b-instruct", + name: "Meta: Llama 3 8B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.03, + output: 0.06, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "mistralai/mixtral-8x22b-instruct": { id: "mistralai/mixtral-8x22b-instruct", name: "Mistral: Mixtral 8x22B Instruct", diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index ea5da9e9..b6c20726 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -282,6 +282,12 @@ function buildParams( return params; } +// Sanitize tool call IDs to match Anthropic's required pattern: ^[a-zA-Z0-9_-]+$ +function sanitizeToolCallId(id: string): string { + // Replace any character that isn't alphanumeric, underscore, or hyphen with underscore + return id.replace(/[^a-zA-Z0-9_-]/g, "_"); +} + function convertMessages(messages: Message[], model: Model<"anthropic-messages">): MessageParam[] { const params: MessageParam[] = []; @@ -348,7 +354,7 @@ function convertMessages(messages: Message[], model: Model<"anthropic-messages"> } else if (block.type === "toolCall") { blocks.push({ type: "tool_use", - id: block.id, + id: sanitizeToolCallId(block.id), name: block.name, input: block.arguments, }); @@ -365,7 +371,7 @@ function convertMessages(messages: Message[], model: Model<"anthropic-messages"> content: [ { type: "tool_result", - tool_use_id: msg.toolCallId, + tool_use_id: sanitizeToolCallId(msg.toolCallId), content: msg.content, is_error: msg.isError, }, diff --git a/packages/ai/test/cross-provider-toolcall.test.ts b/packages/ai/test/cross-provider-toolcall.test.ts new file mode 100644 index 00000000..2707ce56 --- /dev/null +++ b/packages/ai/test/cross-provider-toolcall.test.ts @@ -0,0 +1,113 @@ +import { type Context, complete, getModel } from "../src/index.js"; + +async function testCrossProviderToolCall() { + console.log("Testing cross-provider tool call handoff...\n"); + + // Define a simple tool + const tools = [ + { + name: "get_weather", + description: "Get current weather for a location", + parameters: { + type: "object", + properties: { + location: { type: "string", description: "City name" }, + }, + required: ["location"], + }, + }, + ]; + + // Create context with tools + const context: Context = { + systemPrompt: "You are a helpful assistant. Use the get_weather tool when asked about weather.", + messages: [{ role: "user", content: "What is the weather in Paris?" }], + tools, + }; + + try { + // Step 1: Get tool call from GPT-5 + console.log("Step 1: Getting tool call from GPT-5..."); + const gpt5 = getModel("openai", "gpt-5-mini"); + const gpt5Response = await complete(gpt5, context); + context.messages.push(gpt5Response); + + // Check for tool calls + const toolCalls = gpt5Response.content.filter((b) => b.type === "toolCall"); + console.log(`GPT-5 made ${toolCalls.length} tool call(s)`); + + if (toolCalls.length > 0) { + const toolCall = toolCalls[0]; + console.log(`Tool call ID: ${toolCall.id}`); + console.log(`Tool call contains pipe: ${toolCall.id.includes("|")}`); + console.log(`Tool: ${toolCall.name}(${JSON.stringify(toolCall.arguments)})\n`); + + // Add tool result + context.messages.push({ + role: "toolResult", + toolCallId: toolCall.id, + toolName: toolCall.name, + content: JSON.stringify({ + location: "Paris", + temperature: "22°C", + conditions: "Partly cloudy", + }), + isError: false, + }); + + // Step 2: Send to Claude Haiku for follow-up + console.log("Step 2: Sending to Claude Haiku for follow-up..."); + const haiku = getModel("anthropic", "claude-3-5-haiku-20241022"); + + try { + const haikuResponse = await complete(haiku, context); + console.log("✅ Claude Haiku successfully processed the conversation!"); + console.log("Response content types:", haikuResponse.content.map((b) => b.type).join(", ")); + console.log("Number of content blocks:", haikuResponse.content.length); + console.log("Stop reason:", haikuResponse.stopReason); + if (haikuResponse.error) { + console.log("Error message:", haikuResponse.error); + } + + // Print all response content + for (const block of haikuResponse.content) { + if (block.type === "text") { + console.log("\nClaude text response:", block.text); + } else if (block.type === "thinking") { + console.log("\nClaude thinking:", block.thinking); + } else if (block.type === "toolCall") { + console.log("\nClaude tool call:", block.name, block.arguments); + } + } + + if (haikuResponse.content.length === 0) { + console.log("⚠️ Claude returned an empty response!"); + } + } catch (error) { + console.error("❌ Claude Haiku failed to process the conversation:"); + console.error("Error:", error); + + // Check if it's related to the tool call ID + if (error instanceof Error && error.message.includes("tool")) { + console.error("\n⚠️ This appears to be a tool call ID issue!"); + console.error("The pipe character (|) in OpenAI Response API tool IDs might be causing problems."); + } + } + } else { + console.log("No tool calls were made by GPT-5"); + } + } catch (error) { + console.error("Test failed:", error); + } +} + +// Set API keys from environment or pass them explicitly +const openaiKey = process.env.OPENAI_API_KEY; +const anthropicKey = process.env.ANTHROPIC_API_KEY; + +if (!openaiKey || !anthropicKey) { + console.error("Please set OPENAI_API_KEY and ANTHROPIC_API_KEY environment variables"); + process.exit(1); +} + +testCrossProviderToolCall().catch(console.error);