mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 13:42:09 +03:00
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:
committed by
GitHub
parent
25b65ac34a
commit
2b0e1dfd94
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user