diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 29373932b29..20a0f58b994 100644 --- a/src/api/providers/openrouter.ts +++ b/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. diff --git a/src/api/transform/__tests__/mistral-format.spec.ts b/src/api/transform/__tests__/mistral-format.spec.ts index 51d70bb3114..290bea1ec50 100644 --- a/src/api/transform/__tests__/mistral-format.spec.ts +++ b/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 diff --git a/src/api/transform/__tests__/openai-format.spec.ts b/src/api/transform/__tests__/openai-format.spec.ts index bab655dcb59..da9329fa326 100644 --- a/src/api/transform/__tests__/openai-format.spec.ts +++ b/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") + }) }) diff --git a/src/api/transform/mistral-format.ts b/src/api/transform/mistral-format.ts index c184e2d731c..d32f84d6e06 100644 --- a/src/api/transform/mistral-format.ts +++ b/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, diff --git a/src/api/transform/openai-format.ts b/src/api/transform/openai-format.ts index 6a88491b7e1..56d6441c48b 100644 --- a/src/api/transform/openai-format.ts +++ b/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,