Skip to content

Commit a7b192a

Browse files
authored
fix: normalize tool call IDs for cross-provider compatibility via OpenRouter (#10102)
1 parent 93bc8c4 commit a7b192a

File tree

5 files changed

+199
-13
lines changed

5 files changed

+199
-13
lines changed

src/api/providers/openrouter.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCal
1717
import type { ApiHandlerOptions, ModelRecord } from "../../shared/api"
1818

1919
import { convertToOpenAiMessages } from "../transform/openai-format"
20+
import { normalizeMistralToolCallId } from "../transform/mistral-format"
2021
import { resolveToolProtocol } from "../../utils/resolveToolProtocol"
2122
import { TOOL_PROTOCOL } from "@roo-code/types"
2223
import { ApiStreamChunk } from "../transform/stream"
@@ -226,9 +227,14 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
226227
}
227228

228229
// Convert Anthropic messages to OpenAI format.
230+
// Pass normalization function for Mistral compatibility (requires 9-char alphanumeric IDs)
231+
const isMistral = modelId.toLowerCase().includes("mistral")
229232
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
230233
{ role: "system", content: systemPrompt },
231-
...convertToOpenAiMessages(messages),
234+
...convertToOpenAiMessages(
235+
messages,
236+
isMistral ? { normalizeToolCallId: normalizeMistralToolCallId } : undefined,
237+
),
232238
]
233239

234240
// DeepSeek highly recommends using user instead of system role.

src/api/transform/__tests__/mistral-format.spec.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,44 @@
22

33
import { Anthropic } from "@anthropic-ai/sdk"
44

5-
import { convertToMistralMessages } from "../mistral-format"
5+
import { convertToMistralMessages, normalizeMistralToolCallId } from "../mistral-format"
6+
7+
describe("normalizeMistralToolCallId", () => {
8+
it("should strip non-alphanumeric characters and truncate to 9 characters", () => {
9+
// OpenAI-style tool call ID: "call_5019f900..." -> "call5019f900..." -> first 9 chars = "call5019f"
10+
expect(normalizeMistralToolCallId("call_5019f900a247472bacde0b82")).toBe("call5019f")
11+
})
12+
13+
it("should handle Anthropic-style tool call IDs", () => {
14+
// Anthropic-style tool call ID
15+
expect(normalizeMistralToolCallId("toolu_01234567890abcdef")).toBe("toolu0123")
16+
})
17+
18+
it("should pad short IDs to 9 characters", () => {
19+
expect(normalizeMistralToolCallId("abc")).toBe("abc000000")
20+
expect(normalizeMistralToolCallId("tool-1")).toBe("tool10000")
21+
})
22+
23+
it("should handle IDs that are exactly 9 alphanumeric characters", () => {
24+
expect(normalizeMistralToolCallId("abcd12345")).toBe("abcd12345")
25+
})
26+
27+
it("should return consistent results for the same input", () => {
28+
const id = "call_5019f900a247472bacde0b82"
29+
expect(normalizeMistralToolCallId(id)).toBe(normalizeMistralToolCallId(id))
30+
})
31+
32+
it("should handle edge cases", () => {
33+
// Empty string
34+
expect(normalizeMistralToolCallId("")).toBe("000000000")
35+
36+
// Only non-alphanumeric characters
37+
expect(normalizeMistralToolCallId("---___---")).toBe("000000000")
38+
39+
// Mixed special characters
40+
expect(normalizeMistralToolCallId("a-b_c.d@e")).toBe("abcde0000")
41+
})
42+
})
643

744
describe("convertToMistralMessages", () => {
845
it("should convert simple text messages for user and assistant roles", () => {
@@ -87,7 +124,9 @@ describe("convertToMistralMessages", () => {
87124
const mistralMessages = convertToMistralMessages(anthropicMessages)
88125
expect(mistralMessages).toHaveLength(1)
89126
expect(mistralMessages[0].role).toBe("tool")
90-
expect((mistralMessages[0] as { toolCallId?: string }).toolCallId).toBe("weather-123")
127+
expect((mistralMessages[0] as { toolCallId?: string }).toolCallId).toBe(
128+
normalizeMistralToolCallId("weather-123"),
129+
)
91130
expect(mistralMessages[0].content).toBe("Current temperature in London: 20°C")
92131
})
93132

@@ -124,7 +163,9 @@ describe("convertToMistralMessages", () => {
124163

125164
// Only the tool result should be present
126165
expect(mistralMessages[0].role).toBe("tool")
127-
expect((mistralMessages[0] as { toolCallId?: string }).toolCallId).toBe("weather-123")
166+
expect((mistralMessages[0] as { toolCallId?: string }).toolCallId).toBe(
167+
normalizeMistralToolCallId("weather-123"),
168+
)
128169
expect(mistralMessages[0].content).toBe("Current temperature in London: 20°C")
129170
})
130171

@@ -265,7 +306,9 @@ describe("convertToMistralMessages", () => {
265306

266307
// Tool result message
267308
expect(mistralMessages[2].role).toBe("tool")
268-
expect((mistralMessages[2] as { toolCallId?: string }).toolCallId).toBe("search-123")
309+
expect((mistralMessages[2] as { toolCallId?: string }).toolCallId).toBe(
310+
normalizeMistralToolCallId("search-123"),
311+
)
269312
expect(mistralMessages[2].content).toBe("Found information about different mountain types.")
270313

271314
// Final assistant message

src/api/transform/__tests__/openai-format.spec.ts

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Anthropic } from "@anthropic-ai/sdk"
44
import OpenAI from "openai"
55

66
import { convertToOpenAiMessages } from "../openai-format"
7+
import { normalizeMistralToolCallId } from "../mistral-format"
78

89
describe("convertToOpenAiMessages", () => {
910
it("should convert simple text messages", () => {
@@ -70,7 +71,7 @@ describe("convertToOpenAiMessages", () => {
7071
})
7172
})
7273

73-
it("should handle assistant messages with tool use", () => {
74+
it("should handle assistant messages with tool use (no normalization without normalizeToolCallId)", () => {
7475
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
7576
{
7677
role: "assistant",
@@ -97,7 +98,7 @@ describe("convertToOpenAiMessages", () => {
9798
expect(assistantMessage.content).toBe("Let me check the weather.")
9899
expect(assistantMessage.tool_calls).toHaveLength(1)
99100
expect(assistantMessage.tool_calls![0]).toEqual({
100-
id: "weather-123",
101+
id: "weather-123", // Not normalized without normalizeToolCallId function
101102
type: "function",
102103
function: {
103104
name: "get_weather",
@@ -106,7 +107,7 @@ describe("convertToOpenAiMessages", () => {
106107
})
107108
})
108109

109-
it("should handle user messages with tool results", () => {
110+
it("should handle user messages with tool results (no normalization without normalizeToolCallId)", () => {
110111
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
111112
{
112113
role: "user",
@@ -125,7 +126,102 @@ describe("convertToOpenAiMessages", () => {
125126

126127
const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam
127128
expect(toolMessage.role).toBe("tool")
128-
expect(toolMessage.tool_call_id).toBe("weather-123")
129+
expect(toolMessage.tool_call_id).toBe("weather-123") // Not normalized without normalizeToolCallId function
129130
expect(toolMessage.content).toBe("Current temperature in London: 20°C")
130131
})
132+
133+
it("should normalize tool call IDs when normalizeToolCallId function is provided", () => {
134+
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
135+
{
136+
role: "assistant",
137+
content: [
138+
{
139+
type: "tool_use",
140+
id: "call_5019f900a247472bacde0b82",
141+
name: "read_file",
142+
input: { path: "test.ts" },
143+
},
144+
],
145+
},
146+
{
147+
role: "user",
148+
content: [
149+
{
150+
type: "tool_result",
151+
tool_use_id: "call_5019f900a247472bacde0b82",
152+
content: "file contents",
153+
},
154+
],
155+
},
156+
]
157+
158+
// With normalizeToolCallId function - should normalize
159+
const openAiMessages = convertToOpenAiMessages(anthropicMessages, {
160+
normalizeToolCallId: normalizeMistralToolCallId,
161+
})
162+
163+
const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam
164+
expect(assistantMessage.tool_calls![0].id).toBe(normalizeMistralToolCallId("call_5019f900a247472bacde0b82"))
165+
166+
const toolMessage = openAiMessages[1] as OpenAI.Chat.ChatCompletionToolMessageParam
167+
expect(toolMessage.tool_call_id).toBe(normalizeMistralToolCallId("call_5019f900a247472bacde0b82"))
168+
})
169+
170+
it("should not normalize tool call IDs when normalizeToolCallId function is not provided", () => {
171+
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
172+
{
173+
role: "assistant",
174+
content: [
175+
{
176+
type: "tool_use",
177+
id: "call_5019f900a247472bacde0b82",
178+
name: "read_file",
179+
input: { path: "test.ts" },
180+
},
181+
],
182+
},
183+
{
184+
role: "user",
185+
content: [
186+
{
187+
type: "tool_result",
188+
tool_use_id: "call_5019f900a247472bacde0b82",
189+
content: "file contents",
190+
},
191+
],
192+
},
193+
]
194+
195+
// Without normalizeToolCallId function - should NOT normalize
196+
const openAiMessages = convertToOpenAiMessages(anthropicMessages, {})
197+
198+
const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam
199+
expect(assistantMessage.tool_calls![0].id).toBe("call_5019f900a247472bacde0b82")
200+
201+
const toolMessage = openAiMessages[1] as OpenAI.Chat.ChatCompletionToolMessageParam
202+
expect(toolMessage.tool_call_id).toBe("call_5019f900a247472bacde0b82")
203+
})
204+
205+
it("should use custom normalization function when provided", () => {
206+
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
207+
{
208+
role: "assistant",
209+
content: [
210+
{
211+
type: "tool_use",
212+
id: "toolu_123",
213+
name: "test_tool",
214+
input: {},
215+
},
216+
],
217+
},
218+
]
219+
220+
// Custom normalization function that prefixes with "custom_"
221+
const customNormalizer = (id: string) => `custom_${id}`
222+
const openAiMessages = convertToOpenAiMessages(anthropicMessages, { normalizeToolCallId: customNormalizer })
223+
224+
const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam
225+
expect(assistantMessage.tool_calls![0].id).toBe("custom_toolu_123")
226+
})
131227
})

src/api/transform/mistral-format.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,31 @@ import { SystemMessage } from "@mistralai/mistralai/models/components/systemmess
44
import { ToolMessage } from "@mistralai/mistralai/models/components/toolmessage"
55
import { UserMessage } from "@mistralai/mistralai/models/components/usermessage"
66

7+
/**
8+
* Normalizes a tool call ID to be compatible with Mistral's strict ID requirements.
9+
* Mistral requires tool call IDs to be:
10+
* - Only alphanumeric characters (a-z, A-Z, 0-9)
11+
* - Exactly 9 characters in length
12+
*
13+
* This function extracts alphanumeric characters from the original ID and
14+
* pads/truncates to exactly 9 characters, ensuring deterministic output.
15+
*
16+
* @param id - The original tool call ID (e.g., "call_5019f900a247472bacde0b82" or "toolu_123")
17+
* @returns A normalized 9-character alphanumeric ID compatible with Mistral
18+
*/
19+
export function normalizeMistralToolCallId(id: string): string {
20+
// Extract only alphanumeric characters
21+
const alphanumeric = id.replace(/[^a-zA-Z0-9]/g, "")
22+
23+
// Take first 9 characters, or pad with zeros if shorter
24+
if (alphanumeric.length >= 9) {
25+
return alphanumeric.slice(0, 9)
26+
}
27+
28+
// Pad with zeros to reach 9 characters
29+
return alphanumeric.padEnd(9, "0")
30+
}
31+
732
export type MistralMessage =
833
| (SystemMessage & { role: "system" })
934
| (UserMessage & { role: "user" })
@@ -67,7 +92,7 @@ export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.M
6792

6893
mistralMessages.push({
6994
role: "tool",
70-
toolCallId: toolResult.tool_use_id,
95+
toolCallId: normalizeMistralToolCallId(toolResult.tool_use_id),
7196
content: resultContent,
7297
} as ToolMessage & { role: "tool" })
7398
}
@@ -122,7 +147,7 @@ export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.M
122147
let toolCalls: MistralToolCallMessage[] | undefined
123148
if (toolMessages.length > 0) {
124149
toolCalls = toolMessages.map((toolUse) => ({
125-
id: toolUse.id,
150+
id: normalizeMistralToolCallId(toolUse.id),
126151
type: "function" as const,
127152
function: {
128153
name: toolUse.name,

src/api/transform/openai-format.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,27 @@
11
import { Anthropic } from "@anthropic-ai/sdk"
22
import OpenAI from "openai"
33

4+
/**
5+
* Options for converting Anthropic messages to OpenAI format.
6+
*/
7+
export interface ConvertToOpenAiMessagesOptions {
8+
/**
9+
* Optional function to normalize tool call IDs for providers with strict ID requirements.
10+
* When provided, this function will be applied to all tool_use IDs and tool_result tool_use_ids.
11+
* This allows callers to declare provider-specific ID format requirements.
12+
*/
13+
normalizeToolCallId?: (id: string) => string
14+
}
15+
416
export function convertToOpenAiMessages(
517
anthropicMessages: Anthropic.Messages.MessageParam[],
18+
options?: ConvertToOpenAiMessagesOptions,
619
): OpenAI.Chat.ChatCompletionMessageParam[] {
720
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = []
821

22+
// Use provided normalization function or identity function
23+
const normalizeId = options?.normalizeToolCallId ?? ((id: string) => id)
24+
925
for (const anthropicMessage of anthropicMessages) {
1026
if (typeof anthropicMessage.content === "string") {
1127
openAiMessages.push({ role: anthropicMessage.role, content: anthropicMessage.content })
@@ -56,7 +72,7 @@ export function convertToOpenAiMessages(
5672
}
5773
openAiMessages.push({
5874
role: "tool",
59-
tool_call_id: toolMessage.tool_use_id,
75+
tool_call_id: normalizeId(toolMessage.tool_use_id),
6076
content: content,
6177
})
6278
})
@@ -123,7 +139,7 @@ export function convertToOpenAiMessages(
123139

124140
// Process tool use messages
125141
let tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolMessages.map((toolMessage) => ({
126-
id: toolMessage.id,
142+
id: normalizeId(toolMessage.id),
127143
type: "function",
128144
function: {
129145
name: toolMessage.name,

0 commit comments

Comments
 (0)