fix: restore Gemini thought signature round-tripping after AI SDK migration (#11237)

PR #11180 migrated Gemini/Vertex providers to the AI SDK and deleted
gemini-format.ts which contained the working thought signature round-trip
logic (originally added in PR #10590). This broke all Gemini 3 tool use
with a 400 error: 'Function call is missing a thought_signature'.

Changes:
- Gemini/Vertex handlers: capture thoughtSignature from providerMetadata
  on tool-call stream events, expose via getThoughtSignature()
- convertToAiSdkMessages(): extract thoughtSignature content blocks from
  history, attach as providerOptions on first tool-call part (per Gemini 3
  parallel call rules)
- Add 3 tests verifying thought signature round-trip behavior
This commit is contained in:
Hannes Rudolph 2026-02-05 18:17:13 -07:00 committed by GitHub
parent a266834ee2
commit 6a32b2e6fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 175 additions and 3 deletions

View file

@ -31,6 +31,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
protected options: ApiHandlerOptions
protected provider: GoogleGenerativeAIProvider
private readonly providerName = "Gemini"
private lastThoughtSignature: string | undefined
constructor(options: ApiHandlerOptions) {
super()
@ -124,11 +125,23 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
}
try {
// Reset thought signature for this request
this.lastThoughtSignature = undefined
// Use streamText for streaming responses
const result = streamText(requestOptions)
// Process the full stream to get all events including reasoning
for await (const part of result.fullStream) {
// Capture thoughtSignature from tool-call events (Gemini 3 thought signatures)
// The AI SDK's tool-call event includes providerMetadata with the signature
if (part.type === "tool-call") {
const googleMeta = (part as any).providerMetadata?.google
if (googleMeta?.thoughtSignature) {
this.lastThoughtSignature = googleMeta.thoughtSignature
}
}
for (const chunk of processAiSdkStreamPart(part)) {
yield chunk
}
@ -401,4 +414,13 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
override isAiSdkProvider(): boolean {
return true
}
/**
* Returns the thought signature captured from the last Gemini response.
* Gemini 3 models return thoughtSignature on function call parts,
* which must be round-tripped back for tool use continuations.
*/
getThoughtSignature(): string | undefined {
return this.lastThoughtSignature
}
}

View file

@ -35,6 +35,7 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl
protected options: ApiHandlerOptions
protected provider: GoogleVertexProvider
private readonly providerName = "Vertex"
private lastThoughtSignature: string | undefined
constructor(options: ApiHandlerOptions) {
super()
@ -138,11 +139,26 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl
}
try {
// Reset thought signature for this request
this.lastThoughtSignature = undefined
// Use streamText for streaming responses
const result = streamText(requestOptions)
// Process the full stream to get all events including reasoning
for await (const part of result.fullStream) {
// Capture thoughtSignature from tool-call events (Gemini 3 thought signatures)
// The AI SDK's tool-call event includes providerMetadata with the signature
// Vertex AI stores it under the "vertex" key in providerMetadata
if (part.type === "tool-call") {
const vertexMeta = (part as any).providerMetadata?.vertex
const googleMeta = (part as any).providerMetadata?.google
const sig = vertexMeta?.thoughtSignature ?? googleMeta?.thoughtSignature
if (sig) {
this.lastThoughtSignature = sig
}
}
for (const chunk of processAiSdkStreamPart(part)) {
yield chunk
}
@ -406,4 +422,13 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl
override isAiSdkProvider(): boolean {
return true
}
/**
* Returns the thought signature captured from the last Vertex AI response.
* Gemini 3 models return thoughtSignature on function call parts,
* which must be round-tripped back for tool use continuations.
*/
getThoughtSignature(): string | undefined {
return this.lastThoughtSignature
}
}

View file

@ -399,6 +399,101 @@ describe("AI SDK conversion utilities", () => {
],
})
})
it("attaches thoughtSignature to first tool-call part for Gemini 3 round-tripping", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{ type: "text", text: "Let me check that." },
{
type: "tool_use",
id: "tool-1",
name: "read_file",
input: { path: "test.txt" },
},
{ type: "thoughtSignature", thoughtSignature: "encrypted-sig-abc" } as any,
],
},
]
const result = convertToAiSdkMessages(messages)
expect(result).toHaveLength(1)
const assistantMsg = result[0]
expect(assistantMsg.role).toBe("assistant")
const content = assistantMsg.content as any[]
expect(content).toHaveLength(2) // text + tool-call (thoughtSignature block is consumed, not passed through)
const toolCallPart = content.find((p: any) => p.type === "tool-call")
expect(toolCallPart).toBeDefined()
expect(toolCallPart.providerOptions).toEqual({
google: { thoughtSignature: "encrypted-sig-abc" },
vertex: { thoughtSignature: "encrypted-sig-abc" },
})
})
it("attaches thoughtSignature only to the first tool-call in parallel calls", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-1",
name: "get_weather",
input: { city: "Paris" },
},
{
type: "tool_use",
id: "tool-2",
name: "get_weather",
input: { city: "London" },
},
{ type: "thoughtSignature", thoughtSignature: "sig-parallel" } as any,
],
},
]
const result = convertToAiSdkMessages(messages)
const content = (result[0] as any).content as any[]
const toolCalls = content.filter((p: any) => p.type === "tool-call")
expect(toolCalls).toHaveLength(2)
// Only the first tool call should have the signature
expect(toolCalls[0].providerOptions).toEqual({
google: { thoughtSignature: "sig-parallel" },
vertex: { thoughtSignature: "sig-parallel" },
})
// Second tool call should NOT have the signature
expect(toolCalls[1].providerOptions).toBeUndefined()
})
it("does not attach providerOptions when no thoughtSignature block is present", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{ type: "text", text: "Using tool" },
{
type: "tool_use",
id: "tool-1",
name: "read_file",
input: { path: "test.txt" },
},
],
},
]
const result = convertToAiSdkMessages(messages)
const content = (result[0] as any).content as any[]
const toolCallPart = content.find((p: any) => p.type === "tool-call")
expect(toolCallPart).toBeDefined()
expect(toolCallPart.providerOptions).toBeUndefined()
})
})
describe("convertToolsForAiSdk", () => {

View file

@ -136,8 +136,19 @@ export function convertToAiSdkMessages(
toolCallId: string
toolName: string
input: unknown
providerOptions?: Record<string, Record<string, unknown>>
}> = []
// Extract thoughtSignature from content blocks (Gemini 3 thought signature round-tripping).
// Task.ts stores these as { type: "thoughtSignature", thoughtSignature: "..." } blocks.
let thoughtSignature: string | undefined
for (const part of message.content) {
const partAny = part as unknown as { type?: string; thoughtSignature?: string }
if (partAny.type === "thoughtSignature" && partAny.thoughtSignature) {
thoughtSignature = partAny.thoughtSignature
}
}
for (const part of message.content) {
if (part.type === "text") {
textParts.push(part.text)
@ -145,12 +156,25 @@ export function convertToAiSdkMessages(
}
if (part.type === "tool_use") {
toolCalls.push({
const toolCall: (typeof toolCalls)[number] = {
type: "tool-call",
toolCallId: part.id,
toolName: part.name,
input: part.input,
})
}
// Attach thoughtSignature as providerOptions on tool-call parts.
// The AI SDK's @ai-sdk/google provider reads providerOptions.google.thoughtSignature
// and attaches it to the Gemini functionCall part.
// Per Gemini 3 rules: only the FIRST functionCall in a parallel batch gets the signature.
if (thoughtSignature && toolCalls.length === 0) {
toolCall.providerOptions = {
google: { thoughtSignature },
vertex: { thoughtSignature },
}
}
toolCalls.push(toolCall)
continue
}
@ -183,7 +207,13 @@ export function convertToAiSdkMessages(
const content: Array<
| { type: "reasoning"; text: string }
| { type: "text"; text: string }
| { type: "tool-call"; toolCallId: string; toolName: string; input: unknown }
| {
type: "tool-call"
toolCallId: string
toolName: string
input: unknown
providerOptions?: Record<string, Record<string, unknown>>
}
> = []
if (reasoningContent) {