From 72eddd7e127dc11eeac7fcb5e4933a98c20373ea Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 15 Dec 2025 14:06:32 -0500 Subject: [PATCH 1/3] fix: normalize tool call IDs for Mistral compatibility via OpenRouter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some providers (like Mistral) require tool call IDs to be: - Only alphanumeric characters (a-z, A-Z, 0-9) - Exactly 9 characters in length This caused errors when conversations with tool calls from one provider (e.g., OpenAI's call_xxx format) were routed to Mistral via OpenRouter. Solution: - Added normalizeToolCallId() function that strips non-alphanumeric characters and pads/truncates to exactly 9 characters - Added modelId option to convertToOpenAiMessages() that conditionally normalizes IDs only when the model contains 'mistral' - OpenRouter now passes modelId to enable normalization for Mistral models - Direct Mistral provider uses convertToMistralMessages() which always normalizes IDs This scoped approach only affects Mistral models, avoiding any potential impact on the 15+ other providers using OpenAI-compatible format. Example transformations: - call_5019f900a247472bacde0b82 → call5019f - toolu_01234567890abcdef → toolu0123 - weather-123 → weather12 --- src/api/providers/openrouter.ts | 3 +- .../__tests__/mistral-format.spec.ts | 7 +- .../transform/__tests__/openai-format.spec.ts | 141 +++++++++++++++++- src/api/transform/mistral-format.ts | 6 +- src/api/transform/openai-format.ts | 46 +++++- 5 files changed, 190 insertions(+), 13 deletions(-) diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 29373932b29..d4329ca5e33 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -226,9 +226,10 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } // Convert Anthropic messages to OpenAI format. + // Pass modelId to normalize tool call IDs for Mistral compatibility let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ { role: "system", content: systemPrompt }, - ...convertToOpenAiMessages(messages), + ...convertToOpenAiMessages(messages, { modelId }), ] // 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..e5cd992acb8 100644 --- a/src/api/transform/__tests__/mistral-format.spec.ts +++ b/src/api/transform/__tests__/mistral-format.spec.ts @@ -3,6 +3,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import { convertToMistralMessages } from "../mistral-format" +import { normalizeToolCallId } from "../openai-format" describe("convertToMistralMessages", () => { it("should convert simple text messages for user and assistant roles", () => { @@ -87,7 +88,7 @@ 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(normalizeToolCallId("weather-123")) expect(mistralMessages[0].content).toBe("Current temperature in London: 20°C") }) @@ -124,7 +125,7 @@ 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(normalizeToolCallId("weather-123")) expect(mistralMessages[0].content).toBe("Current temperature in London: 20°C") }) @@ -265,7 +266,7 @@ 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(normalizeToolCallId("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..7e411673d3f 100644 --- a/src/api/transform/__tests__/openai-format.spec.ts +++ b/src/api/transform/__tests__/openai-format.spec.ts @@ -3,7 +3,44 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { convertToOpenAiMessages } from "../openai-format" +import { convertToOpenAiMessages, normalizeToolCallId } from "../openai-format" + +describe("normalizeToolCallId", () => { + it("should strip non-alphanumeric characters and truncate to 9 characters", () => { + // OpenAI-style tool call ID: "call_5019f900..." -> "call5019f900..." -> first 9 chars = "call5019f" + expect(normalizeToolCallId("call_5019f900a247472bacde0b82")).toBe("call5019f") + }) + + it("should handle Anthropic-style tool call IDs", () => { + // Anthropic-style tool call ID + expect(normalizeToolCallId("toolu_01234567890abcdef")).toBe("toolu0123") + }) + + it("should pad short IDs to 9 characters", () => { + expect(normalizeToolCallId("abc")).toBe("abc000000") + expect(normalizeToolCallId("tool-1")).toBe("tool10000") + }) + + it("should handle IDs that are exactly 9 alphanumeric characters", () => { + expect(normalizeToolCallId("abcd12345")).toBe("abcd12345") + }) + + it("should return consistent results for the same input", () => { + const id = "call_5019f900a247472bacde0b82" + expect(normalizeToolCallId(id)).toBe(normalizeToolCallId(id)) + }) + + it("should handle edge cases", () => { + // Empty string + expect(normalizeToolCallId("")).toBe("000000000") + + // Only non-alphanumeric characters + expect(normalizeToolCallId("---___---")).toBe("000000000") + + // Mixed special characters + expect(normalizeToolCallId("a-b_c.d@e")).toBe("abcde0000") + }) +}) describe("convertToOpenAiMessages", () => { it("should convert simple text messages", () => { @@ -70,7 +107,7 @@ describe("convertToOpenAiMessages", () => { }) }) - it("should handle assistant messages with tool use", () => { + it("should handle assistant messages with tool use (no normalization without modelId)", () => { const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "assistant", @@ -97,7 +134,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 modelId type: "function", function: { name: "get_weather", @@ -106,7 +143,7 @@ describe("convertToOpenAiMessages", () => { }) }) - it("should handle user messages with tool results", () => { + it("should handle user messages with tool results (no normalization without modelId)", () => { const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "user", @@ -125,7 +162,101 @@ 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 modelId expect(toolMessage.content).toBe("Current temperature in London: 20°C") }) + + it("should normalize tool call IDs when modelId contains 'mistral'", () => { + 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 Mistral model ID - should normalize + const openAiMessages = convertToOpenAiMessages(anthropicMessages, { + modelId: "mistralai/mistral-large-latest", + }) + + const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam + expect(assistantMessage.tool_calls![0].id).toBe(normalizeToolCallId("call_5019f900a247472bacde0b82")) + + const toolMessage = openAiMessages[1] as OpenAI.Chat.ChatCompletionToolMessageParam + expect(toolMessage.tool_call_id).toBe(normalizeToolCallId("call_5019f900a247472bacde0b82")) + }) + + it("should not normalize tool call IDs when modelId does not contain 'mistral'", () => { + 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 non-Mistral model ID - should NOT normalize + const openAiMessages = convertToOpenAiMessages(anthropicMessages, { modelId: "openai/gpt-4" }) + + 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 be case-insensitive when checking for mistral in modelId", () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_123", + name: "test_tool", + input: {}, + }, + ], + }, + ] + + // Uppercase MISTRAL should still trigger normalization + const openAiMessages = convertToOpenAiMessages(anthropicMessages, { modelId: "MISTRAL-7B" }) + + const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam + expect(assistantMessage.tool_calls![0].id).toBe(normalizeToolCallId("toolu_123")) + }) }) diff --git a/src/api/transform/mistral-format.ts b/src/api/transform/mistral-format.ts index c184e2d731c..031995cfdd9 100644 --- a/src/api/transform/mistral-format.ts +++ b/src/api/transform/mistral-format.ts @@ -4,6 +4,8 @@ import { SystemMessage } from "@mistralai/mistralai/models/components/systemmess import { ToolMessage } from "@mistralai/mistralai/models/components/toolmessage" import { UserMessage } from "@mistralai/mistralai/models/components/usermessage" +import { normalizeToolCallId } from "./openai-format" + export type MistralMessage = | (SystemMessage & { role: "system" }) | (UserMessage & { role: "user" }) @@ -67,7 +69,7 @@ export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.M mistralMessages.push({ role: "tool", - toolCallId: toolResult.tool_use_id, + toolCallId: normalizeToolCallId(toolResult.tool_use_id), content: resultContent, } as ToolMessage & { role: "tool" }) } @@ -122,7 +124,7 @@ export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.M let toolCalls: MistralToolCallMessage[] | undefined if (toolMessages.length > 0) { toolCalls = toolMessages.map((toolUse) => ({ - id: toolUse.id, + id: normalizeToolCallId(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..5dd488d008a 100644 --- a/src/api/transform/openai-format.ts +++ b/src/api/transform/openai-format.ts @@ -1,11 +1,51 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" +/** + * Normalizes a tool call ID to be compatible with providers that have strict ID requirements. + * Some providers (like Mistral) require 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 + */ +export function normalizeToolCallId(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") +} + +/** + * Options for converting Anthropic messages to OpenAI format. + */ +export interface ConvertToOpenAiMessagesOptions { + /** + * The model ID being used. If it contains "mistral", tool call IDs will be + * normalized to be compatible with Mistral's strict ID requirements. + */ + modelId?: string +} + export function convertToOpenAiMessages( anthropicMessages: Anthropic.Messages.MessageParam[], + options?: ConvertToOpenAiMessagesOptions, ): OpenAI.Chat.ChatCompletionMessageParam[] { const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [] + // Check if we need to normalize tool call IDs for Mistral compatibility + const shouldNormalizeToolCallIds = options?.modelId?.toLowerCase().includes("mistral") ?? false + for (const anthropicMessage of anthropicMessages) { if (typeof anthropicMessage.content === "string") { openAiMessages.push({ role: anthropicMessage.role, content: anthropicMessage.content }) @@ -56,7 +96,9 @@ export function convertToOpenAiMessages( } openAiMessages.push({ role: "tool", - tool_call_id: toolMessage.tool_use_id, + tool_call_id: shouldNormalizeToolCallIds + ? normalizeToolCallId(toolMessage.tool_use_id) + : toolMessage.tool_use_id, content: content, }) }) @@ -123,7 +165,7 @@ export function convertToOpenAiMessages( // Process tool use messages let tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolMessages.map((toolMessage) => ({ - id: toolMessage.id, + id: shouldNormalizeToolCallIds ? normalizeToolCallId(toolMessage.id) : toolMessage.id, type: "function", function: { name: toolMessage.name, From ccb4e78254dcc8401ae7570e7409d50fc45dc288 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 16 Dec 2025 11:10:27 -0500 Subject: [PATCH 2/3] refactor: pass normalization function instead of modelId to convertToOpenAiMessages Addresses PR feedback about separation of concerns. The convertToOpenAiMessages function now accepts an optional normalizeToolCallId function instead of checking for 'mistral' in the modelId. This allows callers to flexibly declare the tool call ID format without the transform module needing to know about provider-specific requirements. --- src/api/providers/openrouter.ts | 7 +++-- .../transform/__tests__/openai-format.spec.ts | 29 ++++++++++--------- src/api/transform/openai-format.ts | 17 +++++------ 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index d4329ca5e33..98a1ea19c7e 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -16,7 +16,7 @@ import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCal import type { ApiHandlerOptions, ModelRecord } from "../../shared/api" -import { convertToOpenAiMessages } from "../transform/openai-format" +import { convertToOpenAiMessages, normalizeToolCallId } from "../transform/openai-format" import { resolveToolProtocol } from "../../utils/resolveToolProtocol" import { TOOL_PROTOCOL } from "@roo-code/types" import { ApiStreamChunk } from "../transform/stream" @@ -226,10 +226,11 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } // Convert Anthropic messages to OpenAI format. - // Pass modelId to normalize tool call IDs for Mistral compatibility + // 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, { modelId }), + ...convertToOpenAiMessages(messages, isMistral ? { normalizeToolCallId } : undefined), ] // DeepSeek highly recommends using user instead of system role. diff --git a/src/api/transform/__tests__/openai-format.spec.ts b/src/api/transform/__tests__/openai-format.spec.ts index 7e411673d3f..7991366df0e 100644 --- a/src/api/transform/__tests__/openai-format.spec.ts +++ b/src/api/transform/__tests__/openai-format.spec.ts @@ -107,7 +107,7 @@ describe("convertToOpenAiMessages", () => { }) }) - it("should handle assistant messages with tool use (no normalization without modelId)", () => { + it("should handle assistant messages with tool use (no normalization without normalizeToolCallId)", () => { const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "assistant", @@ -134,7 +134,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", // Not normalized without modelId + id: "weather-123", // Not normalized without normalizeToolCallId function type: "function", function: { name: "get_weather", @@ -143,7 +143,7 @@ describe("convertToOpenAiMessages", () => { }) }) - it("should handle user messages with tool results (no normalization without modelId)", () => { + it("should handle user messages with tool results (no normalization without normalizeToolCallId)", () => { const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "user", @@ -162,11 +162,11 @@ describe("convertToOpenAiMessages", () => { const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam expect(toolMessage.role).toBe("tool") - expect(toolMessage.tool_call_id).toBe("weather-123") // Not normalized without modelId + 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 modelId contains 'mistral'", () => { + it("should normalize tool call IDs when normalizeToolCallId function is provided", () => { const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "assistant", @@ -191,9 +191,9 @@ describe("convertToOpenAiMessages", () => { }, ] - // With Mistral model ID - should normalize + // With normalizeToolCallId function - should normalize const openAiMessages = convertToOpenAiMessages(anthropicMessages, { - modelId: "mistralai/mistral-large-latest", + normalizeToolCallId, }) const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam @@ -203,7 +203,7 @@ describe("convertToOpenAiMessages", () => { expect(toolMessage.tool_call_id).toBe(normalizeToolCallId("call_5019f900a247472bacde0b82")) }) - it("should not normalize tool call IDs when modelId does not contain 'mistral'", () => { + it("should not normalize tool call IDs when normalizeToolCallId function is not provided", () => { const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "assistant", @@ -228,8 +228,8 @@ describe("convertToOpenAiMessages", () => { }, ] - // With non-Mistral model ID - should NOT normalize - const openAiMessages = convertToOpenAiMessages(anthropicMessages, { modelId: "openai/gpt-4" }) + // 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") @@ -238,7 +238,7 @@ describe("convertToOpenAiMessages", () => { expect(toolMessage.tool_call_id).toBe("call_5019f900a247472bacde0b82") }) - it("should be case-insensitive when checking for mistral in modelId", () => { + it("should use custom normalization function when provided", () => { const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { role: "assistant", @@ -253,10 +253,11 @@ describe("convertToOpenAiMessages", () => { }, ] - // Uppercase MISTRAL should still trigger normalization - const openAiMessages = convertToOpenAiMessages(anthropicMessages, { modelId: "MISTRAL-7B" }) + // 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(normalizeToolCallId("toolu_123")) + expect(assistantMessage.tool_calls![0].id).toBe("custom_toolu_123") }) }) diff --git a/src/api/transform/openai-format.ts b/src/api/transform/openai-format.ts index 5dd488d008a..f227fa144d4 100644 --- a/src/api/transform/openai-format.ts +++ b/src/api/transform/openai-format.ts @@ -31,10 +31,11 @@ export function normalizeToolCallId(id: string): string { */ export interface ConvertToOpenAiMessagesOptions { /** - * The model ID being used. If it contains "mistral", tool call IDs will be - * normalized to be compatible with Mistral's strict ID requirements. + * 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. */ - modelId?: string + normalizeToolCallId?: (id: string) => string } export function convertToOpenAiMessages( @@ -43,8 +44,8 @@ export function convertToOpenAiMessages( ): OpenAI.Chat.ChatCompletionMessageParam[] { const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [] - // Check if we need to normalize tool call IDs for Mistral compatibility - const shouldNormalizeToolCallIds = options?.modelId?.toLowerCase().includes("mistral") ?? false + // Use provided normalization function or identity function + const normalizeId = options?.normalizeToolCallId ?? ((id: string) => id) for (const anthropicMessage of anthropicMessages) { if (typeof anthropicMessage.content === "string") { @@ -96,9 +97,7 @@ export function convertToOpenAiMessages( } openAiMessages.push({ role: "tool", - tool_call_id: shouldNormalizeToolCallIds - ? normalizeToolCallId(toolMessage.tool_use_id) - : toolMessage.tool_use_id, + tool_call_id: normalizeId(toolMessage.tool_use_id), content: content, }) }) @@ -165,7 +164,7 @@ export function convertToOpenAiMessages( // Process tool use messages let tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolMessages.map((toolMessage) => ({ - id: shouldNormalizeToolCallIds ? normalizeToolCallId(toolMessage.id) : toolMessage.id, + id: normalizeId(toolMessage.id), type: "function", function: { name: toolMessage.name, From 58410fe379bc7a5a5095feda99899b4c2ed2f099 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 16 Dec 2025 11:25:55 -0500 Subject: [PATCH 3/3] refactor: move normalizeMistralToolCallId to mistral-format.ts Moves the tool call ID normalization function to mistral-format.ts with explicit Mistral naming (normalizeMistralToolCallId) since Mistral is the only provider that requires this specific 9-character alphanumeric ID format. This better separates concerns by keeping provider-specific logic in the appropriate module while keeping the generic convertToOpenAiMessages function flexible via the optional normalizeToolCallId callback. --- src/api/providers/openrouter.ts | 8 ++- .../__tests__/mistral-format.spec.ts | 52 +++++++++++++++++-- .../transform/__tests__/openai-format.spec.ts | 46 ++-------------- src/api/transform/mistral-format.ts | 29 +++++++++-- src/api/transform/openai-format.ts | 25 --------- 5 files changed, 84 insertions(+), 76 deletions(-) diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 98a1ea19c7e..20a0f58b994 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -16,7 +16,8 @@ import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCal import type { ApiHandlerOptions, ModelRecord } from "../../shared/api" -import { convertToOpenAiMessages, normalizeToolCallId } from "../transform/openai-format" +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" @@ -230,7 +231,10 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH const isMistral = modelId.toLowerCase().includes("mistral") let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ { role: "system", content: systemPrompt }, - ...convertToOpenAiMessages(messages, isMistral ? { normalizeToolCallId } : undefined), + ...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 e5cd992acb8..290bea1ec50 100644 --- a/src/api/transform/__tests__/mistral-format.spec.ts +++ b/src/api/transform/__tests__/mistral-format.spec.ts @@ -2,8 +2,44 @@ import { Anthropic } from "@anthropic-ai/sdk" -import { convertToMistralMessages } from "../mistral-format" -import { normalizeToolCallId } from "../openai-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", () => { @@ -88,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(normalizeToolCallId("weather-123")) + expect((mistralMessages[0] as { toolCallId?: string }).toolCallId).toBe( + normalizeMistralToolCallId("weather-123"), + ) expect(mistralMessages[0].content).toBe("Current temperature in London: 20°C") }) @@ -125,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(normalizeToolCallId("weather-123")) + expect((mistralMessages[0] as { toolCallId?: string }).toolCallId).toBe( + normalizeMistralToolCallId("weather-123"), + ) expect(mistralMessages[0].content).toBe("Current temperature in London: 20°C") }) @@ -266,7 +306,9 @@ describe("convertToMistralMessages", () => { // Tool result message expect(mistralMessages[2].role).toBe("tool") - expect((mistralMessages[2] as { toolCallId?: string }).toolCallId).toBe(normalizeToolCallId("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 7991366df0e..da9329fa326 100644 --- a/src/api/transform/__tests__/openai-format.spec.ts +++ b/src/api/transform/__tests__/openai-format.spec.ts @@ -3,44 +3,8 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { convertToOpenAiMessages, normalizeToolCallId } from "../openai-format" - -describe("normalizeToolCallId", () => { - it("should strip non-alphanumeric characters and truncate to 9 characters", () => { - // OpenAI-style tool call ID: "call_5019f900..." -> "call5019f900..." -> first 9 chars = "call5019f" - expect(normalizeToolCallId("call_5019f900a247472bacde0b82")).toBe("call5019f") - }) - - it("should handle Anthropic-style tool call IDs", () => { - // Anthropic-style tool call ID - expect(normalizeToolCallId("toolu_01234567890abcdef")).toBe("toolu0123") - }) - - it("should pad short IDs to 9 characters", () => { - expect(normalizeToolCallId("abc")).toBe("abc000000") - expect(normalizeToolCallId("tool-1")).toBe("tool10000") - }) - - it("should handle IDs that are exactly 9 alphanumeric characters", () => { - expect(normalizeToolCallId("abcd12345")).toBe("abcd12345") - }) - - it("should return consistent results for the same input", () => { - const id = "call_5019f900a247472bacde0b82" - expect(normalizeToolCallId(id)).toBe(normalizeToolCallId(id)) - }) - - it("should handle edge cases", () => { - // Empty string - expect(normalizeToolCallId("")).toBe("000000000") - - // Only non-alphanumeric characters - expect(normalizeToolCallId("---___---")).toBe("000000000") - - // Mixed special characters - expect(normalizeToolCallId("a-b_c.d@e")).toBe("abcde0000") - }) -}) +import { convertToOpenAiMessages } from "../openai-format" +import { normalizeMistralToolCallId } from "../mistral-format" describe("convertToOpenAiMessages", () => { it("should convert simple text messages", () => { @@ -193,14 +157,14 @@ describe("convertToOpenAiMessages", () => { // With normalizeToolCallId function - should normalize const openAiMessages = convertToOpenAiMessages(anthropicMessages, { - normalizeToolCallId, + normalizeToolCallId: normalizeMistralToolCallId, }) const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam - expect(assistantMessage.tool_calls![0].id).toBe(normalizeToolCallId("call_5019f900a247472bacde0b82")) + expect(assistantMessage.tool_calls![0].id).toBe(normalizeMistralToolCallId("call_5019f900a247472bacde0b82")) const toolMessage = openAiMessages[1] as OpenAI.Chat.ChatCompletionToolMessageParam - expect(toolMessage.tool_call_id).toBe(normalizeToolCallId("call_5019f900a247472bacde0b82")) + expect(toolMessage.tool_call_id).toBe(normalizeMistralToolCallId("call_5019f900a247472bacde0b82")) }) it("should not normalize tool call IDs when normalizeToolCallId function is not provided", () => { diff --git a/src/api/transform/mistral-format.ts b/src/api/transform/mistral-format.ts index 031995cfdd9..d32f84d6e06 100644 --- a/src/api/transform/mistral-format.ts +++ b/src/api/transform/mistral-format.ts @@ -4,7 +4,30 @@ import { SystemMessage } from "@mistralai/mistralai/models/components/systemmess import { ToolMessage } from "@mistralai/mistralai/models/components/toolmessage" import { UserMessage } from "@mistralai/mistralai/models/components/usermessage" -import { normalizeToolCallId } from "./openai-format" +/** + * 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" }) @@ -69,7 +92,7 @@ export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.M mistralMessages.push({ role: "tool", - toolCallId: normalizeToolCallId(toolResult.tool_use_id), + toolCallId: normalizeMistralToolCallId(toolResult.tool_use_id), content: resultContent, } as ToolMessage & { role: "tool" }) } @@ -124,7 +147,7 @@ export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.M let toolCalls: MistralToolCallMessage[] | undefined if (toolMessages.length > 0) { toolCalls = toolMessages.map((toolUse) => ({ - id: normalizeToolCallId(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 f227fa144d4..56d6441c48b 100644 --- a/src/api/transform/openai-format.ts +++ b/src/api/transform/openai-format.ts @@ -1,31 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -/** - * Normalizes a tool call ID to be compatible with providers that have strict ID requirements. - * Some providers (like Mistral) require 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 - */ -export function normalizeToolCallId(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") -} - /** * Options for converting Anthropic messages to OpenAI format. */