Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit d7b7e17

Browse files
authored
fix(litellm): inject dummy thought signatures on ALL tool calls for Gemini (#10743)
1 parent 4ebbca0 commit d7b7e17

2 files changed

Lines changed: 400 additions & 7 deletions

File tree

src/api/providers/__tests__/lite-llm.spec.ts

Lines changed: 318 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Anthropic } from "@anthropic-ai/sdk"
33

44
import { LiteLLMHandler } from "../lite-llm"
55
import { ApiHandlerOptions } from "../../../shared/api"
6-
import { litellmDefaultModelId, litellmDefaultModelInfo } from "@roo-code/types"
6+
import { litellmDefaultModelId, litellmDefaultModelInfo, TOOL_PROTOCOL } from "@roo-code/types"
77

88
// Mock vscode first to avoid import errors
99
vi.mock("vscode", () => ({}))
@@ -40,6 +40,12 @@ vi.mock("../fetchers/modelCache", () => ({
4040
"claude-3-opus": { ...litellmDefaultModelInfo, maxTokens: 8192 },
4141
"llama-3": { ...litellmDefaultModelInfo, maxTokens: 8192 },
4242
"gpt-4-turbo": { ...litellmDefaultModelInfo, maxTokens: 8192 },
43+
// Gemini models for thought signature injection tests
44+
"gemini-3-pro": { ...litellmDefaultModelInfo, maxTokens: 8192, supportsNativeTools: true },
45+
"gemini-3-flash": { ...litellmDefaultModelInfo, maxTokens: 8192, supportsNativeTools: true },
46+
"gemini-2.5-pro": { ...litellmDefaultModelInfo, maxTokens: 8192, supportsNativeTools: true },
47+
"google/gemini-3-pro": { ...litellmDefaultModelInfo, maxTokens: 8192, supportsNativeTools: true },
48+
"vertex_ai/gemini-3-pro": { ...litellmDefaultModelInfo, maxTokens: 8192, supportsNativeTools: true },
4349
})
4450
}),
4551
getModelsFromCache: vi.fn().mockReturnValue(undefined),
@@ -388,4 +394,315 @@ describe("LiteLLMHandler", () => {
388394
expect(createCall.max_completion_tokens).toBeUndefined()
389395
})
390396
})
397+
398+
describe("Gemini thought signature injection", () => {
399+
describe("isGeminiModel detection", () => {
400+
it("should detect Gemini 3 models", () => {
401+
const handler = new LiteLLMHandler(mockOptions)
402+
const isGeminiModel = (handler as any).isGeminiModel.bind(handler)
403+
404+
expect(isGeminiModel("gemini-3-pro")).toBe(true)
405+
expect(isGeminiModel("gemini-3-flash")).toBe(true)
406+
expect(isGeminiModel("gemini-3-pro-preview")).toBe(true)
407+
})
408+
409+
it("should detect Gemini 2.5 models", () => {
410+
const handler = new LiteLLMHandler(mockOptions)
411+
const isGeminiModel = (handler as any).isGeminiModel.bind(handler)
412+
413+
expect(isGeminiModel("gemini-2.5-pro")).toBe(true)
414+
expect(isGeminiModel("gemini-2.5-flash")).toBe(true)
415+
})
416+
417+
it("should detect provider-prefixed Gemini models", () => {
418+
const handler = new LiteLLMHandler(mockOptions)
419+
const isGeminiModel = (handler as any).isGeminiModel.bind(handler)
420+
421+
expect(isGeminiModel("google/gemini-3-pro")).toBe(true)
422+
expect(isGeminiModel("vertex_ai/gemini-3-pro")).toBe(true)
423+
expect(isGeminiModel("vertex/gemini-2.5-pro")).toBe(true)
424+
})
425+
426+
it("should not detect non-Gemini models", () => {
427+
const handler = new LiteLLMHandler(mockOptions)
428+
const isGeminiModel = (handler as any).isGeminiModel.bind(handler)
429+
430+
expect(isGeminiModel("gpt-4")).toBe(false)
431+
expect(isGeminiModel("claude-3-opus")).toBe(false)
432+
expect(isGeminiModel("gemini-1.5-pro")).toBe(false)
433+
expect(isGeminiModel("gemini-2.0-flash")).toBe(false)
434+
})
435+
})
436+
437+
describe("injectThoughtSignatureForGemini", () => {
438+
// Base64 encoded "skip_thought_signature_validator"
439+
const dummySignature = Buffer.from("skip_thought_signature_validator").toString("base64")
440+
441+
it("should inject provider_specific_fields.thought_signature for assistant messages with tool_calls", () => {
442+
const handler = new LiteLLMHandler(mockOptions)
443+
const injectThoughtSignature = (handler as any).injectThoughtSignatureForGemini.bind(handler)
444+
445+
const messages = [
446+
{ role: "user", content: "Hello" },
447+
{
448+
role: "assistant",
449+
content: "",
450+
tool_calls: [
451+
{ id: "call_123", type: "function", function: { name: "test_tool", arguments: "{}" } },
452+
],
453+
},
454+
{ role: "tool", tool_call_id: "call_123", content: "result" },
455+
]
456+
457+
const result = injectThoughtSignature(messages)
458+
459+
// The first tool call should have provider_specific_fields.thought_signature injected
460+
expect(result[1].tool_calls[0].provider_specific_fields).toBeDefined()
461+
expect(result[1].tool_calls[0].provider_specific_fields.thought_signature).toBe(dummySignature)
462+
})
463+
464+
it("should not inject if assistant message has no tool_calls", () => {
465+
const handler = new LiteLLMHandler(mockOptions)
466+
const injectThoughtSignature = (handler as any).injectThoughtSignatureForGemini.bind(handler)
467+
468+
const messages = [
469+
{ role: "user", content: "Hello" },
470+
{ role: "assistant", content: "Hi there!" },
471+
]
472+
473+
const result = injectThoughtSignature(messages)
474+
475+
// No changes should be made
476+
expect(result[1].tool_calls).toBeUndefined()
477+
})
478+
479+
it("should always overwrite existing thought_signature", () => {
480+
const handler = new LiteLLMHandler(mockOptions)
481+
const injectThoughtSignature = (handler as any).injectThoughtSignatureForGemini.bind(handler)
482+
483+
const existingSignature = "existing_signature_base64"
484+
485+
const messages = [
486+
{ role: "user", content: "Hello" },
487+
{
488+
role: "assistant",
489+
content: "",
490+
tool_calls: [
491+
{
492+
id: "call_123",
493+
type: "function",
494+
function: { name: "test_tool", arguments: "{}" },
495+
provider_specific_fields: { thought_signature: existingSignature },
496+
},
497+
],
498+
},
499+
]
500+
501+
const result = injectThoughtSignature(messages)
502+
503+
// Should overwrite with dummy signature (always inject to ensure compatibility)
504+
expect(result[1].tool_calls[0].provider_specific_fields.thought_signature).toBe(dummySignature)
505+
})
506+
507+
it("should inject signature into ALL tool calls for parallel calls", () => {
508+
const handler = new LiteLLMHandler(mockOptions)
509+
const injectThoughtSignature = (handler as any).injectThoughtSignatureForGemini.bind(handler)
510+
511+
const messages = [
512+
{ role: "user", content: "Hello" },
513+
{
514+
role: "assistant",
515+
content: "",
516+
tool_calls: [
517+
{ id: "call_first", type: "function", function: { name: "tool1", arguments: "{}" } },
518+
{ id: "call_second", type: "function", function: { name: "tool2", arguments: "{}" } },
519+
{ id: "call_third", type: "function", function: { name: "tool3", arguments: "{}" } },
520+
],
521+
},
522+
]
523+
524+
const result = injectThoughtSignature(messages)
525+
526+
// ALL tool calls should have the signature
527+
expect(result[1].tool_calls[0].provider_specific_fields.thought_signature).toBe(dummySignature)
528+
expect(result[1].tool_calls[1].provider_specific_fields.thought_signature).toBe(dummySignature)
529+
expect(result[1].tool_calls[2].provider_specific_fields.thought_signature).toBe(dummySignature)
530+
})
531+
532+
it("should preserve existing provider_specific_fields when adding thought_signature", () => {
533+
const handler = new LiteLLMHandler(mockOptions)
534+
const injectThoughtSignature = (handler as any).injectThoughtSignatureForGemini.bind(handler)
535+
536+
const messages = [
537+
{ role: "user", content: "Hello" },
538+
{
539+
role: "assistant",
540+
content: "",
541+
tool_calls: [
542+
{
543+
id: "call_123",
544+
type: "function",
545+
function: { name: "test_tool", arguments: "{}" },
546+
provider_specific_fields: { other_field: "value" },
547+
},
548+
],
549+
},
550+
]
551+
552+
const result = injectThoughtSignature(messages)
553+
554+
// Should have both existing field and new thought_signature
555+
expect(result[1].tool_calls[0].provider_specific_fields.other_field).toBe("value")
556+
expect(result[1].tool_calls[0].provider_specific_fields.thought_signature).toBe(dummySignature)
557+
})
558+
})
559+
560+
describe("createMessage integration with Gemini models", () => {
561+
// Base64 encoded "skip_thought_signature_validator"
562+
const dummySignature = Buffer.from("skip_thought_signature_validator").toString("base64")
563+
564+
it("should inject thought signatures for Gemini 3 models with native tools", async () => {
565+
const optionsWithGemini: ApiHandlerOptions = {
566+
...mockOptions,
567+
litellmModelId: "gemini-3-pro",
568+
}
569+
handler = new LiteLLMHandler(optionsWithGemini)
570+
571+
// Mock fetchModel to return a Gemini model with native tool support
572+
vi.spyOn(handler as any, "fetchModel").mockResolvedValue({
573+
id: "gemini-3-pro",
574+
info: { ...litellmDefaultModelInfo, maxTokens: 8192, supportsNativeTools: true },
575+
})
576+
577+
const systemPrompt = "You are a helpful assistant"
578+
// Simulate conversation history with a tool call from a previous model (Claude)
579+
const messages: Anthropic.Messages.MessageParam[] = [
580+
{ role: "user", content: "Hello" },
581+
{
582+
role: "assistant",
583+
content: [
584+
{ type: "text", text: "I'll help you with that." },
585+
{ type: "tool_use", id: "toolu_123", name: "read_file", input: { path: "test.txt" } },
586+
],
587+
},
588+
{
589+
role: "user",
590+
content: [{ type: "tool_result", tool_use_id: "toolu_123", content: "file contents" }],
591+
},
592+
{ role: "user", content: "Thanks!" },
593+
]
594+
595+
// Mock the stream response
596+
const mockStream = {
597+
async *[Symbol.asyncIterator]() {
598+
yield {
599+
choices: [{ delta: { content: "You're welcome!" } }],
600+
usage: {
601+
prompt_tokens: 100,
602+
completion_tokens: 20,
603+
},
604+
}
605+
},
606+
}
607+
608+
mockCreate.mockReturnValue({
609+
withResponse: vi.fn().mockResolvedValue({ data: mockStream }),
610+
})
611+
612+
// Provide tools and native protocol to trigger the injection
613+
const metadata = {
614+
tools: [
615+
{
616+
type: "function",
617+
function: { name: "read_file", description: "Read a file", parameters: {} },
618+
},
619+
],
620+
toolProtocol: TOOL_PROTOCOL.NATIVE,
621+
}
622+
623+
const generator = handler.createMessage(systemPrompt, messages, metadata as any)
624+
for await (const _chunk of generator) {
625+
// Consume the generator
626+
}
627+
628+
// Verify that the assistant message with tool_calls has thought_signature injected
629+
const createCall = mockCreate.mock.calls[0][0]
630+
const assistantMessage = createCall.messages.find(
631+
(msg: any) => msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0,
632+
)
633+
634+
expect(assistantMessage).toBeDefined()
635+
// First tool call should have the thought signature
636+
expect(assistantMessage.tool_calls[0].provider_specific_fields).toBeDefined()
637+
expect(assistantMessage.tool_calls[0].provider_specific_fields.thought_signature).toBe(dummySignature)
638+
})
639+
640+
it("should not inject thought signatures for non-Gemini models", async () => {
641+
const optionsWithGPT4: ApiHandlerOptions = {
642+
...mockOptions,
643+
litellmModelId: "gpt-4",
644+
}
645+
handler = new LiteLLMHandler(optionsWithGPT4)
646+
647+
vi.spyOn(handler as any, "fetchModel").mockResolvedValue({
648+
id: "gpt-4",
649+
info: { ...litellmDefaultModelInfo, maxTokens: 8192, supportsNativeTools: true },
650+
})
651+
652+
const systemPrompt = "You are a helpful assistant"
653+
const messages: Anthropic.Messages.MessageParam[] = [
654+
{ role: "user", content: "Hello" },
655+
{
656+
role: "assistant",
657+
content: [
658+
{ type: "text", text: "I'll help you with that." },
659+
{ type: "tool_use", id: "toolu_123", name: "read_file", input: { path: "test.txt" } },
660+
],
661+
},
662+
{
663+
role: "user",
664+
content: [{ type: "tool_result", tool_use_id: "toolu_123", content: "file contents" }],
665+
},
666+
]
667+
668+
const mockStream = {
669+
async *[Symbol.asyncIterator]() {
670+
yield {
671+
choices: [{ delta: { content: "Response" } }],
672+
usage: { prompt_tokens: 100, completion_tokens: 20 },
673+
}
674+
},
675+
}
676+
677+
mockCreate.mockReturnValue({
678+
withResponse: vi.fn().mockResolvedValue({ data: mockStream }),
679+
})
680+
681+
const metadata = {
682+
tools: [
683+
{
684+
type: "function",
685+
function: { name: "read_file", description: "Read a file", parameters: {} },
686+
},
687+
],
688+
toolProtocol: TOOL_PROTOCOL.NATIVE,
689+
}
690+
691+
const generator = handler.createMessage(systemPrompt, messages, metadata as any)
692+
for await (const _chunk of generator) {
693+
// Consume
694+
}
695+
696+
// Verify that thought_signature was NOT injected for non-Gemini model
697+
const createCall = mockCreate.mock.calls[0][0]
698+
const assistantMessage = createCall.messages.find(
699+
(msg: any) => msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0,
700+
)
701+
702+
expect(assistantMessage).toBeDefined()
703+
// Tool calls should not have provider_specific_fields added
704+
expect(assistantMessage.tool_calls[0].provider_specific_fields).toBeUndefined()
705+
})
706+
})
707+
})
391708
})

0 commit comments

Comments
 (0)