mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
fix: normalize tool call IDs for cross-provider compatibility via OpenRouter (#10102)
This commit is contained in:
parent
93bc8c4f1d
commit
a7b192adca
5 changed files with 199 additions and 13 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue