Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
6 changes: 4 additions & 2 deletions src/api/providers/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -226,9 +226,11 @@ 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 } : undefined),
]

// DeepSeek highly recommends using user instead of system role.
Expand Down
7 changes: 4 additions & 3 deletions src/api/transform/__tests__/mistral-format.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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")
})

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

Expand Down Expand Up @@ -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
Expand Down
142 changes: 137 additions & 5 deletions src/api/transform/__tests__/openai-format.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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 normalizeToolCallId)", () => {
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
Expand All @@ -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 normalizeToolCallId function
type: "function",
function: {
name: "get_weather",
Expand All @@ -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 normalizeToolCallId)", () => {
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
Expand All @@ -125,7 +162,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,
})

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 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")
})
})
6 changes: 4 additions & 2 deletions src/api/transform/mistral-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down Expand Up @@ -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" })
}
Expand Down Expand Up @@ -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,
Expand Down
45 changes: 43 additions & 2 deletions src/api/transform/openai-format.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,52 @@
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 {
/**
* 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 +97,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 +164,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