mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
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:
committed by
GitHub
parent
4387ee86e0
commit
98966fdac9
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user