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

This commit is contained in:
Daniel 2025-12-16 12:34:35 -05:00 committed by GitHub
parent 93bc8c4f1d
commit a7b192adca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 199 additions and 13 deletions

View file

@ -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.

View file

@ -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

View file

@ -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")
})
})

View file

@ -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,

View file

@ -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,