1
0
forked from boos/BoosAPI

Merge pull request #135 from weijiafu14/fix/gemini-thought-signature-tool-calls

fix: inject thoughtSignature into functionCall parts for OpenAI→Gemini conversion
This commit is contained in:
Cita
2026-03-17 18:37:12 +08:00
committed by GitHub
3 changed files with 279 additions and 44 deletions
@@ -0,0 +1,196 @@
import { describe, it, expect } from 'vitest';
import { buildGeminiGenerateContentRequestFromOpenAi } from './geminiCliCompat.js';
describe('thoughtSignature injection in OpenAI→Gemini conversion', () => {
it('injects thoughtSignature from provider_specific_fields into functionCall parts', () => {
const result = buildGeminiGenerateContentRequestFromOpenAi({
body: {
model: 'gemini-3-flash-preview',
messages: [
{ role: 'user', content: 'What is the weather?' },
{
role: 'assistant',
content: 'Let me check.',
tool_calls: [
{
id: 'call_123',
type: 'function',
function: { name: 'get_weather', arguments: '{"city":"Tokyo"}' },
provider_specific_fields: { thought_signature: 'real_sig_abc' },
},
],
},
{ role: 'tool', tool_call_id: 'call_123', content: '{"temp":"22C"}' },
],
},
modelName: 'gemini-3-flash-preview',
}) as Record<string, unknown>;
const contents = result.contents as Array<Record<string, unknown>>;
// Find the model message with functionCall
const modelMsgs = contents.filter((c) => c.role === 'model');
const fcParts = modelMsgs
.flatMap((m) => (m.parts as Array<Record<string, unknown>>))
.filter((p) => 'functionCall' in p);
expect(fcParts.length).toBe(1);
expect(fcParts[0].thoughtSignature).toBe('real_sig_abc');
});
it('splits text and signed functionCall parts into separate model messages', () => {
const result = buildGeminiGenerateContentRequestFromOpenAi({
body: {
model: 'gemini-3-flash-preview',
messages: [
{ role: 'user', content: 'Read the file.' },
{
role: 'assistant',
content: 'I will read it.',
tool_calls: [
{
id: 'call_456',
type: 'function',
function: { name: 'Read', arguments: '{"path":"/tmp/x"}' },
provider_specific_fields: { thought_signature: 'sig_split_test' },
},
],
},
{ role: 'tool', tool_call_id: 'call_456', content: 'file content here' },
],
},
modelName: 'gemini-3-flash-preview',
}) as Record<string, unknown>;
const contents = result.contents as Array<Record<string, unknown>>;
const modelMsgs = contents.filter((c) => c.role === 'model');
// Should be 2 model messages: text (unsigned) + functionCall (signed)
expect(modelMsgs.length).toBe(2);
const firstParts = modelMsgs[0].parts as Array<Record<string, unknown>>;
const secondParts = modelMsgs[1].parts as Array<Record<string, unknown>>;
// First: text only, no thoughtSignature
expect(firstParts.every((p) => 'text' in p)).toBe(true);
expect(firstParts.every((p) => !('thoughtSignature' in p))).toBe(true);
// Second: functionCall with thoughtSignature
expect(secondParts.every((p) => 'functionCall' in p)).toBe(true);
expect(secondParts[0].thoughtSignature).toBe('sig_split_test');
});
it('injects dummy sentinel when thinking enabled but no signature available', () => {
const result = buildGeminiGenerateContentRequestFromOpenAi({
body: {
model: 'gemini-3-flash-preview',
reasoning_effort: 'high',
messages: [
{ role: 'user', content: 'Do something.' },
{
role: 'assistant',
tool_calls: [
{
id: 'call_no_sig',
type: 'function',
function: { name: 'Bash', arguments: '{"command":"ls"}' },
// No provider_specific_fields — signature missing
},
],
},
{ role: 'tool', tool_call_id: 'call_no_sig', content: 'file1\nfile2' },
],
},
modelName: 'gemini-3-flash-preview',
}) as Record<string, unknown>;
const contents = result.contents as Array<Record<string, unknown>>;
const modelMsgs = contents.filter((c) => c.role === 'model');
const fcParts = modelMsgs
.flatMap((m) => (m.parts as Array<Record<string, unknown>>))
.filter((p) => 'functionCall' in p);
expect(fcParts.length).toBe(1);
// Should have a dummy sentinel, not be missing
expect(typeof fcParts[0].thoughtSignature).toBe('string');
expect((fcParts[0].thoughtSignature as string).length).toBeGreaterThan(0);
});
it('does not inject thoughtSignature when thinking is not enabled', () => {
const result = buildGeminiGenerateContentRequestFromOpenAi({
body: {
model: 'gemini-2.5-flash',
messages: [
{ role: 'user', content: 'Hello' },
{
role: 'assistant',
tool_calls: [
{
id: 'call_no_think',
type: 'function',
function: { name: 'Read', arguments: '{"path":"/x"}' },
},
],
},
{ role: 'tool', tool_call_id: 'call_no_think', content: 'data' },
],
},
modelName: 'gemini-2.5-flash',
}) as Record<string, unknown>;
const contents = result.contents as Array<Record<string, unknown>>;
const modelMsgs = contents.filter((c) => c.role === 'model');
const fcParts = modelMsgs
.flatMap((m) => (m.parts as Array<Record<string, unknown>>))
.filter((p) => 'functionCall' in p);
expect(fcParts.length).toBe(1);
// No thinking → no signature injected
expect(fcParts[0].thoughtSignature).toBeUndefined();
});
it('preserves functionResponse count matching functionCall count', () => {
const result = buildGeminiGenerateContentRequestFromOpenAi({
body: {
model: 'gemini-3-flash-preview',
messages: [
{ role: 'user', content: 'Read two files.' },
{
role: 'assistant',
tool_calls: [
{
id: 'call_a',
type: 'function',
function: { name: 'Read', arguments: '{"path":"/a"}' },
provider_specific_fields: { thought_signature: 'sig_a' },
},
{
id: 'call_b',
type: 'function',
function: { name: 'Read', arguments: '{"path":"/b"}' },
provider_specific_fields: { thought_signature: 'sig_b' },
},
],
},
{ role: 'tool', tool_call_id: 'call_a', content: 'content a' },
{ role: 'tool', tool_call_id: 'call_b', content: 'content b' },
],
},
modelName: 'gemini-3-flash-preview',
}) as Record<string, unknown>;
const contents = result.contents as Array<Record<string, unknown>>;
// Count functionCall and functionResponse parts
let fcCount = 0;
let frCount = 0;
for (const content of contents) {
for (const part of (content.parts as Array<Record<string, unknown>>)) {
if ('functionCall' in part) fcCount++;
if ('functionResponse' in part) frCount++;
}
}
expect(fcCount).toBe(2);
expect(frCount).toBe(2);
});
});
+65 -15
View File
@@ -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<string, unknown> {
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>> = [];
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<Record<string, unknown>> = [];
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<string, unknown> = {
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) {