Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
114 changes: 107 additions & 7 deletions src/mitm/handlers/antigravity.ts
Original file line number Diff line number Diff line change
@@ -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`).
Expand All @@ -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";

Expand All @@ -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);
Expand Down
10 changes: 7 additions & 3 deletions tests/unit/_mitmHandlerHarness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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;
Expand Down
82 changes: 81 additions & 1 deletion tests/unit/mitm-handler-antigravity.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand All @@ -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);
});
Loading