Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/api/providers/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down
51 changes: 47 additions & 4 deletions src/api/transform/__tests__/mistral-format.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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")
})

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

Expand Down Expand Up @@ -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
Expand Down
104 changes: 100 additions & 4 deletions src/api/transform/__tests__/openai-format.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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")
})
})
29 changes: 27 additions & 2 deletions src/api/transform/mistral-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down Expand Up @@ -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" })
}
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 18 additions & 2 deletions src/api/transform/openai-format.ts
Original file line number Diff line number Diff line change
@@ -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 })
Expand Down Expand Up @@ -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,
})
})
Expand Down Expand Up @@ -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,
Expand Down
Loading