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.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-05 14:20:05 -03:00
committed by GitHub
parent 9c13d44cca
commit 62e6336aad
4 changed files with 94 additions and 0 deletions

View File

@@ -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",

View File

@@ -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,

View File

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

View File

@@ -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", "<html>oops</html>");
assert.ok(body2.error);
assert.notEqual((body2 as { object?: string }).object, "chat.completion");
});