fix(sse): convert Gemini body to OpenAI format in antigravity MITM handler (#4845)

Integrated into release/v3.8.38 (rebased on tip, CHANGELOG re-injected)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-26 22:07:04 -03:00
committed by GitHub
parent 25b65ac34a
commit 2b0e1dfd94
4 changed files with 196 additions and 11 deletions

View File

@@ -22,6 +22,7 @@ _In development — bullets added per PR; finalized at release._
### 🔧 Bug Fixes
- **fix(sse): convert the native Gemini request body to OpenAI format in the Antigravity MITM handler**`contents` / `systemInstruction` / `generationConfig` / `thinkingConfig` are now translated to OpenAI chat-completions format before forwarding to `/v1/chat/completions`, so thinking-capable models (e.g. `ag/claude-opus-4-6-thinking`) no longer fail with provider-side 400 "invalid argument" errors. ([#4845](https://github.com/diegosouzapw/OmniRoute/pull/4845) — thanks @anuragg-saxenaa)
- **fix(db): translate the two pt-BR SQLite driver-fallback log lines to English**`[DB] Pré-inicializando sql.js WASM…` and `[DB] Drivers síncronos indisponíveis…` were the only non-English server log strings, mixing languages in the logs. Now `[DB] Pre-initializing sql.js WASM (synchronous drivers unavailable)…` / `[DB] Synchronous drivers unavailable — falling back to sql.js (WASM)`, guarded by a test that scans the driver path for accented log strings. ([#5103](https://github.com/diegosouzapw/OmniRoute/issues/5103))
- **fix(diagnostics): non-streaming Claude responses no longer false-502 as `empty_choices`** — the v3.8.37 malformed-200 detector (#4942) only understood OpenAI `choices` and Responses-API `output` shapes, so a `/v1/messages` response that stays in Claude shape (`{type:"message", content:[…]}`) fell through to `empty_choices` → 502 (cascading to "All models failed" in a combo). Most visibly, an extended-thinking turn whose buffered body is a single **empty thinking block with a valid `signature`** (Claude Code's non-streaming Bash classifier) 502'd on every call. `detectMalformedNonStream` now understands the Claude shape: text/tool_use blocks and thinking blocks carrying a signature count as valid output, while a genuinely empty `content:[]` is still flagged. ([#5108](https://github.com/diegosouzapw/OmniRoute/issues/5108), thanks @insoln)
- **fix(combo): empty-content 502 now fails over within the same request instead of exhausting the provider** — a leg that answers HTTP 200 with no usable completion is rewritten to `502 "Provider returned empty content"`, but the combo exhaustion classifier treated that synthetic 502 as a connection-level failure (`#1731v2`) and marked the whole provider/connection exhausted, skipping every remaining **same-provider** leg in that request. The connection is actually healthy (it just returned an empty body), so empty-content 502s are now classified as model-level transient failures: the request advances to the next leg and the rest of that provider's legs stay eligible. Genuine gateway 502s still trip connection exhaustion. ([#5085](https://github.com/diegosouzapw/OmniRoute/issues/5085), thanks @andrea-kingautomation)

View File

@@ -1,11 +1,20 @@
/**
* Antigravity IDE handler.
*
* Preserves the historical behavior of `src/mitm/server.cjs::intercept()`:
* - parses the incoming JSON body,
* - replaces `body.model` with the mapped model,
* - forwards to `/v1/chat/completions` on the OmniRoute router,
* - pipes the SSE response back to the IDE.
* Antigravity (the Gemini-based IDE) sends requests in native Gemini
* GenerateContent format (`contents`, `systemInstruction`, `generationConfig`,
* `thinkingConfig`, …). The OmniRoute router endpoint `/v1/chat/completions`
* expects OpenAI Chat Completions format, so the raw Gemini body must be
* converted before forwarding — otherwise the unknown fields are either
* ignored or cause upstream providers to return a 400 "invalid argument"
* error (especially with thinking-capable models such as
* `ag/claude-opus-4-6-thinking`).
*
* Pipeline:
* - parse the incoming Gemini JSON body,
* - convert it to an OpenAI chat.completions body (model = mapped model),
* - forward to `/v1/chat/completions` on the OmniRoute router,
* - pipe the SSE response back to the IDE.
*
* Non-regressive: any change here must keep the Antigravity flow working as
* before (see `tests/unit/mitm-handler-antigravity.test.ts`).
@@ -14,6 +23,93 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import type { AgentId } from "../types";
import { MitmHandlerBase } from "./base";
interface GeminiPart {
text?: string;
}
interface GeminiContent {
role?: string;
parts?: GeminiPart[];
}
interface GeminiGenerationConfig {
maxOutputTokens?: number;
temperature?: number;
topP?: number;
stopSequences?: string[];
}
interface GeminiRequestBody {
systemInstruction?: GeminiContent;
contents?: GeminiContent[];
generationConfig?: GeminiGenerationConfig;
[key: string]: unknown;
}
interface OpenAIChatMessage {
role: "system" | "user" | "assistant";
content: string;
}
interface OpenAIChatBody {
model: string;
messages: OpenAIChatMessage[];
stream: boolean;
max_tokens?: number;
temperature?: number;
top_p?: number;
stop?: string[];
}
function joinPartsText(parts: GeminiPart[] | undefined): string {
return (parts || [])
.map((p) => p.text)
.filter((t): t is string => Boolean(t))
.join("\n");
}
/**
* Convert a Gemini GenerateContent request body to an OpenAI
* chat.completions body.
*
* @param geminiBody parsed Gemini request
* @param model resolved OmniRoute model string
* @param stream whether the original request was streaming
*/
export function convertGeminiToOpenAI(
geminiBody: GeminiRequestBody,
model: string,
stream: boolean,
): OpenAIChatBody {
const messages: OpenAIChatMessage[] = [];
// System instruction
if (geminiBody.systemInstruction) {
const systemText = joinPartsText(geminiBody.systemInstruction.parts);
if (systemText) messages.push({ role: "system", content: systemText });
}
// Chat turns
for (const content of geminiBody.contents || []) {
const role: OpenAIChatMessage["role"] = content.role === "model" ? "assistant" : "user";
messages.push({ role, content: joinPartsText(content.parts) });
}
const openaiBody: OpenAIChatBody = {
model,
messages,
stream: !!stream,
};
const cfg = geminiBody.generationConfig || {};
if (cfg.maxOutputTokens != null) openaiBody.max_tokens = cfg.maxOutputTokens;
if (cfg.temperature != null) openaiBody.temperature = cfg.temperature;
if (cfg.topP != null) openaiBody.top_p = cfg.topP;
if (cfg.stopSequences?.length) openaiBody.stop = cfg.stopSequences;
return openaiBody;
}
export class AntigravityHandler extends MitmHandlerBase {
readonly agentId: AgentId = "antigravity";
@@ -27,8 +123,12 @@ export class AntigravityHandler extends MitmHandlerBase {
const intercepted = await this.hookBufferStart(req, body, mappedModel);
try {
const payload = JSON.parse(body.toString());
payload.model = mappedModel;
const geminiBody = JSON.parse(body.toString()) as GeminiRequestBody;
// Streaming intent: Antigravity uses :streamGenerateContent for streaming.
const isStream = (req.url || "").includes(":streamGenerateContent");
const payload = convertGeminiToOpenAI(geminiBody, mappedModel, isStream);
const upstreamStart = this.now();
const upstream = await this.fetchRouter(payload, "/v1/chat/completions", req.headers);

View File

@@ -19,10 +19,13 @@ export interface HarnessResult {
responseChunks: string[];
}
function fakeReq(headers: Record<string, string> = {}): IncomingMessage {
function fakeReq(
headers: Record<string, string> = {},
url = "/v1/chat/completions"
): IncomingMessage {
return {
method: "POST",
url: "/v1/chat/completions",
url,
headers: {
host: "api.example.com",
"user-agent": "ut",
@@ -68,10 +71,11 @@ export async function runHandler(
upstreamStatus?: number;
upstreamBody?: string;
headers?: Record<string, string>;
url?: string;
} = {}
): Promise<HarnessResult> {
const { res, out } = fakeRes();
const req = fakeReq(opts.headers);
const req = fakeReq(opts.headers, opts.url);
const buf = Buffer.from(typeof body === "string" ? body : JSON.stringify(body));
const originalFetch = globalThis.fetch;

View File

@@ -1,6 +1,9 @@
import test from "node:test";
import assert from "node:assert/strict";
import { AntigravityHandler } from "../../src/mitm/handlers/antigravity.ts";
import {
AntigravityHandler,
convertGeminiToOpenAI,
} from "../../src/mitm/handlers/antigravity.ts";
import { runHandler } from "./_mitmHandlerHarness.ts";
test("antigravity handler — forwards to OmniRoute and pipes SSE", async () => {
@@ -27,3 +30,80 @@ test("antigravity handler — propagates upstream failure as 500", async () => {
// Error must NOT include raw stack trace (Hard Rule #12 sanitization).
assert.ok(!body.includes("at /"));
});
test("convertGeminiToOpenAI — maps Gemini fields to OpenAI chat body", () => {
const out = convertGeminiToOpenAI(
{
systemInstruction: { parts: [{ text: "be brief" }] },
contents: [
{ role: "user", parts: [{ text: "hello" }] },
{ role: "model", parts: [{ text: "hi there" }] },
],
generationConfig: {
maxOutputTokens: 256,
temperature: 0.4,
topP: 0.9,
stopSequences: ["STOP"],
},
// Gemini-only field that must NOT leak into the OpenAI body.
thinkingConfig: { thinkingBudget: 1024 },
} as Record<string, unknown>,
"claude-opus-4-6-thinking",
true
);
assert.equal(out.model, "claude-opus-4-6-thinking");
assert.equal(out.stream, true);
assert.deepEqual(out.messages, [
{ role: "system", content: "be brief" },
{ role: "user", content: "hello" },
{ role: "assistant", content: "hi there" },
]);
assert.equal(out.max_tokens, 256);
assert.equal(out.temperature, 0.4);
assert.equal(out.top_p, 0.9);
assert.deepEqual(out.stop, ["STOP"]);
// Gemini-native fields must be stripped, not forwarded.
assert.equal((out as Record<string, unknown>).contents, undefined);
assert.equal((out as Record<string, unknown>).generationConfig, undefined);
assert.equal((out as Record<string, unknown>).thinkingConfig, undefined);
});
test("antigravity handler — converts raw Gemini body before forwarding", async () => {
const r = await runHandler(
new AntigravityHandler(),
{
contents: [{ role: "user", parts: [{ text: "ping" }] }],
generationConfig: { maxOutputTokens: 64 },
thinkingConfig: { thinkingBudget: 512 },
},
"ag-claude-opus-4-6-thinking",
{
upstreamBody: "data: pong\n\n",
url: "/v1beta/models/gemini:streamGenerateContent",
}
);
assert.ok(r.fetchCalled);
const forwarded = JSON.parse(r.fetchBody);
// The router must receive OpenAI format, not the raw Gemini body.
assert.equal(forwarded.model, "ag-claude-opus-4-6-thinking");
assert.equal(forwarded.stream, true);
assert.deepEqual(forwarded.messages, [{ role: "user", content: "ping" }]);
assert.equal(forwarded.max_tokens, 64);
// Gemini-native fields that caused upstream 400s must be gone.
assert.equal(forwarded.contents, undefined);
assert.equal(forwarded.generationConfig, undefined);
assert.equal(forwarded.thinkingConfig, undefined);
});
test("antigravity handler — non-streaming URL yields stream:false", async () => {
const r = await runHandler(
new AntigravityHandler(),
{ contents: [{ role: "user", parts: [{ text: "hi" }] }] },
"gpt-4o",
{ url: "/v1beta/models/gemini:generateContent" }
);
const forwarded = JSON.parse(r.fetchBody);
assert.equal(forwarded.stream, false);
});