Skip to content

Commit 29c2d2e

Browse files
authored
fix(gemini): base64 encoding though signature (#776)
1 parent 63dec51 commit 29c2d2e

4 files changed

Lines changed: 227 additions & 6 deletions

File tree

src/api/providers/__tests__/gemini.spec.ts

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,215 @@ describe("GeminiHandler", () => {
5454
})
5555
})
5656

57+
describe("thoughtSignature round-trip (issue #536)", () => {
58+
const systemPrompt = "You are a helpful assistant"
59+
const toolMetadata = { tools: [{ function: { name: "read_file", description: "", parameters: {} } }] } as any
60+
61+
// Helper: build a mock async-iterable stream from chunks
62+
function makeStream(chunks: unknown[]) {
63+
return {
64+
[Symbol.asyncIterator]: async function* () {
65+
for (const chunk of chunks) yield chunk
66+
},
67+
}
68+
}
69+
70+
// Simulate a Gemini 3.x response: thoughtSignature arrives on its own part,
71+
// alongside a functionCall part (the way the real Gemini 3 API returns it).
72+
const turn1Response = makeStream([
73+
{
74+
candidates: [
75+
{
76+
content: {
77+
parts: [
78+
{ thought: true, text: "thinking…" },
79+
{ functionCall: { name: "read_file", args: { path: "foo.ts" } } },
80+
{ thoughtSignature: "sig-abc123" },
81+
],
82+
},
83+
},
84+
],
85+
},
86+
{ usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 } },
87+
])
88+
89+
it("captures thoughtSignature from the stream after turn 1", async () => {
90+
;(handler["client"].models.generateContentStream as any).mockResolvedValue(turn1Response)
91+
92+
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Read foo.ts" }]
93+
94+
for await (const _chunk of handler.createMessage(systemPrompt, messages, toolMetadata)) {
95+
// drain
96+
}
97+
98+
expect(handler.getThoughtSignature()).toBe("sig-abc123")
99+
})
100+
101+
it("sends thoughtSignature from history on turn 2 (core regression)", async () => {
102+
// This is the bug from issue #536: after turn 1 the thoughtSignature block is
103+
// persisted into apiConversationHistory. On turn 2 the handler must include it
104+
// in the outgoing request, otherwise Gemini 3.x returns an empty response.
105+
const historyAfterTurn1: Anthropic.Messages.MessageParam[] = [
106+
{ role: "user", content: "Read foo.ts" },
107+
{
108+
role: "assistant",
109+
// assistant turn as stored by prepareApiConversationMessage:
110+
// tool_use block + appended thoughtSignature block
111+
content: [
112+
{ type: "tool_use", id: "call-1", name: "read_file", input: { path: "foo.ts" } },
113+
{ type: "thoughtSignature", thoughtSignature: "sig-abc123" } as any,
114+
],
115+
},
116+
{
117+
role: "user",
118+
content: [{ type: "tool_result", tool_use_id: "call-1", content: "file contents here" }],
119+
},
120+
]
121+
122+
;(handler["client"].models.generateContentStream as any).mockResolvedValue(
123+
makeStream([
124+
{ candidates: [{ content: { parts: [{ text: "Done." }] } }] },
125+
{ usageMetadata: { promptTokenCount: 20, candidatesTokenCount: 5 } },
126+
]),
127+
)
128+
129+
for await (const _chunk of handler.createMessage(systemPrompt, historyAfterTurn1, toolMetadata)) {
130+
// drain
131+
}
132+
133+
const callArgs = (handler["client"].models.generateContentStream as any).mock.calls[0][0]
134+
const contents: any[] = callArgs.contents
135+
136+
// The model turn in the outgoing request must carry the thoughtSignature on its functionCall part
137+
const modelTurn = contents.find((c: any) => c.role === "model")
138+
expect(modelTurn).toBeDefined()
139+
const fnPart = modelTurn.parts.find((p: any) => p.functionCall)
140+
expect(fnPart).toBeDefined()
141+
expect(fnPart.thoughtSignature).toBe("sig-abc123")
142+
})
143+
144+
it("falls back to base64-encoded skip_thought_signature_validator when history has no signature", async () => {
145+
// Cross-model history scenario: prior session used a non-Gemini model, no signature stored.
146+
// The fallback bypass token must be base64-encoded because Part.thoughtSignature is
147+
// documented as a base64 field. Vertex AI validates this strictly.
148+
const historyNoSig: Anthropic.Messages.MessageParam[] = [
149+
{ role: "user", content: "Read foo.ts" },
150+
{
151+
role: "assistant",
152+
content: [{ type: "tool_use", id: "call-1", name: "read_file", input: { path: "foo.ts" } }],
153+
},
154+
{
155+
role: "user",
156+
content: [{ type: "tool_result", tool_use_id: "call-1", content: "file contents" }],
157+
},
158+
]
159+
160+
;(handler["client"].models.generateContentStream as any).mockResolvedValue(
161+
makeStream([
162+
{ candidates: [{ content: { parts: [{ text: "Done." }] } }] },
163+
{ usageMetadata: { promptTokenCount: 20, candidatesTokenCount: 5 } },
164+
]),
165+
)
166+
167+
for await (const _chunk of handler.createMessage(systemPrompt, historyNoSig, toolMetadata)) {
168+
// drain
169+
}
170+
171+
const callArgs = (handler["client"].models.generateContentStream as any).mock.calls[0][0]
172+
const contents: any[] = callArgs.contents
173+
const modelTurn = contents.find((c: any) => c.role === "model")
174+
const fnPart = modelTurn?.parts.find((p: any) => p.functionCall)
175+
expect(fnPart).toBeDefined()
176+
const expectedBypass = Buffer.from("skip_thought_signature_validator").toString("base64")
177+
expect(fnPart.thoughtSignature).toBe(expectedBypass)
178+
})
179+
180+
it("sends thoughtSignature even when reasoningEffort is disabled", async () => {
181+
// If the user disables reasoning effort, thinkingConfig=undefined.
182+
// The old code: includeThoughtSignatures = Boolean(thinkingConfig) || Boolean(metadata?.tools?.length)
183+
// With tools present this is still true — but if called with no tools it would be false.
184+
// Verify the signature is sent regardless when tools are in the metadata.
185+
const handlerNoReasoning = new GeminiHandler({
186+
apiKey: "test-key",
187+
geminiApiKey: "test-key",
188+
apiModelId: GEMINI_MODEL_NAME,
189+
reasoningEffort: "disable" as any,
190+
})
191+
handlerNoReasoning["client"] = handler["client"] as any
192+
193+
const historyWithSig: Anthropic.Messages.MessageParam[] = [
194+
{ role: "user", content: "Read foo.ts" },
195+
{
196+
role: "assistant",
197+
content: [
198+
{ type: "tool_use", id: "call-1", name: "read_file", input: { path: "foo.ts" } },
199+
{ type: "thoughtSignature", thoughtSignature: "sig-xyz" } as any,
200+
],
201+
},
202+
{
203+
role: "user",
204+
content: [{ type: "tool_result", tool_use_id: "call-1", content: "file contents" }],
205+
},
206+
]
207+
208+
;(handler["client"].models.generateContentStream as any).mockResolvedValue(
209+
makeStream([
210+
{ candidates: [{ content: { parts: [{ text: "Done." }] } }] },
211+
{ usageMetadata: { promptTokenCount: 20, candidatesTokenCount: 5 } },
212+
]),
213+
)
214+
215+
for await (const _chunk of handlerNoReasoning.createMessage(systemPrompt, historyWithSig, toolMetadata)) {
216+
// drain
217+
}
218+
219+
const callArgs = (handler["client"].models.generateContentStream as any).mock.calls[0][0]
220+
const contents: any[] = callArgs.contents
221+
const modelTurn = contents.find((c: any) => c.role === "model")
222+
const fnPart = modelTurn?.parts.find((p: any) => p.functionCall)
223+
expect(fnPart).toBeDefined()
224+
expect(fnPart.thoughtSignature).toBe("sig-xyz")
225+
})
226+
227+
it("does NOT capture thoughtSignature when there are no tools in metadata", async () => {
228+
// Without tools, includeThoughtSignatures=false when thinkingConfig is also absent.
229+
// This tests the boundary so we don't over-eagerly store signatures for non-tool calls.
230+
const handlerNoReasoning = new GeminiHandler({
231+
apiKey: "test-key",
232+
geminiApiKey: "test-key",
233+
apiModelId: GEMINI_MODEL_NAME,
234+
reasoningEffort: "disable" as any,
235+
})
236+
handlerNoReasoning["client"] = handler["client"] as any
237+
;(handler["client"].models.generateContentStream as any).mockResolvedValue(
238+
makeStream([
239+
{
240+
candidates: [
241+
{
242+
content: {
243+
parts: [
244+
{ functionCall: { name: "read_file", args: { path: "foo.ts" } } },
245+
{ thoughtSignature: "sig-should-not-be-captured" },
246+
],
247+
},
248+
},
249+
],
250+
},
251+
{ usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 } },
252+
]),
253+
)
254+
255+
// No tools in metadata, no thinkingConfig → includeThoughtSignatures=false
256+
for await (const _chunk of handlerNoReasoning.createMessage(systemPrompt, [
257+
{ role: "user", content: "hi" },
258+
])) {
259+
// drain
260+
}
261+
262+
expect(handlerNoReasoning.getThoughtSignature()).toBeUndefined()
263+
})
264+
})
265+
57266
describe("createMessage", () => {
58267
const mockMessages: Anthropic.Messages.MessageParam[] = [
59268
{

src/api/providers/lite-llm.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ApiHandlerOptions } from "../../shared/api"
99

1010
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
1111
import { convertToOpenAiMessages } from "../transform/openai-format"
12+
import { GEMINI_THOUGHT_SIGNATURE_BYPASS } from "../transform/gemini-format"
1213
import { sanitizeOpenAiCallId } from "../../utils/tool-id"
1314

1415
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
@@ -72,7 +73,7 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa
7273
*
7374
* Per LiteLLM documentation:
7475
* - Thought signatures are stored in provider_specific_fields.thought_signature of tool calls
75-
* - The dummy signature base64("skip_thought_signature_validator") bypasses validation
76+
* - The bypass token (GEMINI_THOUGHT_SIGNATURE_BYPASS) skips signature validation
7677
*
7778
* We inject the dummy signature on EVERY tool call unconditionally to ensure Gemini
7879
* doesn't complain about missing/corrupted signatures when conversation history
@@ -81,8 +82,7 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa
8182
private injectThoughtSignatureForGemini(
8283
openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[],
8384
): OpenAI.Chat.ChatCompletionMessageParam[] {
84-
// Base64 encoded "skip_thought_signature_validator" as per LiteLLM docs
85-
const dummySignature = Buffer.from("skip_thought_signature_validator").toString("base64")
85+
const dummySignature = GEMINI_THOUGHT_SIGNATURE_BYPASS
8686

8787
return openAiMessages.map((msg) => {
8888
if (msg.role === "assistant") {

src/api/transform/__tests__/gemini-format.spec.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@ describe("convertAnthropicMessageToGemini", () => {
123123

124124
const result = convertAnthropicMessageToGemini(anthropicMessage)
125125

126+
// thoughtSignature must be base64-encoded: the Gemini API documents Part.thoughtSignature
127+
// as "Encoded as base64 string". Sending the raw bypass token without base64 encoding fails
128+
// on Vertex AI (Gemini 3.1/3.5 strict validation), causing empty-response loops on turn 2+.
129+
const expectedBypassToken = Buffer.from("skip_thought_signature_validator").toString("base64")
130+
126131
expect(result).toEqual([
127132
{
128133
role: "model",
@@ -133,7 +138,7 @@ describe("convertAnthropicMessageToGemini", () => {
133138
name: "calculator",
134139
args: { operation: "add", numbers: [2, 3] },
135140
},
136-
thoughtSignature: "skip_thought_signature_validator",
141+
thoughtSignature: expectedBypassToken,
137142
},
138143
],
139144
},

src/api/transform/gemini-format.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { Anthropic } from "@anthropic-ai/sdk"
22
import { Content, Part } from "@google/genai"
33

4+
// Gemini documents Part.thoughtSignature as "Encoded as base64 string". Vertex AI enforces
5+
// this strictly — sending the plain string causes empty responses after the first tool call.
6+
// This bypass token tells Gemini to skip signature validation for cross-model history entries.
7+
export const GEMINI_THOUGHT_SIGNATURE_BYPASS = Buffer.from("skip_thought_signature_validator").toString("base64")
8+
49
type ThoughtSignatureContentBlock = {
510
type: "thoughtSignature"
611
thoughtSignature?: string
@@ -42,10 +47,12 @@ export function convertAnthropicContentToGemini(
4247
// Determine the signature to attach to function calls.
4348
// If we're in a mode that expects signatures (includeThoughtSignatures is true):
4449
// 1. Use the actual signature if we found one in the history/content.
45-
// 2. Fallback to "skip_thought_signature_validator" if missing (e.g. cross-model history).
50+
// 2. Fallback to a base64-encoded bypass token if missing (e.g. cross-model history).
51+
// Part.thoughtSignature is documented as "Encoded as base64 string" — Vertex AI validates
52+
// this strictly and returns empty responses when a non-base64 value is sent.
4653
let functionCallSignature: string | undefined
4754
if (includeThoughtSignatures) {
48-
functionCallSignature = activeThoughtSignature || "skip_thought_signature_validator"
55+
functionCallSignature = activeThoughtSignature || GEMINI_THOUGHT_SIGNATURE_BYPASS
4956
}
5057

5158
if (typeof content === "string") {

0 commit comments

Comments
 (0)