From d246cbaf75f31a448b66d5fc5c9fe5ed3d4b37a7 Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Mon, 27 Apr 2026 16:37:09 -0700 Subject: [PATCH] Fix Gemini 3.1 Pro thought_signature in tool calls Thinking models attach a thought_signature to functionCall parts. Must be echoed back in functionResponse or API returns 400. Carry it through ToolCall -> ChatMessage -> toGeminiContents(). Made-with: Cursor --- app/api/chat/route.ts | 2 ++ lib/ai/gemini-chat.ts | 32 +++++++++++++++++++++++--------- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 3dfbbeba..5f5a945f 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -183,6 +183,8 @@ export async function POST(request: Request) { content: result, toolCallId: tc.id, toolName: tc.name, + // Echo thought_signature back — required by Gemini thinking models + thoughtSignature: tc.thoughtSignature, }; messages.push(toolMsg); } diff --git a/lib/ai/gemini-chat.ts b/lib/ai/gemini-chat.ts index aa0d3817..14ecd36e 100644 --- a/lib/ai/gemini-chat.ts +++ b/lib/ai/gemini-chat.ts @@ -4,6 +4,11 @@ * Uses the Gemini API (generativelanguage.googleapis.com) with the * existing GOOGLE_API_KEY. Drop-in upgrade to Vertex AI when needed * by swapping GEMINI_BASE_URL. + * + * NOTE: Gemini thinking models (2.5+, 3.x) attach a `thought_signature` + * to functionCall parts. This signature MUST be echoed back in the + * functionResponse or the API returns a 400. We carry it through our + * ToolCall type and re-attach it when building contents[]. */ const GEMINI_API_KEY = process.env.GOOGLE_API_KEY || ''; @@ -18,12 +23,16 @@ export interface ChatMessage { /** Populated when role === 'tool' */ toolCallId?: string; toolName?: string; + /** Echo thought_signature back for tool responses (thinking models) */ + thoughtSignature?: string; } export interface ToolCall { id: string; name: string; args: Record; + /** Gemini thinking-model signature — must be echoed in functionResponse */ + thoughtSignature?: string; } export interface ToolDefinition { @@ -51,20 +60,23 @@ function toGeminiContents(messages: ChatMessage[]) { if (msg.content) parts.push({ text: msg.content }); if (msg.toolCalls?.length) { for (const tc of msg.toolCalls) { - parts.push({ functionCall: { name: tc.name, args: tc.args, id: tc.id } }); + const fc: any = { name: tc.name, args: tc.args, id: tc.id }; + if (tc.thoughtSignature) fc.thought_signature = tc.thoughtSignature; + parts.push({ functionCall: fc }); } } if (parts.length) contents.push({ role: 'model', parts }); } else if (msg.role === 'tool') { - // Tool results must be bundled after the model turn that requested them - const last = contents[contents.length - 1]; - const part = { - functionResponse: { - name: msg.toolName || 'unknown', - id: msg.toolCallId, - response: { content: msg.content }, - }, + const fr: any = { + name: msg.toolName || 'unknown', + id: msg.toolCallId, + response: { content: msg.content }, }; + // Echo the thought_signature back — required for Gemini thinking models + if (msg.thoughtSignature) fr.thought_signature = msg.thoughtSignature; + + const part = { functionResponse: fr }; + const last = contents[contents.length - 1]; if (last?.role === 'user') { last.parts.push(part); } else { @@ -178,6 +190,8 @@ export async function* streamGeminiChat(opts: { id: part.functionCall.id || `tc-${Date.now()}`, name: part.functionCall.name, args: part.functionCall.args ?? {}, + // Carry the thought_signature so the chat route can echo it back + thoughtSignature: part.functionCall.thought_signature, }, }; }