From 8bfdbda251641eb339d8942d769db517d31b149a Mon Sep 17 00:00:00 2001 From: weijiafu14 <17469139+weijiafu14@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:08:08 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20inject=20thoughtSignature=20into=20funct?= =?UTF-8?q?ionCall=20parts=20for=20OpenAI=E2=86=92Gemini=20conversion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When requests arrive via OpenAI-compatible endpoints and are converted to Gemini format, functionCall parts were missing thoughtSignature. Gemini 3+ rejects such requests with 400 when thinking mode is enabled. Changes: - Extract thoughtSignature from tool_calls provider_specific_fields - Inject into corresponding functionCall parts - Fall back to dummy sentinel when thinking is enabled but no signature available - Split signed/unsigned parts into separate model messages (Gemini requirement) All existing tests pass (59/59). --- src/server/routes/proxy/geminiCliCompat.ts | 80 ++++++++++++++++++---- 1 file changed, 65 insertions(+), 15 deletions(-) diff --git a/src/server/routes/proxy/geminiCliCompat.ts b/src/server/routes/proxy/geminiCliCompat.ts index 917b0a3..090d60c 100644 --- a/src/server/routes/proxy/geminiCliCompat.ts +++ b/src/server/routes/proxy/geminiCliCompat.ts @@ -1,6 +1,10 @@ import { TextDecoder, TextEncoder } from 'node:util'; import { resolveGeminiThinkingConfigFromRequest } from '../../transformers/gemini/generate-content/convert.js'; +// Dummy sentinel used when no real thoughtSignature is available but thinking +// mode is enabled. Gemini accepts any base64 string and won't reject this. +const DUMMY_THOUGHT_SIGNATURE = 'c2tpcF90aG91Z2h0X3NpZ25hdHVyZV92YWxpZGF0b3I='; + function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } @@ -143,6 +147,27 @@ export function buildGeminiGenerateContentRequestFromOpenAi(input: { } } + // Detect if thinking mode is enabled (needed for dummy signature fallback) + const hasThinkingEnabled = !!resolveGeminiThinkingConfigFromRequest(input.modelName, input.body); + + // Collect thoughtSignatures from assistant tool_calls for injection. + // OpenAI format stores signatures in provider_specific_fields or encoded in tool call IDs. + const thoughtSignatureById = new Map(); + for (const message of messages) { + if (!isRecord(message) || asTrimmedString(message.role) !== 'assistant') continue; + const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; + for (const toolCall of toolCalls) { + if (!isRecord(toolCall)) continue; + const id = asTrimmedString(toolCall.id); + if (!id) continue; + // Check provider_specific_fields first + const providerFields = isRecord(toolCall.provider_specific_fields) ? toolCall.provider_specific_fields : null; + if (providerFields && typeof providerFields.thought_signature === 'string') { + thoughtSignatureById.set(id, providerFields.thought_signature); + } + } + } + const systemParts: Array> = []; if (typeof input.instructions === 'string' && input.instructions.trim()) { systemParts.push({ text: input.instructions.trim() }); @@ -176,7 +201,8 @@ export function buildGeminiGenerateContentRequestFromOpenAi(input: { continue; } - const parts = convertContentToGeminiParts(message.content); + const textParts = convertContentToGeminiParts(message.content); + const fcParts: Array> = []; const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; for (const toolCall of toolCalls) { if (!isRecord(toolCall) || !isRecord(toolCall.function)) continue; @@ -193,21 +219,45 @@ export function buildGeminiGenerateContentRequestFromOpenAi(input: { } else if (isRecord(rawArguments)) { args = rawArguments; } - parts.push({ - functionCall: { - name, - args, - }, - }); + const fcPart: Record = { + functionCall: { name, args }, + }; + // Inject thoughtSignature if available from provider_specific_fields. + // If no signature is found, use a dummy sentinel so Gemini doesn't reject + // the request outright. Gemini 3+ requires thoughtSignature on all + // functionCall parts when thinking is enabled. + // Ref: https://ai.google.dev/gemini-api/docs/thought-signatures + const id = asTrimmedString(toolCall.id); + const signature = thoughtSignatureById.get(id); + if (signature) { + fcPart.thoughtSignature = signature; + } else if (hasThinkingEnabled) { + fcPart.thoughtSignature = DUMMY_THOUGHT_SIGNATURE; + } + fcParts.push(fcPart); + } + + const geminiRole = role === 'assistant' ? 'model' : 'user'; + + // Gemini requires that parts WITH thoughtSignature and parts WITHOUT + // are not mixed in the same message. Split if needed. + // Ref: https://ai.google.dev/gemini-api/docs/thought-signatures + const hasSigned = fcParts.some((p) => 'thoughtSignature' in p); + if (hasSigned && textParts.length > 0 && fcParts.length > 0) { + // Emit text parts first, then functionCall parts in a separate message + request.contents = [ + ...(Array.isArray(request.contents) ? request.contents : []), + { role: geminiRole, parts: textParts }, + { role: geminiRole, parts: fcParts }, + ]; + } else { + const allParts = [...textParts, ...fcParts]; + if (allParts.length <= 0) continue; + request.contents = [ + ...(Array.isArray(request.contents) ? request.contents : []), + { role: geminiRole, parts: allParts }, + ]; } - if (parts.length <= 0) continue; - request.contents = [ - ...(Array.isArray(request.contents) ? request.contents : []), - { - role: role === 'assistant' ? 'model' : 'user', - parts, - }, - ]; } if (systemParts.length > 0) {