From 0ab1876008cdeeb0bd9b842a6fd9250c2bdeadab Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:01:14 -0300 Subject: [PATCH] feat(mitm): translate Antigravity cloudcode end-to-end (Gap B) (#4299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Antigravity IDE speaks cloudcode (the Gemini payload wrapped under `request`) and expects a cloudcode reply ({response:{candidates}}). The AgentBridge proxy forwarded that envelope verbatim to /v1/chat/completions (OpenAI), which 400s on the missing `messages` field — so the IDE could be decrypted/intercepted but never actually routed to a provider. Wire the inbound cloudcode path, reusing the already-registered bidirectional translators (no new translators needed): - provider.ts: detectFormatFromEndpoint classifies the /antigravity path as sourceFormat "antigravity" (mirrors /messages -> claude), so the pipeline translates request antigravity->openai and response openai->antigravity. - /v1/antigravity route (new): cloudcode-compatible endpoint — just calls handleChat (mirrors /v1/messages). - server.cjs: routes cloudcode envelopes to /v1/antigravity (translates both ways) and plain OpenAI bodies to /v1/chat/completions, via a testable shim. Tests: forward-target shim (cloudcode vs openai routing) + endpoint format detection. The antigravity<->openai translators are already covered by translator-antigravity-to-openai / translator-resp-openai-to-antigravity. Stacked on #4285 (Gap A). Full Antigravity-IDE e2e validates on the next standalone deploy (provider.ts + the route compile into .next). --- open-sse/services/provider.ts | 9 +++ src/app/api/v1/antigravity/route.ts | 47 ++++++++++++++++ src/mitm/_internal/forwardTarget.cjs | 55 +++++++++++++++++++ src/mitm/server.cjs | 12 +++- .../unit/antigravity-format-detection.test.ts | 24 ++++++++ tests/unit/mitm-forward-target.test.ts | 45 +++++++++++++++ 6 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 src/app/api/v1/antigravity/route.ts create mode 100644 src/mitm/_internal/forwardTarget.cjs create mode 100644 tests/unit/antigravity-format-detection.test.ts create mode 100644 tests/unit/mitm-forward-target.test.ts diff --git a/open-sse/services/provider.ts b/open-sse/services/provider.ts index 6b62bea92a..e269c85863 100644 --- a/open-sse/services/provider.ts +++ b/open-sse/services/provider.ts @@ -96,6 +96,15 @@ export function detectFormatFromEndpoint(body, endpointPath = "") { return "claude"; } + // Antigravity/cloudcode-compatible inbound endpoint (D4): the AgentBridge + // proxy forwards the IDE's cloudcode envelope here. Path-based detection + // (mirrors /messages → claude) makes the pipeline translate the request + // antigravity→openai and the response openai→antigravity, so the IDE gets + // a cloudcode reply regardless of which provider actually served it. + if (/\/antigravity(?=\/|:|$)/i.test(path) || /^antigravity(?=\/|:|$)/i.test(path)) { + return "antigravity"; + } + if ( /\/(?:chat\/completions|completions)(?=\/|$)/i.test(path) || /^(?:chat\/completions|completions)(?=\/|$)/i.test(path) diff --git a/src/app/api/v1/antigravity/route.ts b/src/app/api/v1/antigravity/route.ts new file mode 100644 index 0000000000..89f12036ae --- /dev/null +++ b/src/app/api/v1/antigravity/route.ts @@ -0,0 +1,47 @@ +import { handleChat } from "@/sse/handlers/chat"; +import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; + +let initialized = false; + +/** + * Initialize translators once. + */ +async function ensureInitialized() { + if (!initialized) { + await initTranslators(); + initialized = true; + console.log("[SSE] Translators initialized for /v1/antigravity"); + } +} + +/** + * Handle CORS preflight. + */ +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "*", + }, + }); +} + +/** + * POST /v1/antigravity — Antigravity/cloudcode-compatible endpoint. + * + * Accepts the Antigravity IDE's cloudcode envelope + * { model, project, request: { contents, systemInstruction, tools, generationConfig } } + * and returns a cloudcode SSE reply + * { response: { candidates: [...], usageMetadata } }. + * + * `detectFormatFromEndpoint()` classifies the `/antigravity` path as + * sourceFormat "antigravity" (mirrors `/v1/messages` → claude), so `handleChat` + * translates the request antigravity→openai, routes it to the resolved + * provider/model, and translates the response openai→antigravity — reusing the + * already-registered bidirectional translators. The AgentBridge MITM proxy + * (`server.cjs`) forwards the IDE's intercepted cloudcode request here. + */ +export async function POST(request: Request): Promise { + await ensureInitialized(); + return await handleChat(request); +} diff --git a/src/mitm/_internal/forwardTarget.cjs b/src/mitm/_internal/forwardTarget.cjs new file mode 100644 index 0000000000..269f94f3a8 --- /dev/null +++ b/src/mitm/_internal/forwardTarget.cjs @@ -0,0 +1,55 @@ +"use strict"; + +// ========================================================================= +// Decide where the standalone proxy (server.cjs) forwards an intercepted, +// decrypted request — and which router endpoint speaks its wire format. +// +// The Antigravity IDE sends a cloudcode envelope: +// { model, project, request: { contents, systemInstruction, tools, ... } } +// and expects a cloudcode SSE reply ({ response: { candidates, ... } }). +// That envelope is forwarded to the antigravity-compatible endpoint +// (/v1/antigravity), where the pipeline translates request antigravity→openai +// and response openai→antigravity, so the IDE gets its own format back +// regardless of which provider actually served it. +// +// Plain OpenAI bodies ({ messages: [...] }) keep going to /v1/chat/completions. +// +// Pure + deterministic so it is unit-testable without the proxy. +// ========================================================================= + +const CHAT_PATH = "/v1/chat/completions"; +const ANTIGRAVITY_PATH = "/v1/antigravity"; + +/** + * True when the parsed body is an Antigravity/cloudcode envelope (the IDE wraps + * the Gemini-style payload under `request`, with a `contents` array). + */ +function isCloudcodeEnvelope(body) { + return !!( + body && + typeof body === "object" && + !Array.isArray(body) && + body.request && + typeof body.request === "object" && + Array.isArray(body.request.contents) + ); +} + +/** + * Resolve the forward URL + the format the IDE expects back. `baseUrl` is the + * router base (e.g. http://localhost:20128); trailing slashes are trimmed. + */ +function resolveForwardTarget(baseUrl, body) { + const base = String(baseUrl || "").replace(/\/+$/, ""); + if (isCloudcodeEnvelope(body)) { + return { url: `${base}${ANTIGRAVITY_PATH}`, format: "antigravity" }; + } + return { url: `${base}${CHAT_PATH}`, format: "openai" }; +} + +module.exports = { + resolveForwardTarget, + isCloudcodeEnvelope, + CHAT_PATH, + ANTIGRAVITY_PATH, +}; diff --git a/src/mitm/server.cjs b/src/mitm/server.cjs index cb86fe6228..8be6ad1b76 100644 --- a/src/mitm/server.cjs +++ b/src/mitm/server.cjs @@ -135,6 +135,7 @@ function sanitizeErrorMessage(message) { const bypassShim = require("./_internal/bypass.cjs"); const ingestShim = require("./_internal/ingest.cjs"); +const forwardShim = require("./_internal/forwardTarget.cjs"); // Inspector capture (D4 fallback). The standalone proxy intercepts AgentBridge // traffic inline (no MitmHandlerBase / agentBridgeHook), so it posts captured @@ -470,8 +471,17 @@ async function intercept(req, res, bodyBuffer, mappedModel, sourceModel) { const body = JSON.parse(bodyBuffer.toString()); body.model = mappedModel; + // Gap B — the Antigravity IDE speaks cloudcode (the Gemini payload wrapped + // under `request`) and expects a cloudcode reply. Forward such envelopes to + // the antigravity-compatible endpoint (which translates both directions) so + // the IDE gets its own format back; plain OpenAI bodies still go to + // chat/completions. Without this, cloudcode hits chat/completions and 400s + // on the missing `messages` field. + const forward = forwardShim.resolveForwardTarget(ROUTER_BASE_URL, body); + vlog(1, `[MITM] → forward ${forward.format} ${forward.url}`); + upstreamStartedAt = Date.now(); - const response = await fetch(ROUTER_URL, { + const response = await fetch(forward.url, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/tests/unit/antigravity-format-detection.test.ts b/tests/unit/antigravity-format-detection.test.ts new file mode 100644 index 0000000000..45ef14021f --- /dev/null +++ b/tests/unit/antigravity-format-detection.test.ts @@ -0,0 +1,24 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { detectFormatFromEndpoint } from "@omniroute/open-sse/services/provider.ts"; + +const ENVELOPE = { + model: "gemini-2.5-pro", + project: "projects/test", + request: { contents: [{ role: "user", parts: [{ text: "oi" }] }] }, +}; + +test("detectFormatFromEndpoint classifies the /v1/antigravity path as antigravity", () => { + assert.equal(detectFormatFromEndpoint(ENVELOPE, "/v1/antigravity"), "antigravity"); + assert.equal(detectFormatFromEndpoint(ENVELOPE, "/api/v1/antigravity"), "antigravity"); + assert.equal( + detectFormatFromEndpoint(ENVELOPE, "/v1/antigravity:streamGenerateContent"), + "antigravity" + ); +}); + +test("the antigravity carve-out does not disturb the other endpoint formats", () => { + assert.equal(detectFormatFromEndpoint({ messages: [] }, "/v1/messages"), "claude"); + assert.equal(detectFormatFromEndpoint({ messages: [] }, "/v1/chat/completions"), "openai"); + assert.equal(detectFormatFromEndpoint({ input: "x" }, "/v1/responses"), "openai-responses"); +}); diff --git a/tests/unit/mitm-forward-target.test.ts b/tests/unit/mitm-forward-target.test.ts new file mode 100644 index 0000000000..5c5ace48ed --- /dev/null +++ b/tests/unit/mitm-forward-target.test.ts @@ -0,0 +1,45 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { resolveForwardTarget, isCloudcodeEnvelope, CHAT_PATH, ANTIGRAVITY_PATH } = require( + "../../src/mitm/_internal/forwardTarget.cjs" +); + +const BASE = "http://localhost:20128"; + +test("cloudcode envelope routes to the antigravity endpoint", () => { + const body = { + model: "gemini-2.5-pro", + project: "projects/test", + request: { contents: [{ role: "user", parts: [{ text: "oi" }] }] }, + }; + assert.equal(isCloudcodeEnvelope(body), true); + const t = resolveForwardTarget(BASE, body); + assert.equal(t.url, `${BASE}${ANTIGRAVITY_PATH}`); + assert.equal(t.format, "antigravity"); +}); + +test("plain OpenAI body routes to chat/completions", () => { + const body = { model: "gemini-2.5-pro", messages: [{ role: "user", content: "oi" }] }; + assert.equal(isCloudcodeEnvelope(body), false); + const t = resolveForwardTarget(BASE, body); + assert.equal(t.url, `${BASE}${CHAT_PATH}`); + assert.equal(t.format, "openai"); +}); + +test("base url trailing slash is trimmed", () => { + const t = resolveForwardTarget("http://localhost:20128/", { messages: [] }); + assert.equal(t.url, `http://localhost:20128${CHAT_PATH}`); +}); + +test("non-envelope shapes are not misclassified as cloudcode", () => { + // request present but contents missing / wrong type + assert.equal(isCloudcodeEnvelope({ request: {} }), false); + assert.equal(isCloudcodeEnvelope({ request: { contents: "nope" } }), false); + assert.equal(isCloudcodeEnvelope({ contents: [{ parts: [] }] }), false); // bare gemini, no envelope + assert.equal(isCloudcodeEnvelope(null), false); + assert.equal(isCloudcodeEnvelope([]), false); + assert.equal(isCloudcodeEnvelope("string"), false); +});