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); +});