diff --git a/open-sse/translator/response/openai-to-gemini-sse.ts b/open-sse/translator/response/openai-to-gemini-sse.ts index 6613d061c90..4a39cf9175d 100644 --- a/open-sse/translator/response/openai-to-gemini-sse.ts +++ b/open-sse/translator/response/openai-to-gemini-sse.ts @@ -41,7 +41,14 @@ interface OpenAIToolCallDelta { index?: number; id?: string; type?: string; - function?: { name?: string; arguments?: string }; + thoughtSignature?: string; + thought_signature?: string; + function?: { + name?: string; + arguments?: string; + thoughtSignature?: string; + thought_signature?: string; + }; } interface OpenAIChoiceDelta { @@ -84,6 +91,7 @@ interface GeminiFunctionResponse { interface GeminiPart { text?: string; thought?: boolean; + thoughtSignature?: string; functionCall?: GeminiFunctionCall; functionResponse?: GeminiFunctionResponse; } @@ -95,7 +103,10 @@ interface GeminiPart { * `functionCall` part once `finish_reason` arrives. */ export interface GeminiToolCallState { - toolCallAccum?: Record; + toolCallAccum?: Record< + number, + { id: string; name: string; arguments: string; thoughtSignature?: string } + >; } interface GeminiCandidate { @@ -154,6 +165,15 @@ export function openAIChunkToGeminiChunk( if (tc.id) entry.id = tc.id; if (tc.function?.name) entry.name += tc.function.name; if (tc.function?.arguments) entry.arguments += tc.function.arguments; + // Preserve a genuine Gemini thought signature when the upstream carries it + // (top-level `thoughtSignature`/`thought_signature` or nested on `function`), + // so the flushed functionCall can be replayed on the next tool turn (#400). + const sig = + tc.thoughtSignature ?? + tc.thought_signature ?? + tc.function?.thoughtSignature ?? + tc.function?.thought_signature; + if (typeof sig === "string" && sig.length > 0) entry.thoughtSignature = sig; } } @@ -168,7 +188,10 @@ export function openAIChunkToGeminiChunk( } catch { args = {}; } - parts.push({ functionCall: { name: entry.name, args } }); + parts.push({ + ...(entry.thoughtSignature ? { thoughtSignature: entry.thoughtSignature } : {}), + functionCall: { name: entry.name, args }, + }); } } @@ -294,7 +317,14 @@ export function transformOpenAISSEToGeminiSSE(upstreamResponse: Response, model: interface OpenAIToolCall { id?: string; type?: string; - function?: { name?: string; arguments?: string }; + thoughtSignature?: string; + thought_signature?: string; + function?: { + name?: string; + arguments?: string; + thoughtSignature?: string; + thought_signature?: string; + }; } interface OpenAIMessage { @@ -395,7 +425,15 @@ export async function convertOpenAIResponseToGemini( } catch { args = {}; } - parts.push({ functionCall: { name: tc.function?.name || "", args } }); + const sig = + tc.thoughtSignature ?? + tc.thought_signature ?? + tc.function?.thoughtSignature ?? + tc.function?.thought_signature; + parts.push({ + ...(typeof sig === "string" && sig.length > 0 ? { thoughtSignature: sig } : {}), + functionCall: { name: tc.function?.name || "", args }, + }); } const finishReason = OPENAI_TO_GEMINI_FINISH_REASON[finish_reason ?? "stop"] ?? "STOP"; diff --git a/tests/unit/translator-openai-to-gemini-sse.test.ts b/tests/unit/translator-openai-to-gemini-sse.test.ts index 96a0b222e1c..3d0f91877c3 100644 --- a/tests/unit/translator-openai-to-gemini-sse.test.ts +++ b/tests/unit/translator-openai-to-gemini-sse.test.ts @@ -226,3 +226,106 @@ test("convertOpenAIResponseToGemini: surfaces upstream error bodies untouched", const body = (await out.json()) as { error: { code: number } }; assert.equal(body.error.code, 429); }); + +test("transformOpenAISSEToGeminiSSE: preserves genuine thoughtSignature across streamed tool_calls deltas", async () => { + const upstream = makeOpenAISSEResponse([ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_sig123","type":"function","thoughtSignature":"sig_stream_123","function":{"name":"get_weather","arguments":"{\\"city\\":"}}]},"finish_reason":null}]}', + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"Tokyo\\"}"}}]},"finish_reason":null}]}', + 'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}', + ]); + + const out = transformOpenAISSEToGeminiSSE(upstream, "gemini/gemini-pro"); + const events = await readGeminiSSE(out); + assert.equal(events.length, 1); + + const candidate = events[0].candidates[0]; + assert.equal(candidate.finishReason, "STOP"); + + const fcPart = candidate.content.parts.find((p) => "functionCall" in p) as + | { thoughtSignature?: string; functionCall: { name: string; args: Record } } + | undefined; + + assert.ok(fcPart); + assert.equal(fcPart.functionCall.name, "get_weather"); + assert.deepEqual(fcPart.functionCall.args, { city: "Tokyo" }); + assert.equal(fcPart.thoughtSignature, "sig_stream_123"); +}); + +test("convertOpenAIResponseToGemini: preserves genuine thoughtSignature in non-stream tool calls", async () => { + const upstream = Response.json({ + choices: [ + { + message: { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_sig456", + type: "function", + thoughtSignature: "sig_nonstream_456", + function: { name: "search", arguments: '{"q":"gemini"}' }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + model: "gemini-3.5-flash-lite", + }); + + const out = await convertOpenAIResponseToGemini(upstream, "fallback"); + const body = (await out.json()) as { + candidates: Array<{ + content: { + parts: Array<{ + thoughtSignature?: string; + functionCall?: { name: string; args: Record }; + }>; + }; + }>; + }; + + const fcPart = body.candidates[0].content.parts.find((p) => p.functionCall); + assert.ok(fcPart?.functionCall); + assert.equal(fcPart.functionCall.name, "search"); + assert.deepEqual(fcPart.functionCall.args, { q: "gemini" }); + assert.equal(fcPart.thoughtSignature, "sig_nonstream_456"); +}); + +test("convertOpenAIResponseToGemini: omits thoughtSignature when none was provided by upstream", async () => { + const upstream = Response.json({ + choices: [ + { + message: { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_plain", + type: "function", + function: { name: "ping", arguments: "{}" }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + }); + + const out = await convertOpenAIResponseToGemini(upstream, "fallback"); + const body = (await out.json()) as { + candidates: Array<{ + content: { + parts: Array<{ + thoughtSignature?: string; + functionCall?: { name: string; args: Record }; + }>; + }; + }>; + }; + + const fcPart = body.candidates[0].content.parts.find((p) => p.functionCall); + assert.ok(fcPart?.functionCall); + assert.equal(fcPart.functionCall.name, "ping"); + assert.equal("thoughtSignature" in fcPart, false); +});