fix(agent-bridge): unwrap cloudcode-pa .request envelope for Antigravity IDE (#4294) (#5267)

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-28 20:21:48 -03:00
committed by GitHub
parent 2b0c129ab6
commit 598bebae26
3 changed files with 99 additions and 4 deletions

View File

@@ -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/<model>: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)

View File

@@ -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/<model>: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;

View File

@@ -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<string, unknown>,
"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(),