Skip to content
Open
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
43 changes: 43 additions & 0 deletions src/api/providers/__tests__/lite-llm.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Anthropic } from "@anthropic-ai/sdk"
import type OpenAI from "openai"

import { LiteLLMHandler } from "../lite-llm"
import type { ApiHandlerCreateMessageMetadata } from "../../index"
import { ApiHandlerOptions } from "../../../shared/api"
import { litellmDefaultModelId, litellmDefaultModelInfo } from "@roo-code/types"
import { asyncStreamFrom, collectStream } from "../../../test-utils/stream"
Expand Down Expand Up @@ -754,6 +756,47 @@ describe("LiteLLMHandler", () => {
expect(assistantMessage.tool_calls[0].provider_specific_fields.thought_signature).toBe(dummySignature)
})

it("should send tool call ids that carry a thought signature unchanged", async () => {
// LiteLLM encodes the Gemini signature into the id; truncating it breaks the signature.
const signature = "AY89a18ErUKBAfkTYnDbzq6mGnRG5QS8VFogncpDzYVXzC0i_aDwY5o-0123456789abcdef"
const toolCallId = `call_864944__thought__${signature}`
expect(toolCallId.length).toBeGreaterThan(64)

const messages: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "Hello" },
{
role: "assistant",
content: [{ type: "tool_use", id: toolCallId, name: "read_file", input: { path: "test.txt" } }],
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: toolCallId, content: "file contents" }],
},
]
mockCreate.mockReturnValue({
withResponse: vi.fn().mockResolvedValue({
data: asyncStreamFrom([{ choices: [{ delta: { content: "ok" } }] }]),
}),
})
const metadata: ApiHandlerCreateMessageMetadata = {
taskId: "task-1",
tools: [
{
type: "function",
function: { name: "read_file", description: "Read a file", parameters: {} },
},
],
}

await collectStream(handler.createMessage("You are a helpful assistant", messages, metadata))

const sent: OpenAI.Chat.ChatCompletionMessageParam[] = mockCreate.mock.calls[0][0].messages
const assistantMessage = sent.find((msg) => msg.role === "assistant" && msg.tool_calls?.length)
const toolMessage = sent.find((msg) => msg.role === "tool")
expect(assistantMessage?.role === "assistant" && assistantMessage.tool_calls?.[0].id).toBe(toolCallId)
expect(toolMessage?.role === "tool" && toolMessage.tool_call_id).toBe(toolCallId)
})

it("should not inject thought signatures for non-Gemini models", async () => {
const optionsWithGPT4: ApiHandlerOptions = {
...mockOptions,
Expand Down
6 changes: 3 additions & 3 deletions src/api/providers/lite-llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { convertToR1Format } from "../transform/r1-format"
import { GEMINI_THOUGHT_SIGNATURE_BYPASS } from "../transform/gemini-format"
import { sanitizeOpenAiCallId } from "../../utils/tool-id"
import { sanitizeLiteLlmCallId } from "../../utils/tool-id"

import type {
SingleCompletionHandler,
Expand Down Expand Up @@ -158,11 +158,11 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa
// never gets inserted mid-turn and causes the model to drop prior reasoning_content.
const openAiMessages = info.preserveReasoning
? convertToR1Format(messages, {
normalizeToolCallId: sanitizeOpenAiCallId,
normalizeToolCallId: sanitizeLiteLlmCallId,
mergeToolResultText: true,
})
: convertToOpenAiMessages(messages, {
normalizeToolCallId: sanitizeOpenAiCallId,
normalizeToolCallId: sanitizeLiteLlmCallId,
})

// Prepare messages with cache control if enabled and supported
Expand Down
34 changes: 33 additions & 1 deletion src/utils/__tests__/tool-id.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { sanitizeToolUseId, truncateOpenAiCallId, sanitizeOpenAiCallId, OPENAI_CALL_ID_MAX_LENGTH } from "../tool-id"
import {
sanitizeToolUseId,
truncateOpenAiCallId,
sanitizeOpenAiCallId,
sanitizeLiteLlmCallId,
OPENAI_CALL_ID_MAX_LENGTH,
} from "../tool-id"

describe("sanitizeToolUseId", () => {
describe("valid IDs pass through unchanged", () => {
Expand Down Expand Up @@ -184,3 +190,29 @@ describe("OPENAI_CALL_ID_MAX_LENGTH constant", () => {
expect(OPENAI_CALL_ID_MAX_LENGTH).toBe(64)
})
})

describe("sanitizeLiteLlmCallId", () => {
const signature = "AY89a18ErUKBAfkTYnDbzq6mGnRG5QS8VFogncpDzYVXzC0i_aDwY5o-0123456789abcdef"

it("keeps an id that carries a thought signature intact, however long", () => {
const id = `call_864944__thought__${signature}`

expect(id.length).toBeGreaterThan(OPENAI_CALL_ID_MAX_LENGTH)
expect(sanitizeLiteLlmCallId(id)).toBe(id)
})

it("still replaces invalid characters in an id that carries a thought signature", () => {
expect(sanitizeLiteLlmCallId("call_1__thought__ab+c/d=")).toBe("call_1__thought__ab_c_d_")
})

it("truncates other long ids like sanitizeOpenAiCallId", () => {
const id = "call_" + "x".repeat(100)

expect(sanitizeLiteLlmCallId(id)).toBe(sanitizeOpenAiCallId(id))
expect(sanitizeLiteLlmCallId(id).length).toBe(OPENAI_CALL_ID_MAX_LENGTH)
})

it("leaves short ids unchanged", () => {
expect(sanitizeLiteLlmCallId("toolu_123")).toBe("toolu_123")
})
})
18 changes: 18 additions & 0 deletions src/utils/tool-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,21 @@
const sanitized = sanitizeToolUseId(id)
return truncateOpenAiCallId(sanitized, maxLength)
}

/**
* LiteLLM carries a Gemini thought signature inside the tool call id, as
* `<call id>__thought__<signature>`. Truncating such an id corrupts the signature.
*/
export const LITELLM_THOUGHT_SIGNATURE_MARKER = "__thought__"

Check warning on line 62 in src/utils/tool-id.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/utils/tool-id.ts:62: Survived StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.

/**
* Normalize a tool call id for LiteLLM. Ids that carry a thought signature are only
* character-sanitized, because the signature must reach Gemini intact; every other id
* gets the usual OpenAI 64-character treatment.
*
* @param id - The original call_id
* @returns The id to send to LiteLLM
*/
export function sanitizeLiteLlmCallId(id: string): string {
return id.includes(LITELLM_THOUGHT_SIGNATURE_MARKER) ? sanitizeToolUseId(id) : sanitizeOpenAiCallId(id)
}
Loading