1
0
forked from boos/BoosAPI

fix: inject thoughtSignature into functionCall parts for OpenAI→Gemini conversion

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).
This commit is contained in:
weijiafu14
2026-03-17 16:08:08 +08:00
parent 4c90028559
commit 8bfdbda251
+62 -12
View File
@@ -1,6 +1,10 @@
import { TextDecoder, TextEncoder } from 'node:util'; import { TextDecoder, TextEncoder } from 'node:util';
import { resolveGeminiThinkingConfigFromRequest } from '../../transformers/gemini/generate-content/convert.js'; 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<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value); 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<string, string>();
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<Record<string, unknown>> = []; const systemParts: Array<Record<string, unknown>> = [];
if (typeof input.instructions === 'string' && input.instructions.trim()) { if (typeof input.instructions === 'string' && input.instructions.trim()) {
systemParts.push({ text: input.instructions.trim() }); systemParts.push({ text: input.instructions.trim() });
@@ -176,7 +201,8 @@ export function buildGeminiGenerateContentRequestFromOpenAi(input: {
continue; continue;
} }
const parts = convertContentToGeminiParts(message.content); const textParts = convertContentToGeminiParts(message.content);
const fcParts: Array<Record<string, unknown>> = [];
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
for (const toolCall of toolCalls) { for (const toolCall of toolCalls) {
if (!isRecord(toolCall) || !isRecord(toolCall.function)) continue; if (!isRecord(toolCall) || !isRecord(toolCall.function)) continue;
@@ -193,21 +219,45 @@ export function buildGeminiGenerateContentRequestFromOpenAi(input: {
} else if (isRecord(rawArguments)) { } else if (isRecord(rawArguments)) {
args = rawArguments; args = rawArguments;
} }
parts.push({ const fcPart: Record<string, unknown> = {
functionCall: { functionCall: { name, args },
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;
} }
if (parts.length <= 0) continue; 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 = [ request.contents = [
...(Array.isArray(request.contents) ? request.contents : []), ...(Array.isArray(request.contents) ? request.contents : []),
{ { role: geminiRole, parts: textParts },
role: role === 'assistant' ? 'model' : 'user', { role: geminiRole, parts: fcParts },
parts,
},
]; ];
} else {
const allParts = [...textParts, ...fcParts];
if (allParts.length <= 0) continue;
request.contents = [
...(Array.isArray(request.contents) ? request.contents : []),
{ role: geminiRole, parts: allParts },
];
}
} }
if (systemParts.length > 0) { if (systemParts.length > 0) {