Skip to content

Commit 72eddd7

Browse files
committed
fix: normalize tool call IDs for Mistral compatibility via OpenRouter
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
1 parent 1d4fc52 commit 72eddd7

File tree

5 files changed

+190
-13
lines changed

5 files changed

+190
-13
lines changed

src/api/providers/openrouter.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,9 +226,10 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
226226
}
227227

228228
// Convert Anthropic messages to OpenAI format.
229+
// Pass modelId to normalize tool call IDs for Mistral compatibility
229230
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
230231
{ role: "system", content: systemPrompt },
231-
...convertToOpenAiMessages(messages),
232+
...convertToOpenAiMessages(messages, { modelId }),
232233
]
233234

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

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

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

55
import { convertToMistralMessages } from "../mistral-format"
6+
import { normalizeToolCallId } from "../openai-format"
67

78
describe("convertToMistralMessages", () => {
89
it("should convert simple text messages for user and assistant roles", () => {
@@ -87,7 +88,7 @@ describe("convertToMistralMessages", () => {
8788
const mistralMessages = convertToMistralMessages(anthropicMessages)
8889
expect(mistralMessages).toHaveLength(1)
8990
expect(mistralMessages[0].role).toBe("tool")
90-
expect((mistralMessages[0] as { toolCallId?: string }).toolCallId).toBe("weather-123")
91+
expect((mistralMessages[0] as { toolCallId?: string }).toolCallId).toBe(normalizeToolCallId("weather-123"))
9192
expect(mistralMessages[0].content).toBe("Current temperature in London: 20°C")
9293
})
9394

@@ -124,7 +125,7 @@ describe("convertToMistralMessages", () => {
124125

125126
// Only the tool result should be present
126127
expect(mistralMessages[0].role).toBe("tool")
127-
expect((mistralMessages[0] as { toolCallId?: string }).toolCallId).toBe("weather-123")
128+
expect((mistralMessages[0] as { toolCallId?: string }).toolCallId).toBe(normalizeToolCallId("weather-123"))
128129
expect(mistralMessages[0].content).toBe("Current temperature in London: 20°C")
129130
})
130131

@@ -265,7 +266,7 @@ describe("convertToMistralMessages", () => {
265266

266267
// Tool result message
267268
expect(mistralMessages[2].role).toBe("tool")
268-
expect((mistralMessages[2] as { toolCallId?: string }).toolCallId).toBe("search-123")
269+
expect((mistralMessages[2] as { toolCallId?: string }).toolCallId).toBe(normalizeToolCallId("search-123"))
269270
expect(mistralMessages[2].content).toBe("Found information about different mountain types.")
270271

271272
// Final assistant message

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

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

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

845
describe("convertToOpenAiMessages", () => {
946
it("should convert simple text messages", () => {
@@ -70,7 +107,7 @@ describe("convertToOpenAiMessages", () => {
70107
})
71108
})
72109

73-
it("should handle assistant messages with tool use", () => {
110+
it("should handle assistant messages with tool use (no normalization without modelId)", () => {
74111
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
75112
{
76113
role: "assistant",
@@ -97,7 +134,7 @@ describe("convertToOpenAiMessages", () => {
97134
expect(assistantMessage.content).toBe("Let me check the weather.")
98135
expect(assistantMessage.tool_calls).toHaveLength(1)
99136
expect(assistantMessage.tool_calls![0]).toEqual({
100-
id: "weather-123",
137+
id: "weather-123", // Not normalized without modelId
101138
type: "function",
102139
function: {
103140
name: "get_weather",
@@ -106,7 +143,7 @@ describe("convertToOpenAiMessages", () => {
106143
})
107144
})
108145

109-
it("should handle user messages with tool results", () => {
146+
it("should handle user messages with tool results (no normalization without modelId)", () => {
110147
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
111148
{
112149
role: "user",
@@ -125,7 +162,101 @@ describe("convertToOpenAiMessages", () => {
125162

126163
const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam
127164
expect(toolMessage.role).toBe("tool")
128-
expect(toolMessage.tool_call_id).toBe("weather-123")
165+
expect(toolMessage.tool_call_id).toBe("weather-123") // Not normalized without modelId
129166
expect(toolMessage.content).toBe("Current temperature in London: 20°C")
130167
})
168+
169+
it("should normalize tool call IDs when modelId contains 'mistral'", () => {
170+
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
171+
{
172+
role: "assistant",
173+
content: [
174+
{
175+
type: "tool_use",
176+
id: "call_5019f900a247472bacde0b82",
177+
name: "read_file",
178+
input: { path: "test.ts" },
179+
},
180+
],
181+
},
182+
{
183+
role: "user",
184+
content: [
185+
{
186+
type: "tool_result",
187+
tool_use_id: "call_5019f900a247472bacde0b82",
188+
content: "file contents",
189+
},
190+
],
191+
},
192+
]
193+
194+
// With Mistral model ID - should normalize
195+
const openAiMessages = convertToOpenAiMessages(anthropicMessages, {
196+
modelId: "mistralai/mistral-large-latest",
197+
})
198+
199+
const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam
200+
expect(assistantMessage.tool_calls![0].id).toBe(normalizeToolCallId("call_5019f900a247472bacde0b82"))
201+
202+
const toolMessage = openAiMessages[1] as OpenAI.Chat.ChatCompletionToolMessageParam
203+
expect(toolMessage.tool_call_id).toBe(normalizeToolCallId("call_5019f900a247472bacde0b82"))
204+
})
205+
206+
it("should not normalize tool call IDs when modelId does not contain 'mistral'", () => {
207+
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
208+
{
209+
role: "assistant",
210+
content: [
211+
{
212+
type: "tool_use",
213+
id: "call_5019f900a247472bacde0b82",
214+
name: "read_file",
215+
input: { path: "test.ts" },
216+
},
217+
],
218+
},
219+
{
220+
role: "user",
221+
content: [
222+
{
223+
type: "tool_result",
224+
tool_use_id: "call_5019f900a247472bacde0b82",
225+
content: "file contents",
226+
},
227+
],
228+
},
229+
]
230+
231+
// With non-Mistral model ID - should NOT normalize
232+
const openAiMessages = convertToOpenAiMessages(anthropicMessages, { modelId: "openai/gpt-4" })
233+
234+
const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam
235+
expect(assistantMessage.tool_calls![0].id).toBe("call_5019f900a247472bacde0b82")
236+
237+
const toolMessage = openAiMessages[1] as OpenAI.Chat.ChatCompletionToolMessageParam
238+
expect(toolMessage.tool_call_id).toBe("call_5019f900a247472bacde0b82")
239+
})
240+
241+
it("should be case-insensitive when checking for mistral in modelId", () => {
242+
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
243+
{
244+
role: "assistant",
245+
content: [
246+
{
247+
type: "tool_use",
248+
id: "toolu_123",
249+
name: "test_tool",
250+
input: {},
251+
},
252+
],
253+
},
254+
]
255+
256+
// Uppercase MISTRAL should still trigger normalization
257+
const openAiMessages = convertToOpenAiMessages(anthropicMessages, { modelId: "MISTRAL-7B" })
258+
259+
const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam
260+
expect(assistantMessage.tool_calls![0].id).toBe(normalizeToolCallId("toolu_123"))
261+
})
131262
})

src/api/transform/mistral-format.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ 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+
import { normalizeToolCallId } from "./openai-format"
8+
79
export type MistralMessage =
810
| (SystemMessage & { role: "system" })
911
| (UserMessage & { role: "user" })
@@ -67,7 +69,7 @@ export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.M
6769

6870
mistralMessages.push({
6971
role: "tool",
70-
toolCallId: toolResult.tool_use_id,
72+
toolCallId: normalizeToolCallId(toolResult.tool_use_id),
7173
content: resultContent,
7274
} as ToolMessage & { role: "tool" })
7375
}
@@ -122,7 +124,7 @@ export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.M
122124
let toolCalls: MistralToolCallMessage[] | undefined
123125
if (toolMessages.length > 0) {
124126
toolCalls = toolMessages.map((toolUse) => ({
125-
id: toolUse.id,
127+
id: normalizeToolCallId(toolUse.id),
126128
type: "function" as const,
127129
function: {
128130
name: toolUse.name,

src/api/transform/openai-format.ts

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

4+
/**
5+
* Normalizes a tool call ID to be compatible with providers that have strict ID requirements.
6+
* Some providers (like Mistral) require tool call IDs to be:
7+
* - Only alphanumeric characters (a-z, A-Z, 0-9)
8+
* - Exactly 9 characters in length
9+
*
10+
* This function extracts alphanumeric characters from the original ID and
11+
* pads/truncates to exactly 9 characters, ensuring deterministic output.
12+
*
13+
* @param id - The original tool call ID (e.g., "call_5019f900a247472bacde0b82" or "toolu_123")
14+
* @returns A normalized 9-character alphanumeric ID
15+
*/
16+
export function normalizeToolCallId(id: string): string {
17+
// Extract only alphanumeric characters
18+
const alphanumeric = id.replace(/[^a-zA-Z0-9]/g, "")
19+
20+
// Take first 9 characters, or pad with zeros if shorter
21+
if (alphanumeric.length >= 9) {
22+
return alphanumeric.slice(0, 9)
23+
}
24+
25+
// Pad with zeros to reach 9 characters
26+
return alphanumeric.padEnd(9, "0")
27+
}
28+
29+
/**
30+
* Options for converting Anthropic messages to OpenAI format.
31+
*/
32+
export interface ConvertToOpenAiMessagesOptions {
33+
/**
34+
* The model ID being used. If it contains "mistral", tool call IDs will be
35+
* normalized to be compatible with Mistral's strict ID requirements.
36+
*/
37+
modelId?: string
38+
}
39+
440
export function convertToOpenAiMessages(
541
anthropicMessages: Anthropic.Messages.MessageParam[],
42+
options?: ConvertToOpenAiMessagesOptions,
643
): OpenAI.Chat.ChatCompletionMessageParam[] {
744
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = []
845

46+
// Check if we need to normalize tool call IDs for Mistral compatibility
47+
const shouldNormalizeToolCallIds = options?.modelId?.toLowerCase().includes("mistral") ?? false
48+
949
for (const anthropicMessage of anthropicMessages) {
1050
if (typeof anthropicMessage.content === "string") {
1151
openAiMessages.push({ role: anthropicMessage.role, content: anthropicMessage.content })
@@ -56,7 +96,9 @@ export function convertToOpenAiMessages(
5696
}
5797
openAiMessages.push({
5898
role: "tool",
59-
tool_call_id: toolMessage.tool_use_id,
99+
tool_call_id: shouldNormalizeToolCallIds
100+
? normalizeToolCallId(toolMessage.tool_use_id)
101+
: toolMessage.tool_use_id,
60102
content: content,
61103
})
62104
})
@@ -123,7 +165,7 @@ export function convertToOpenAiMessages(
123165

124166
// Process tool use messages
125167
let tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolMessages.map((toolMessage) => ({
126-
id: toolMessage.id,
168+
id: shouldNormalizeToolCallIds ? normalizeToolCallId(toolMessage.id) : toolMessage.id,
127169
type: "function",
128170
function: {
129171
name: toolMessage.name,

0 commit comments

Comments
 (0)