From 2b0e1dfd941894f95a9b99a991b8c7a3539dddd4 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 26 Jun 2026 22:07:04 -0300 Subject: [PATCH] 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) --- CHANGELOG.md | 1 + src/mitm/handlers/antigravity.ts | 114 ++++++++++++++++++-- tests/unit/_mitmHandlerHarness.ts | 10 +- tests/unit/mitm-handler-antigravity.test.ts | 82 +++++++++++++- 4 files changed, 196 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb5eb0d340..54e3b9f260 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/mitm/handlers/antigravity.ts b/src/mitm/handlers/antigravity.ts index 02deb6e907..e89cafd6ff 100644 --- a/src/mitm/handlers/antigravity.ts +++ b/src/mitm/handlers/antigravity.ts @@ -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); diff --git a/tests/unit/_mitmHandlerHarness.ts b/tests/unit/_mitmHandlerHarness.ts index 7d5a4c15b7..96b2f02bb4 100644 --- a/tests/unit/_mitmHandlerHarness.ts +++ b/tests/unit/_mitmHandlerHarness.ts @@ -19,10 +19,13 @@ export interface HarnessResult { responseChunks: string[]; } -function fakeReq(headers: Record = {}): IncomingMessage { +function fakeReq( + headers: Record = {}, + 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; + url?: string; } = {} ): Promise { 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; diff --git a/tests/unit/mitm-handler-antigravity.test.ts b/tests/unit/mitm-handler-antigravity.test.ts index 2a6f32e723..97ba57c061 100644 --- a/tests/unit/mitm-handler-antigravity.test.ts +++ b/tests/unit/mitm-handler-antigravity.test.ts @@ -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, + "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).contents, undefined); + assert.equal((out as Record).generationConfig, undefined); + assert.equal((out as Record).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); +});