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:
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user