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
This commit is contained in:
2026-04-27 16:37:09 -07:00
parent 210fba4e08
commit d246cbaf75
2 changed files with 25 additions and 9 deletions

View File

@@ -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);
}

View File

@@ -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<string, unknown>;
/** 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,
},
};
}