From 598bebae264170aaaa0d51b071ccf2fba4b03147 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 28 Jun 2026 20:21:48 -0300 Subject: [PATCH] fix(agent-bridge): unwrap cloudcode-pa .request envelope for Antigravity IDE (#4294) (#5267) --- CHANGELOG.md | 1 + src/mitm/handlers/antigravity.ts | 40 +++++++++++-- tests/unit/mitm-handler-antigravity.test.ts | 62 +++++++++++++++++++++ 3 files changed, 99 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd2b933587..2a9e29fbc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ _In development — bullets added per PR; finalized at release._ ### 🔧 Bug Fixes +- **agent-bridge (antigravity):** unwrap the cloudcode-pa `.request` envelope when converting Antigravity IDE requests. The real IDE sends `cloudcode-pa.googleapis.com/v1internal:generateContent` with the Gemini request nested under `.request` (`{ project, model, request: { contents, systemInstruction, generationConfig } }`), but the bridge read those fields at the top level — yielding an empty conversation, so prompts hung mid-execution. The legacy `/v1beta/models/:generateContent` top-level shape still works (#4294 — thanks @shabeer) - **dashboard:** add a GitHub releases fallback to the "Update Available" lookup. After the v3.8.28 fix added an npm-registry HTTP fallback, the banner could still stay hidden on networks that reach GitHub (where the news feed already loads) but not `registry.npmjs.org`. `resolveLatestVersion()` now tries npm CLI → npm registry → GitHub releases (`/repos/diegosouzapw/OmniRoute/releases/latest`) before giving up, and logs a warning only when all three fail (#4100) - **command-code:** omit `max_tokens` when the client omits it so the upstream applies the model's native default, fixing `400 "expected <=200000"` on `/alpha/generate` for high-cap models; an explicit oversized client value is clamped to the 200k endpoint ceiling (#5221 — thanks @adivekar-utexas) - **combo:** wire session stickiness into the round-robin dispatch path. Multi-turn conversations from clients that send no session id (Codex CLI, Claude Code, most OpenAI-compatible tools) were rotated to a different connection on every turn by round-robin combos, busting the upstream prompt-cache → cold high-reasoning starts, intermittent `504`s and throughput collapse under concurrency. The weighted/priority paths already honored per-conversation stickiness; the round-robin handler returned before reaching it. Round-robin now starts the rotation at the conversation's sticky connection (failover to the other targets is preserved), and different conversations still spread across connections — only intra-conversation rotation is removed (#3825 — thanks @bypanghu, @jpsn123, @xz-dev) diff --git a/src/mitm/handlers/antigravity.ts b/src/mitm/handlers/antigravity.ts index e89cafd6ff..d2aa17a181 100644 --- a/src/mitm/handlers/antigravity.ts +++ b/src/mitm/handlers/antigravity.ts @@ -43,9 +43,37 @@ interface GeminiRequestBody { systemInstruction?: GeminiContent; contents?: GeminiContent[]; generationConfig?: GeminiGenerationConfig; + /** + * Antigravity IDE talks to `cloudcode-pa.googleapis.com/v1internal:generateContent`, + * whose envelope nests the real Gemini request one level down: + * `{ project, model, userAgent, requestType, request: { contents, systemInstruction, + * generationConfig, … } }` + * (see `open-sse/translator/request/antigravity-to-openai.ts`). The legacy + * `/v1beta/models/:generateContent` path instead carries those fields at the top + * level. We must read whichever level actually holds the conversation (#4294). + */ + request?: GeminiRequestBody; [key: string]: unknown; } +/** + * Return the object that actually holds the Gemini conversation fields. Antigravity's + * cloudcode-pa envelope wraps them under `.request`; the legacy `/v1beta` path puts them at + * the top level. Without this unwrap, a real Antigravity request yields zero messages, so + * the upstream gets an empty conversation and the IDE prompt hangs (#4294). + */ +function resolveGeminiSource(body: GeminiRequestBody): GeminiRequestBody { + const inner = body.request; + if ( + inner && + typeof inner === "object" && + ("contents" in inner || "systemInstruction" in inner || "generationConfig" in inner) + ) { + return inner; + } + return body; +} + interface OpenAIChatMessage { role: "system" | "user" | "assistant"; content: string; @@ -81,16 +109,20 @@ export function convertGeminiToOpenAI( model: string, stream: boolean, ): OpenAIChatBody { + // Unwrap the cloudcode-pa envelope (`.request`) used by the real Antigravity IDE; fall + // back to the top level for the legacy `/v1beta` shape. (#4294) + const src = resolveGeminiSource(geminiBody); + const messages: OpenAIChatMessage[] = []; // System instruction - if (geminiBody.systemInstruction) { - const systemText = joinPartsText(geminiBody.systemInstruction.parts); + if (src.systemInstruction) { + const systemText = joinPartsText(src.systemInstruction.parts); if (systemText) messages.push({ role: "system", content: systemText }); } // Chat turns - for (const content of geminiBody.contents || []) { + for (const content of src.contents || []) { const role: OpenAIChatMessage["role"] = content.role === "model" ? "assistant" : "user"; messages.push({ role, content: joinPartsText(content.parts) }); } @@ -101,7 +133,7 @@ export function convertGeminiToOpenAI( stream: !!stream, }; - const cfg = geminiBody.generationConfig || {}; + const cfg = src.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; diff --git a/tests/unit/mitm-handler-antigravity.test.ts b/tests/unit/mitm-handler-antigravity.test.ts index 97ba57c061..ec3265d365 100644 --- a/tests/unit/mitm-handler-antigravity.test.ts +++ b/tests/unit/mitm-handler-antigravity.test.ts @@ -97,6 +97,68 @@ test("antigravity handler — converts raw Gemini body before forwarding", async assert.equal(forwarded.thinkingConfig, undefined); }); +test("convertGeminiToOpenAI — unwraps the cloudcode-pa `.request` envelope (#4294)", () => { + // Shape the real Antigravity IDE sends to cloudcode-pa /v1internal:generateContent. + const out = convertGeminiToOpenAI( + { + project: "projects/123", + model: "gemini-3-pro", + userAgent: "Antigravity", + requestType: "GENERATE", + request: { + systemInstruction: { parts: [{ text: "be brief" }] }, + contents: [ + { role: "user", parts: [{ text: "hello" }] }, + { role: "model", parts: [{ text: "hi there" }] }, + ], + generationConfig: { maxOutputTokens: 256, temperature: 0.4 }, + }, + } as Record, + "ag-claude-opus-4-6-thinking", + true + ); + + assert.equal(out.model, "ag-claude-opus-4-6-thinking"); + // Without the unwrap these would be empty → upstream gets an empty conversation → hang. + 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); +}); + +test("antigravity handler — forwards a cloudcode envelope request with real messages (#4294)", async () => { + const r = await runHandler( + new AntigravityHandler(), + { + project: "projects/123", + model: "gemini-3-pro", + request: { + contents: [{ role: "user", parts: [{ text: "ping" }] }], + generationConfig: { maxOutputTokens: 64 }, + }, + }, + "ag-claude-opus-4-6-thinking", + { + upstreamBody: "data: pong\n\n", + url: "/v1internal:streamGenerateContent", + } + ); + + assert.ok(r.fetchCalled); + const forwarded = JSON.parse(r.fetchBody); + assert.equal(forwarded.model, "ag-claude-opus-4-6-thinking"); + assert.equal(forwarded.stream, true); + // The prompt must survive the conversion (the hang was an empty messages array). + assert.deepEqual(forwarded.messages, [{ role: "user", content: "ping" }]); + assert.equal(forwarded.max_tokens, 64); + // Envelope wrapper fields must not leak into the OpenAI body. + assert.equal(forwarded.request, undefined); + assert.equal(forwarded.project, undefined); +}); + test("antigravity handler — non-streaming URL yields stream:false", async () => { const r = await runHandler( new AntigravityHandler(),