diff --git a/src/api/providers/__tests__/lite-llm.spec.ts b/src/api/providers/__tests__/lite-llm.spec.ts index dd582e5c2c..c4891c1c5a 100644 --- a/src/api/providers/__tests__/lite-llm.spec.ts +++ b/src/api/providers/__tests__/lite-llm.spec.ts @@ -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" @@ -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, diff --git a/src/api/providers/lite-llm.ts b/src/api/providers/lite-llm.ts index 6b46694126..69da258010 100644 --- a/src/api/providers/lite-llm.ts +++ b/src/api/providers/lite-llm.ts @@ -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, @@ -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 diff --git a/src/utils/__tests__/tool-id.spec.ts b/src/utils/__tests__/tool-id.spec.ts index 2459786cea..9f7d2ec754 100644 --- a/src/utils/__tests__/tool-id.spec.ts +++ b/src/utils/__tests__/tool-id.spec.ts @@ -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", () => { @@ -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") + }) +}) diff --git a/src/utils/tool-id.ts b/src/utils/tool-id.ts index feba6598f6..d05c9a245a 100644 --- a/src/utils/tool-id.ts +++ b/src/utils/tool-id.ts @@ -54,3 +54,21 @@ export function sanitizeOpenAiCallId(id: string, maxLength: number = OPENAI_CALL const sanitized = sanitizeToolUseId(id) return truncateOpenAiCallId(sanitized, maxLength) } + +/** + * LiteLLM carries a Gemini thought signature inside the tool call id, as + * `__thought__`. Truncating such an id corrupts the signature. + */ +export const LITELLM_THOUGHT_SIGNATURE_MARKER = "__thought__" + +/** + * 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) +}