From 62e6336aad243013cc95c2abb02db334d75bef47 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:20:05 -0300 Subject: [PATCH] fix(antigravity): alias agy gemini-3.1-pro -high/-low + stop masking upstream 4xx (#3229) (#3245) agy's gemini-3.1-pro-high/-low had no alias, so resolveAntigravityModelId sent the speculative -high/-low suffix verbatim to upstream, which rejects it (400) for gemini-3.x. Worse, the non-stream executor branch fed the 4xx response into the SSE collector, returning a synthetic empty {object:chat.completion} envelope that masked the error. Alias both to gemini-3.1-pro, and surface real upstream errors via buildErrorBody for non-ok non-stream responses. + unit tests. --- open-sse/config/antigravityModelAliases.ts | 4 ++ open-sse/executors/antigravity.ts | 24 +++++++++++ .../executors/antigravityUpstreamError.ts | 25 +++++++++++ tests/unit/agy-gemini-400-3229.test.ts | 41 +++++++++++++++++++ 4 files changed, 94 insertions(+) create mode 100644 open-sse/executors/antigravityUpstreamError.ts create mode 100644 tests/unit/agy-gemini-400-3229.test.ts diff --git a/open-sse/config/antigravityModelAliases.ts b/open-sse/config/antigravityModelAliases.ts index 2b069c3d94..f15b44d331 100644 --- a/open-sse/config/antigravityModelAliases.ts +++ b/open-sse/config/antigravityModelAliases.ts @@ -103,6 +103,10 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([ // gemini-3.x models. Only plain IDs like "gemini-2.5-flash" are proven working. export const ANTIGRAVITY_MODEL_ALIASES = Object.freeze({ "gemini-3-pro-preview": "gemini-3.1-pro", + // agy catalog exposes -high/-low budget tiers, but the upstream rejects the suffix + // for gemini-3.x (#3229) — map them to the plain proven id. + "gemini-3.1-pro-high": "gemini-3.1-pro", + "gemini-3.1-pro-low": "gemini-3.1-pro", "gemini-3.5-flash-preview": "gemini-3.5-flash", "gemini-3-flash-preview": "gemini-3-flash", "gemini-3-pro-image-preview": "gemini-3-pro-image", diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index b6c8027c50..82dda4a0d2 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -8,6 +8,7 @@ import { type ProviderCredentials, } from "./base.ts"; import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts"; +import { buildAntigravityUpstreamError } from "./antigravityUpstreamError.ts"; import { PROVIDERS, OAUTH_ENDPOINTS, @@ -1354,6 +1355,29 @@ export class AntigravityExecutor extends BaseExecutor { // For non-streaming clients, collect the SSE stream and return a synthetic // non-streaming Response so chatCore doesn't need to handle SSE conversion. if (!stream) { + // #3229: surface a real upstream error instead of masking a 4xx/5xx as an + // empty `chat.completion` envelope (collectStreamToResponse synthesizes a + // success-shaped body when the upstream returned no SSE data). + if (!response.ok) { + const rawBody = await response + .clone() + .text() + .catch(() => ""); + const errorBody = buildAntigravityUpstreamError( + response.status, + response.statusText, + rawBody + ); + return { + response: new Response(JSON.stringify(errorBody), { + status: response.status, + headers: { "Content-Type": "application/json" }, + }), + url, + headers: finalHeaders, + transformedBody: attachToolNameMap(transformedBody, requestToolNameMap), + }; + } const collected = await this.collectStreamToResponse( response, model, diff --git a/open-sse/executors/antigravityUpstreamError.ts b/open-sse/executors/antigravityUpstreamError.ts new file mode 100644 index 0000000000..7b285c1ea0 --- /dev/null +++ b/open-sse/executors/antigravityUpstreamError.ts @@ -0,0 +1,25 @@ +/** + * Build a sanitized OpenAI-style error body for a non-ok Antigravity/agy upstream + * response (#3229). + * + * The non-streaming executor path previously fed 4xx/5xx responses into the SSE + * collector, which produced a synthetic `{"object":"chat.completion","content":""}` + * success envelope — masking the real error. Route non-ok responses through + * `buildErrorBody` instead so the client sees a proper error (hard rule #12). + */ +import { buildErrorBody } from "../utils/error.ts"; + +export function buildAntigravityUpstreamError( + status: number, + statusText: string, + rawBody: string +) { + let upstreamDetails: unknown; + try { + upstreamDetails = JSON.parse(rawBody); + } catch { + // upstream body is not JSON (e.g. HTML error page) — omit structured details + } + const suffix = statusText ? `: ${statusText}` : ""; + return buildErrorBody(status, `Antigravity upstream error (${status})${suffix}`, upstreamDetails); +} diff --git a/tests/unit/agy-gemini-400-3229.test.ts b/tests/unit/agy-gemini-400-3229.test.ts new file mode 100644 index 0000000000..6d3fc448b0 --- /dev/null +++ b/tests/unit/agy-gemini-400-3229.test.ts @@ -0,0 +1,41 @@ +/** + * #3229 — agy `gemini-3.1-pro-high`/`-low` returned [400] but were MASKED as an empty + * `chat.completion` envelope. + * + * Two parts: + * (a) the budget-suffix ids had no alias, so `resolveAntigravityModelId` sent them + * verbatim to upstream (which rejects -high/-low for gemini-3.x) → alias to plain. + * (b) the non-stream branch fed the 4xx response into the SSE collector, producing a + * synthetic `{"object":"chat.completion","content":""}` instead of a real error → + * build a proper sanitized error body for non-ok upstream responses. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { resolveAntigravityModelId } from "../../open-sse/config/antigravityModelAliases.ts"; +import { buildAntigravityUpstreamError } from "../../open-sse/executors/antigravityUpstreamError.ts"; + +test("(a) agy gemini-3.1-pro -high/-low budget suffixes alias to the plain upstream id", () => { + assert.equal(resolveAntigravityModelId("gemini-3.1-pro-high"), "gemini-3.1-pro"); + assert.equal(resolveAntigravityModelId("gemini-3.1-pro-low"), "gemini-3.1-pro"); + // plain id stays plain + assert.equal(resolveAntigravityModelId("gemini-3.1-pro"), "gemini-3.1-pro"); +}); + +test("(b) a non-ok upstream response becomes a real error body, not an empty chat.completion", () => { + const body = buildAntigravityUpstreamError( + 400, + "Bad Request", + JSON.stringify({ error: { code: 400, message: "Model not found: gemini-3.1-pro-high" } }) + ); + assert.notEqual((body as { object?: string }).object, "chat.completion"); + assert.ok(body.error, "must carry an error object"); + assert.equal(typeof body.error.message, "string"); + // sanitized: no raw stack traces leaked (hard rule #12) + assert.ok(!body.error.message.includes("at /")); + + // non-JSON upstream body still yields a valid error envelope + const body2 = buildAntigravityUpstreamError(503, "Service Unavailable", "oops"); + assert.ok(body2.error); + assert.notEqual((body2 as { object?: string }).object, "chat.completion"); +});