Browse Source

fix: normalize tool call IDs for cross-provider compatibility via OpenRouter (#10102)

Daniel 2 months ago
parent
commit
a7b192adca

+ 7 - 1
src/api/providers/openrouter.ts

@@ -17,6 +17,7 @@ import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCal
 import type { ApiHandlerOptions, ModelRecord } from "../../shared/api"
 
 import { convertToOpenAiMessages } from "../transform/openai-format"
+import { normalizeMistralToolCallId } from "../transform/mistral-format"
 import { resolveToolProtocol } from "../../utils/resolveToolProtocol"
 import { TOOL_PROTOCOL } from "@roo-code/types"
 import { ApiStreamChunk } from "../transform/stream"
@@ -226,9 +227,14 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
 		}
 
 		// Convert Anthropic messages to OpenAI format.
+		// Pass normalization function for Mistral compatibility (requires 9-char alphanumeric IDs)
+		const isMistral = modelId.toLowerCase().includes("mistral")
 		let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
 			{ role: "system", content: systemPrompt },
-			...convertToOpenAiMessages(messages),
+			...convertToOpenAiMessages(
+				messages,
+				isMistral ? { normalizeToolCallId: normalizeMistralToolCallId } : undefined,
+			),
 		]
 
 		// DeepSeek highly recommends using user instead of system role.

+ 47 - 4
src/api/transform/__tests__/mistral-format.spec.ts

@@ -2,7 +2,44 @@
 
 import { Anthropic } from "@anthropic-ai/sdk"
 
-import { convertToMistralMessages } from "../mistral-format"
+import { convertToMistralMessages, normalizeMistralToolCallId } from "../mistral-format"
+
+describe("normalizeMistralToolCallId", () => {
+	it("should strip non-alphanumeric characters and truncate to 9 characters", () => {
+		// OpenAI-style tool call ID: "call_5019f900..." -> "call5019f900..." -> first 9 chars = "call5019f"
+		expect(normalizeMistralToolCallId("call_5019f900a247472bacde0b82")).toBe("call5019f")
+	})
+
+	it("should handle Anthropic-style tool call IDs", () => {
+		// Anthropic-style tool call ID
+		expect(normalizeMistralToolCallId("toolu_01234567890abcdef")).toBe("toolu0123")
+	})
+
+	it("should pad short IDs to 9 characters", () => {
+		expect(normalizeMistralToolCallId("abc")).toBe("abc000000")
+		expect(normalizeMistralToolCallId("tool-1")).toBe("tool10000")
+	})
+
+	it("should handle IDs that are exactly 9 alphanumeric characters", () => {
+		expect(normalizeMistralToolCallId("abcd12345")).toBe("abcd12345")
+	})
+
+	it("should return consistent results for the same input", () => {
+		const id = "call_5019f900a247472bacde0b82"
+		expect(normalizeMistralToolCallId(id)).toBe(normalizeMistralToolCallId(id))
+	})
+
+	it("should handle edge cases", () => {
+		// Empty string
+		expect(normalizeMistralToolCallId("")).toBe("000000000")
+
+		// Only non-alphanumeric characters
+		expect(normalizeMistralToolCallId("---___---")).toBe("000000000")
+
+		// Mixed special characters
+		expect(normalizeMistralToolCallId("a-b_c.d@e")).toBe("abcde0000")
+	})
+})
 
 describe("convertToMistralMessages", () => {
 	it("should convert simple text messages for user and assistant roles", () => {
@@ -87,7 +124,9 @@ describe("convertToMistralMessages", () => {
 		const mistralMessages = convertToMistralMessages(anthropicMessages)
 		expect(mistralMessages).toHaveLength(1)
 		expect(mistralMessages[0].role).toBe("tool")
-		expect((mistralMessages[0] as { toolCallId?: string }).toolCallId).toBe("weather-123")
+		expect((mistralMessages[0] as { toolCallId?: string }).toolCallId).toBe(
+			normalizeMistralToolCallId("weather-123"),
+		)
 		expect(mistralMessages[0].content).toBe("Current temperature in London: 20°C")
 	})
 
@@ -124,7 +163,9 @@ describe("convertToMistralMessages", () => {
 
 		// Only the tool result should be present
 		expect(mistralMessages[0].role).toBe("tool")
-		expect((mistralMessages[0] as { toolCallId?: string }).toolCallId).toBe("weather-123")
+		expect((mistralMessages[0] as { toolCallId?: string }).toolCallId).toBe(
+			normalizeMistralToolCallId("weather-123"),
+		)
 		expect(mistralMessages[0].content).toBe("Current temperature in London: 20°C")
 	})
 
@@ -265,7 +306,9 @@ describe("convertToMistralMessages", () => {
 
 		// Tool result message
 		expect(mistralMessages[2].role).toBe("tool")
-		expect((mistralMessages[2] as { toolCallId?: string }).toolCallId).toBe("search-123")
+		expect((mistralMessages[2] as { toolCallId?: string }).toolCallId).toBe(
+			normalizeMistralToolCallId("search-123"),
+		)
 		expect(mistralMessages[2].content).toBe("Found information about different mountain types.")
 
 		// Final assistant message

+ 100 - 4
src/api/transform/__tests__/openai-format.spec.ts

@@ -4,6 +4,7 @@ import { Anthropic } from "@anthropic-ai/sdk"
 import OpenAI from "openai"
 
 import { convertToOpenAiMessages } from "../openai-format"
+import { normalizeMistralToolCallId } from "../mistral-format"
 
 describe("convertToOpenAiMessages", () => {
 	it("should convert simple text messages", () => {
@@ -70,7 +71,7 @@ describe("convertToOpenAiMessages", () => {
 		})
 	})
 
-	it("should handle assistant messages with tool use", () => {
+	it("should handle assistant messages with tool use (no normalization without normalizeToolCallId)", () => {
 		const anthropicMessages: Anthropic.Messages.MessageParam[] = [
 			{
 				role: "assistant",
@@ -97,7 +98,7 @@ describe("convertToOpenAiMessages", () => {
 		expect(assistantMessage.content).toBe("Let me check the weather.")
 		expect(assistantMessage.tool_calls).toHaveLength(1)
 		expect(assistantMessage.tool_calls![0]).toEqual({
-			id: "weather-123",
+			id: "weather-123", // Not normalized without normalizeToolCallId function
 			type: "function",
 			function: {
 				name: "get_weather",
@@ -106,7 +107,7 @@ describe("convertToOpenAiMessages", () => {
 		})
 	})
 
-	it("should handle user messages with tool results", () => {
+	it("should handle user messages with tool results (no normalization without normalizeToolCallId)", () => {
 		const anthropicMessages: Anthropic.Messages.MessageParam[] = [
 			{
 				role: "user",
@@ -125,7 +126,102 @@ describe("convertToOpenAiMessages", () => {
 
 		const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam
 		expect(toolMessage.role).toBe("tool")
-		expect(toolMessage.tool_call_id).toBe("weather-123")
+		expect(toolMessage.tool_call_id).toBe("weather-123") // Not normalized without normalizeToolCallId function
 		expect(toolMessage.content).toBe("Current temperature in London: 20°C")
 	})
+
+	it("should normalize tool call IDs when normalizeToolCallId function is provided", () => {
+		const anthropicMessages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "assistant",
+				content: [
+					{
+						type: "tool_use",
+						id: "call_5019f900a247472bacde0b82",
+						name: "read_file",
+						input: { path: "test.ts" },
+					},
+				],
+			},
+			{
+				role: "user",
+				content: [
+					{
+						type: "tool_result",
+						tool_use_id: "call_5019f900a247472bacde0b82",
+						content: "file contents",
+					},
+				],
+			},
+		]
+
+		// With normalizeToolCallId function - should normalize
+		const openAiMessages = convertToOpenAiMessages(anthropicMessages, {
+			normalizeToolCallId: normalizeMistralToolCallId,
+		})
+
+		const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam
+		expect(assistantMessage.tool_calls![0].id).toBe(normalizeMistralToolCallId("call_5019f900a247472bacde0b82"))
+
+		const toolMessage = openAiMessages[1] as OpenAI.Chat.ChatCompletionToolMessageParam
+		expect(toolMessage.tool_call_id).toBe(normalizeMistralToolCallId("call_5019f900a247472bacde0b82"))
+	})
+
+	it("should not normalize tool call IDs when normalizeToolCallId function is not provided", () => {
+		const anthropicMessages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "assistant",
+				content: [
+					{
+						type: "tool_use",
+						id: "call_5019f900a247472bacde0b82",
+						name: "read_file",
+						input: { path: "test.ts" },
+					},
+				],
+			},
+			{
+				role: "user",
+				content: [
+					{
+						type: "tool_result",
+						tool_use_id: "call_5019f900a247472bacde0b82",
+						content: "file contents",
+					},
+				],
+			},
+		]
+
+		// Without normalizeToolCallId function - should NOT normalize
+		const openAiMessages = convertToOpenAiMessages(anthropicMessages, {})
+
+		const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam
+		expect(assistantMessage.tool_calls![0].id).toBe("call_5019f900a247472bacde0b82")
+
+		const toolMessage = openAiMessages[1] as OpenAI.Chat.ChatCompletionToolMessageParam
+		expect(toolMessage.tool_call_id).toBe("call_5019f900a247472bacde0b82")
+	})
+
+	it("should use custom normalization function when provided", () => {
+		const anthropicMessages: Anthropic.Messages.MessageParam[] = [
+			{
+				role: "assistant",
+				content: [
+					{
+						type: "tool_use",
+						id: "toolu_123",
+						name: "test_tool",
+						input: {},
+					},
+				],
+			},
+		]
+
+		// Custom normalization function that prefixes with "custom_"
+		const customNormalizer = (id: string) => `custom_${id}`
+		const openAiMessages = convertToOpenAiMessages(anthropicMessages, { normalizeToolCallId: customNormalizer })
+
+		const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam
+		expect(assistantMessage.tool_calls![0].id).toBe("custom_toolu_123")
+	})
 })

+ 27 - 2
src/api/transform/mistral-format.ts

@@ -4,6 +4,31 @@ import { SystemMessage } from "@mistralai/mistralai/models/components/systemmess
 import { ToolMessage } from "@mistralai/mistralai/models/components/toolmessage"
 import { UserMessage } from "@mistralai/mistralai/models/components/usermessage"
 
+/**
+ * Normalizes a tool call ID to be compatible with Mistral's strict ID requirements.
+ * Mistral requires tool call IDs to be:
+ * - Only alphanumeric characters (a-z, A-Z, 0-9)
+ * - Exactly 9 characters in length
+ *
+ * This function extracts alphanumeric characters from the original ID and
+ * pads/truncates to exactly 9 characters, ensuring deterministic output.
+ *
+ * @param id - The original tool call ID (e.g., "call_5019f900a247472bacde0b82" or "toolu_123")
+ * @returns A normalized 9-character alphanumeric ID compatible with Mistral
+ */
+export function normalizeMistralToolCallId(id: string): string {
+	// Extract only alphanumeric characters
+	const alphanumeric = id.replace(/[^a-zA-Z0-9]/g, "")
+
+	// Take first 9 characters, or pad with zeros if shorter
+	if (alphanumeric.length >= 9) {
+		return alphanumeric.slice(0, 9)
+	}
+
+	// Pad with zeros to reach 9 characters
+	return alphanumeric.padEnd(9, "0")
+}
+
 export type MistralMessage =
 	| (SystemMessage & { role: "system" })
 	| (UserMessage & { role: "user" })
@@ -67,7 +92,7 @@ export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.M
 
 						mistralMessages.push({
 							role: "tool",
-							toolCallId: toolResult.tool_use_id,
+							toolCallId: normalizeMistralToolCallId(toolResult.tool_use_id),
 							content: resultContent,
 						} as ToolMessage & { role: "tool" })
 					}
@@ -122,7 +147,7 @@ export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.M
 				let toolCalls: MistralToolCallMessage[] | undefined
 				if (toolMessages.length > 0) {
 					toolCalls = toolMessages.map((toolUse) => ({
-						id: toolUse.id,
+						id: normalizeMistralToolCallId(toolUse.id),
 						type: "function" as const,
 						function: {
 							name: toolUse.name,

+ 18 - 2
src/api/transform/openai-format.ts

@@ -1,11 +1,27 @@
 import { Anthropic } from "@anthropic-ai/sdk"
 import OpenAI from "openai"
 
+/**
+ * Options for converting Anthropic messages to OpenAI format.
+ */
+export interface ConvertToOpenAiMessagesOptions {
+	/**
+	 * Optional function to normalize tool call IDs for providers with strict ID requirements.
+	 * When provided, this function will be applied to all tool_use IDs and tool_result tool_use_ids.
+	 * This allows callers to declare provider-specific ID format requirements.
+	 */
+	normalizeToolCallId?: (id: string) => string
+}
+
 export function convertToOpenAiMessages(
 	anthropicMessages: Anthropic.Messages.MessageParam[],
+	options?: ConvertToOpenAiMessagesOptions,
 ): OpenAI.Chat.ChatCompletionMessageParam[] {
 	const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = []
 
+	// Use provided normalization function or identity function
+	const normalizeId = options?.normalizeToolCallId ?? ((id: string) => id)
+
 	for (const anthropicMessage of anthropicMessages) {
 		if (typeof anthropicMessage.content === "string") {
 			openAiMessages.push({ role: anthropicMessage.role, content: anthropicMessage.content })
@@ -56,7 +72,7 @@ export function convertToOpenAiMessages(
 					}
 					openAiMessages.push({
 						role: "tool",
-						tool_call_id: toolMessage.tool_use_id,
+						tool_call_id: normalizeId(toolMessage.tool_use_id),
 						content: content,
 					})
 				})
@@ -123,7 +139,7 @@ export function convertToOpenAiMessages(
 
 				// Process tool use messages
 				let tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolMessages.map((toolMessage) => ({
-					id: toolMessage.id,
+					id: normalizeId(toolMessage.id),
 					type: "function",
 					function: {
 						name: toolMessage.name,