diff --git a/changelog.d/fixes/7255-nonstreaming-gemini-family-projection.md b/changelog.d/fixes/7255-nonstreaming-gemini-family-projection.md new file mode 100644 index 0000000000..8e0ce75dc1 --- /dev/null +++ b/changelog.d/fixes/7255-nonstreaming-gemini-family-projection.md @@ -0,0 +1 @@ +- fix(sse): project non-streaming JSON responses back to the Gemini/Antigravity `{response:{candidates}}` envelope instead of leaking the raw OpenAI `choices[]` shape, so tool calls are no longer dropped for Gemini-family clients on the JSON path (#7255) (thanks @warelik) diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index faaf50d84b..12f384c6b1 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -556,6 +556,20 @@ export function translateNonStreamingResponse( return convertOpenAINonStreamingToClaude(toRecord(intermediateOpenAI)); } + // Gemini-family clients (Gemini, Antigravity): the streaming SSE path already + // projects OpenAI chunks into the `{ response: { candidates: [...] } }` envelope + // via the registered FORMATS.OPENAI -> FORMATS.ANTIGRAVITY translator + // (translator/response/openai-to-antigravity.ts), but this non-streaming path had + // no equivalent back-conversion step — it silently returned the raw OpenAI + // chat.completion shape (leaking `choices[]`/`tool_calls` instead of + // `candidates[]`/`functionCall`) to any non-streaming Gemini/Antigravity client. + if ( + (sourceFormat === FORMATS.GEMINI || sourceFormat === FORMATS.ANTIGRAVITY) && + sourceFormat !== targetFormat + ) { + return convertOpenAINonStreamingToGeminiFamily(toRecord(intermediateOpenAI)); + } + // Return intermediateOpenAI (which is either the raw response if unknown targetFormat, or an OpenAI compatible payload) return intermediateOpenAI; } @@ -664,3 +678,92 @@ function convertOpenAINonStreamingToClaude(openaiResponse: JsonRecord): JsonReco return claudeResponse; } + +const OPENAI_TO_GEMINI_FINISH_REASON: Record = { + stop: "STOP", + length: "MAX_TOKENS", + tool_calls: "STOP", + content_filter: "SAFETY", +}; + +/** + * Parse an OpenAI tool-call `arguments` payload into a Gemini `functionCall.args` + * object. Never throws: a provider emitting malformed/truncated JSON must not take + * down the whole non-streaming response path, so an unparseable payload degrades to + * `{}` (matching the streaming Gemini translator's behaviour). + */ +function parseFunctionCallArgs(args: unknown): Record { + if (typeof args !== "string") return toRecord(args); + try { + return toRecord(JSON.parse(args || "{}")); + } catch { + return {}; + } +} + +/** + * Helper to convert an OpenAI chat.completion JSON object into the Gemini/Antigravity + * `{ response: { candidates: [...] } }` envelope for non-streaming clients. Mirrors the + * shape already produced for streaming by the registered + * FORMATS.OPENAI -> FORMATS.ANTIGRAVITY translator + * (translator/response/openai-to-antigravity.ts) so both paths agree. + */ +function convertOpenAINonStreamingToGeminiFamily(openaiResponse: JsonRecord): JsonRecord { + const choices = openaiResponse.choices as unknown[] | undefined; + const isChoicesArray = Array.isArray(choices); + if (!isChoicesArray && openaiResponse.object !== "chat.completion") { + return openaiResponse; // If it doesn't look like OpenAI, return as-is + } + + const choice = isChoicesArray ? toRecord(choices[0]) : {}; + const messageObj = toRecord(choice.message); + + const parts: JsonRecord[] = []; + const reasoningText = resolveReasoningText(messageObj); + if (reasoningText) { + parts.push({ text: reasoningText, thought: true }); + } + if (typeof messageObj.content === "string" && messageObj.content.length > 0) { + parts.push({ text: messageObj.content }); + } + const toolCalls = Array.isArray(messageObj.tool_calls) ? messageObj.tool_calls : []; + for (const toolCall of toolCalls) { + const toolObj = toRecord(toolCall); + const fn = toRecord(toolObj.function); + parts.push({ + functionCall: { + name: toString(fn.name), + args: parseFunctionCallArgs(fn.arguments), + }, + }); + } + if (parts.length === 0) parts.push({ text: "" }); + + const finishReason = + OPENAI_TO_GEMINI_FINISH_REASON[toString(choice.finish_reason, "stop")] ?? "STOP"; + + const usageSrc = toRecord(openaiResponse.usage); + const promptTokens = toNumber(usageSrc.prompt_tokens, 0); + const completionTokens = toNumber(usageSrc.completion_tokens, 0); + + const geminiResponse: JsonRecord = { + response: { + candidates: [ + { + content: { role: "model", parts }, + finishReason, + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: promptTokens, + candidatesTokenCount: completionTokens, + totalTokenCount: toNumber(usageSrc.total_tokens, promptTokens + completionTokens), + }, + modelVersion: toString(openaiResponse.model, "unknown"), + responseId: toString(openaiResponse.id, `resp_${Date.now()}`), + }, + }; + + return geminiResponse; +} diff --git a/tests/unit/nonstreaming-gemini-family-response-projection.test.ts b/tests/unit/nonstreaming-gemini-family-response-projection.test.ts new file mode 100644 index 0000000000..e495df853c --- /dev/null +++ b/tests/unit/nonstreaming-gemini-family-response-projection.test.ts @@ -0,0 +1,165 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { FORMATS } from "../../open-sse/translator/formats.ts"; +import { translateNonStreamingResponse } from "../../open-sse/handlers/responseTranslator.ts"; + +interface GeminiFamilyPart { + text?: string; + thought?: boolean; + functionCall?: { name: string; args: Record }; +} + +interface GeminiFamilyResponse { + choices?: unknown; + response?: { + candidates: Array<{ + content: { role: string; parts: GeminiFamilyPart[] }; + finishReason: string; + index: number; + }>; + usageMetadata: { + promptTokenCount: number; + candidatesTokenCount: number; + totalTokenCount: number; + }; + }; +} + +/** + * Regression guard for the projection drift ported from decolua/9router#2348. + * + * The streaming SSE path already projects an OpenAI-shaped chunk into the + * Antigravity/Gemini `{ response: { candidates: [...] } }` envelope via the + * registered `FORMATS.OPENAI -> FORMATS.ANTIGRAVITY` translator + * (open-sse/translator/response/openai-to-antigravity.ts). + * + * The non-streaming JSON path (`/v1/antigravity` with `stream:false`, or any + * combo target whose provider speaks a different wire format than the + * client) goes through `translateNonStreamingResponse` instead — a + * hand-rolled function whose "Phase 3: translate back to client format" step + * only special-cases FORMATS.CLAUDE. For every other non-OpenAI client format + * (Gemini, Antigravity) it silently falls through and returns the raw OpenAI + * chat.completion shape, leaking `choices[]`/`tool_calls` instead of the + * client's expected `candidates[]`/`functionCall` envelope — the exact + * "leaks OpenAI format to non-OpenAI clients, function calls dropped" bug + * class from the upstream report. + */ +test("translateNonStreamingResponse projects an OpenAI provider payload back to the Antigravity/Gemini envelope for antigravity clients", () => { + const openAICompletion = { + id: "chatcmpl-1", + object: "chat.completion", + created: 1700000000, + model: "gpt-4o", + choices: [ + { + index: 0, + message: { + role: "assistant", + content: "", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "lookup", arguments: '{"q":"x"}' }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + usage: { prompt_tokens: 3, completion_tokens: 5, total_tokens: 8 }, + }; + + const translated = translateNonStreamingResponse( + openAICompletion, + FORMATS.OPENAI, + FORMATS.ANTIGRAVITY + ) as GeminiFamilyResponse; + + // The Antigravity/Gemini client expects a `{ response: { candidates: [...] } }` + // envelope with `functionCall` parts, never a raw OpenAI `choices[]`/`tool_calls` + // shape. + assert.ok( + translated?.response?.candidates, + `expected {response:{candidates:[...]}} envelope for an antigravity client, got: ${JSON.stringify(translated)}` + ); + assert.equal(translated.choices, undefined); + + const candidate = translated.response!.candidates[0]; + assert.equal(candidate.content.role, "model"); + assert.deepEqual(candidate.content.parts[0].functionCall, { + name: "lookup", + args: { q: "x" }, + }); + assert.equal(candidate.finishReason, "STOP"); + assert.equal(translated.response!.usageMetadata.totalTokenCount, 8); +}); + +test("translateNonStreamingResponse projects a Claude provider payload back to the Gemini envelope for gemini clients", () => { + const claudeMessage = { + id: "msg_1", + type: "message", + role: "assistant", + model: "claude-sonnet", + content: [ + { type: "thinking", thinking: "reasoning trace" }, + { type: "text", text: "final answer" }, + ], + stop_reason: "end_turn", + stop_sequence: null, + usage: { input_tokens: 4, output_tokens: 6 }, + }; + + const translated = translateNonStreamingResponse( + claudeMessage, + FORMATS.CLAUDE, + FORMATS.GEMINI + ) as GeminiFamilyResponse; + + assert.ok( + translated?.response?.candidates, + `expected {response:{candidates:[...]}} envelope for a gemini client, got: ${JSON.stringify(translated)}` + ); + const parts = translated.response!.candidates[0].content.parts; + assert.deepEqual( + parts.find((p) => p.thought === true), + { text: "reasoning trace", thought: true } + ); + assert.ok(parts.some((p) => p.text === "final answer")); +}); + +test("translateNonStreamingResponse degrades malformed tool-call arguments to {} instead of throwing", () => { + // A provider emitting truncated/invalid JSON in `arguments` must not take down the + // whole non-streaming response path with an uncaught SyntaxError. + const openAICompletion = { + id: "chatcmpl-2", + object: "chat.completion", + created: 1700000000, + model: "gpt-4o", + choices: [ + { + index: 0, + message: { + role: "assistant", + content: "", + tool_calls: [ + { id: "call_1", type: "function", function: { name: "lookup", arguments: '{"q":' } }, + ], + }, + finish_reason: "tool_calls", + }, + ], + }; + + const translated = translateNonStreamingResponse( + openAICompletion, + FORMATS.OPENAI, + FORMATS.GEMINI + ) as GeminiFamilyResponse; + + assert.deepEqual(translated.response!.candidates[0].content.parts[0].functionCall, { + name: "lookup", + args: {}, + }); +});