Skip to content

Commit 840f421

Browse files
Fix Gemini thought signature validation and token counting errors (#9380)
Co-authored-by: Matt Rubens <[email protected]>
1 parent 89068c4 commit 840f421

File tree

4 files changed

+41
-31
lines changed

4 files changed

+41
-31
lines changed

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

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -206,23 +206,6 @@ describe("GeminiHandler", () => {
206206
expect(handler.calculateCost({ info: mockInfo, inputTokens: 0, outputTokens })).toBeCloseTo(expectedCost)
207207
})
208208

209-
it("should calculate cost with cache write tokens", () => {
210-
const inputTokens = 10000
211-
const outputTokens = 20000
212-
const cacheWriteTokens = 5000
213-
const CACHE_TTL = 5 // Match the constant in gemini.ts
214-
215-
// Added non-null assertions (!)
216-
const expectedInputCost = (inputTokens / 1_000_000) * mockInfo.inputPrice!
217-
const expectedOutputCost = (outputTokens / 1_000_000) * mockInfo.outputPrice!
218-
const expectedCacheWriteCost =
219-
mockInfo.cacheWritesPrice! * (cacheWriteTokens / 1_000_000) * (CACHE_TTL / 60)
220-
const expectedCost = expectedInputCost + expectedOutputCost + expectedCacheWriteCost
221-
222-
const cost = handler.calculateCost({ info: mockInfo, inputTokens, outputTokens })
223-
expect(cost).toBeCloseTo(expectedCost)
224-
})
225-
226209
it("should calculate cost with cache read tokens", () => {
227210
const inputTokens = 10000 // Total logical input
228211
const outputTokens = 20000

src/api/providers/gemini.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,9 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
8282
: (maxTokens ?? undefined)
8383

8484
// Only forward encrypted reasoning continuations (thoughtSignature) when we are
85-
// using effort-based reasoning (thinkingLevel). Budget-only configs should NOT
86-
// send thoughtSignature parts back to Gemini.
87-
const includeThoughtSignatures = Boolean(thinkingConfig?.thinkingLevel)
85+
// using reasoning (thinkingConfig is present). Both effort-based (thinkingLevel)
86+
// and budget-based (thinkingBudget) models require this for active loops.
87+
const includeThoughtSignatures = Boolean(thinkingConfig)
8888

8989
// The message list can include provider-specific meta entries such as
9090
// `{ type: "reasoning", ... }` that are intended only for providers like
@@ -162,10 +162,9 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
162162
}>) {
163163
// Capture thought signatures so they can be persisted into API history.
164164
const thoughtSignature = part.thoughtSignature
165-
// Only persist encrypted reasoning when an effort-based thinking level is set
166-
// (i.e. thinkingConfig.thinkingLevel is present). Budget-based configs that only
167-
// set thinkingBudget should NOT trigger encrypted continuation.
168-
if (thinkingConfig?.thinkingLevel && thoughtSignature) {
165+
// Persist encrypted reasoning when using reasoning. Both effort-based
166+
// and budget-based models require this for active loops.
167+
if (thinkingConfig && thoughtSignature) {
169168
this.lastThoughtSignature = thoughtSignature
170169
}
171170

@@ -351,7 +350,12 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
351350
const countTokensRequest = {
352351
model,
353352
// Token counting does not need encrypted continuation; always drop thoughtSignature.
354-
contents: convertAnthropicContentToGemini(content, { includeThoughtSignatures: false }),
353+
contents: [
354+
{
355+
role: "user",
356+
parts: convertAnthropicContentToGemini(content, { includeThoughtSignatures: false }),
357+
},
358+
],
355359
}
356360

357361
const response = await this.client.models.countTokens(countTokensRequest)

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ describe("convertAnthropicMessageToGemini", () => {
124124
name: "calculator",
125125
args: { operation: "add", numbers: [2, 3] },
126126
},
127+
thoughtSignature: "skip_thought_signature_validator",
127128
},
128129
],
129130
})

src/api/transform/gemini-format.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,30 @@ export function convertAnthropicContentToGemini(
1919
): Part[] {
2020
const includeThoughtSignatures = options?.includeThoughtSignatures ?? true
2121

22+
// First pass: find thoughtSignature if it exists in the content blocks
23+
let activeThoughtSignature: string | undefined
24+
if (Array.isArray(content)) {
25+
const sigBlock = content.find((block) => isThoughtSignatureContentBlock(block)) as ThoughtSignatureContentBlock
26+
if (sigBlock?.thoughtSignature) {
27+
activeThoughtSignature = sigBlock.thoughtSignature
28+
}
29+
}
30+
31+
// Determine the signature to attach to function calls.
32+
// If we're in a mode that expects signatures (includeThoughtSignatures is true):
33+
// 1. Use the actual signature if we found one in the history/content.
34+
// 2. Fallback to "skip_thought_signature_validator" if missing (e.g. cross-model history).
35+
let functionCallSignature: string | undefined
36+
if (includeThoughtSignatures) {
37+
functionCallSignature = activeThoughtSignature || "skip_thought_signature_validator"
38+
}
39+
2240
if (typeof content === "string") {
2341
return [{ text: content }]
2442
}
2543

2644
return content.flatMap((block): Part | Part[] => {
27-
// Handle thoughtSignature blocks first so that the main switch can continue
28-
// to operate on the standard Anthropic content union. This preserves strong
29-
// typing for known block types while still allowing provider-specific
30-
// extensions when needed.
45+
// Handle thoughtSignature blocks first
3146
if (isThoughtSignatureContentBlock(block)) {
3247
if (includeThoughtSignatures && typeof block.thoughtSignature === "string") {
3348
// The Google GenAI SDK currently exposes thoughtSignature as an
@@ -54,7 +69,10 @@ export function convertAnthropicContentToGemini(
5469
name: block.name,
5570
args: block.input as Record<string, unknown>,
5671
},
57-
}
72+
// Inject the thoughtSignature into the functionCall part if required.
73+
// This is necessary for Gemini 2.5/3+ thinking models to validate the tool call.
74+
...(functionCallSignature ? { thoughtSignature: functionCallSignature } : {}),
75+
} as Part
5876
case "tool_result": {
5977
if (!block.content) {
6078
return []
@@ -108,6 +126,10 @@ export function convertAnthropicMessageToGemini(
108126
): Content {
109127
return {
110128
role: message.role === "assistant" ? "model" : "user",
111-
parts: convertAnthropicContentToGemini(message.content, options),
129+
parts: convertAnthropicContentToGemini(message.content, {
130+
...options,
131+
includeThoughtSignatures:
132+
message.role === "assistant" ? (options?.includeThoughtSignatures ?? true) : false,
133+
}),
112134
}
113135
}

0 commit comments

Comments
 (0)