fix(sse): project non-streaming JSON back to the Gemini/Antigravity envelope (#7255)

* fix(sse): project non-streaming JSON back to the Gemini/Antigravity envelope

The streaming and non-streaming response paths disagreed on how a response is
projected back into a non-OpenAI client's wire format.

Streaming goes through the translator registry, where the
FORMATS.OPENAI -> FORMATS.ANTIGRAVITY translator
(open-sse/translator/response/openai-to-antigravity.ts) projects each OpenAI
chunk into the `{ response: { candidates: [...] } }` envelope, mapping
tool_calls to `functionCall` parts and reasoning to `thought` parts.

The non-streaming path uses translateNonStreamingResponse() instead. Its
"Phase 3: translate back to client source format" step only special-cased
FORMATS.CLAUDE — every other non-OpenAI client format fell through and returned
the raw OpenAI chat.completion intermediate. A Gemini/Antigravity client issuing
a non-streaming request therefore received `choices[]`/`tool_calls` instead of
`candidates[]`/`functionCall`: the client's parser sees no candidates and the
function calls are effectively dropped, so tool-calling silently breaks on the
JSON path while working over SSE.

Adds convertOpenAINonStreamingToGeminiFamily() and wires it into Phase 3 for
FORMATS.GEMINI / FORMATS.ANTIGRAVITY, mirroring the shape the streaming
translator already emits so both paths agree. Tool-call `arguments` are parsed
through a non-throwing helper: a provider emitting truncated JSON degrades that
call's args to `{}` rather than raising an uncaught SyntaxError in the shared
response hot path (matching the streaming translator's behaviour).

Scoped deliberately narrow: only the Gemini-family projection gap proven by the
failing test is closed. The Ollama/Responses projections and the SSE terminal
tracker from the upstream change are not ported — OmniRoute has no OLLAMA format
in FORMATS, and its Responses/[DONE] handling already lives in
nonStreamingSse.ts + the registry.

Co-authored-by: W ARELIK <warelik@users.noreply.github.com>
Inspired-by: https://github.com/decolua/9router/pull/2348

* chore(changelog): fragment for #7255

---------

Co-authored-by: W ARELIK <warelik@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-17 10:41:06 -03:00
committed by GitHub
parent 4387ee86e0
commit 98966fdac9
3 changed files with 269 additions and 0 deletions

View File

@@ -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)

View File

@@ -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<string, string> = {
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<string, unknown> {
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;
}

View File

@@ -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<string, unknown> };
}
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: {},
});
});