mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 21:02:12 +03:00
feat(mitm): translate Antigravity cloudcode end-to-end (Gap B) (#4299)
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).
This commit is contained in:
committed by
GitHub
parent
915991c762
commit
0ab1876008
@@ -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)
|
||||
|
||||
47
src/app/api/v1/antigravity/route.ts
Normal file
47
src/app/api/v1/antigravity/route.ts
Normal file
@@ -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<Response> {
|
||||
await ensureInitialized();
|
||||
return await handleChat(request);
|
||||
}
|
||||
55
src/mitm/_internal/forwardTarget.cjs
Normal file
55
src/mitm/_internal/forwardTarget.cjs
Normal file
@@ -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,
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
24
tests/unit/antigravity-format-detection.test.ts
Normal file
24
tests/unit/antigravity-format-detection.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
45
tests/unit/mitm-forward-target.test.ts
Normal file
45
tests/unit/mitm-forward-target.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user