diff --git a/changelog.d/fixes/13591-antigravity-opaque-400-error-surfaced.md b/changelog.d/fixes/13591-antigravity-opaque-400-error-surfaced.md new file mode 100644 index 0000000000..37c11a42fe --- /dev/null +++ b/changelog.d/fixes/13591-antigravity-opaque-400-error-surfaced.md @@ -0,0 +1,3 @@ +- **fix(providers):** Antigravity error responses and logs now surface the real upstream + message (e.g. Gemini field-path rejections) instead of the generic "Antigravity upstream + error (400)" placeholder (#13591) — thanks @afonsoft diff --git a/open-sse/executors/antigravityUpstreamError.ts b/open-sse/executors/antigravityUpstreamError.ts index 074824ef17..2807baca9f 100644 --- a/open-sse/executors/antigravityUpstreamError.ts +++ b/open-sse/executors/antigravityUpstreamError.ts @@ -21,6 +21,20 @@ const GEO_BLOCKED_HINT = "call the model API. Route antigravity/agy egress through a proxy in a " + "supported region (e.g. US/EU) or use a different provider."; +/** + * Extract the real upstream error message (e.g. Google's Gemini-dialect field-path + * rejection) from a parsed Antigravity `upstream_details`-shaped body, so callers can + * surface it directly in `error.message` instead of only nesting it under + * `upstream_details` — the generic `parseUpstreamError()` re-parser used by the shared + * chatCore failure path only reads the outer `error.message` (#13591). + */ +function extractUpstreamMessage(details: unknown): string | null { + if (!details || typeof details !== "object") return null; + const err = (details as { error?: { message?: unknown } }).error; + const msg = err && typeof err.message === "string" ? err.message : null; + return msg && msg.trim() ? msg.trim() : null; +} + export function buildAntigravityUpstreamError(status: number, statusText: string, rawBody: string) { let upstreamDetails: unknown; try { @@ -36,5 +50,9 @@ export function buildAntigravityUpstreamError(status: number, statusText: string upstreamDetails ); } - return buildErrorBody(status, `Antigravity upstream error (${status})${suffix}`, upstreamDetails); + const upstreamMessage = extractUpstreamMessage(upstreamDetails); + const message = upstreamMessage + ? `Antigravity upstream error (${status}): ${upstreamMessage}` + : `Antigravity upstream error (${status})${suffix}`; + return buildErrorBody(status, message, upstreamDetails); } diff --git a/tests/unit/antigravityUpstreamError.test.ts b/tests/unit/antigravityUpstreamError.test.ts new file mode 100644 index 0000000000..4329b4fb8d --- /dev/null +++ b/tests/unit/antigravityUpstreamError.test.ts @@ -0,0 +1,75 @@ +// Regression coverage for issue #13591: Antigravity double-wraps its own errors, so +// parseUpstreamError() (the shared chatCore failure-classification path) only ever saw +// the generic "Antigravity upstream error (400)" template instead of the real Gemini +// upstream detail buried under `upstream_details`. buildAntigravityUpstreamError() must +// surface the real upstream message as `error.message` directly. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { buildAntigravityUpstreamError } from "../../open-sse/executors/antigravityUpstreamError.ts"; +import { parseUpstreamError } from "../../open-sse/utils/error.ts"; + +test("issue #13591: parseUpstreamError surfaces the real upstream detail for an Antigravity-wrapped 400", async () => { + const rawUpstreamGeminiBody = JSON.stringify({ + error: { + code: 400, + message: + "Invalid value at 'tools[0].function_declarations[0].parameters.properties[0].value' " + + '(type.googleapis.com/google.ai.generativelanguage.v1beta.Schema), "string"', + status: "INVALID_ARGUMENT", + }, + }); + + const wrappedErrorBody = buildAntigravityUpstreamError(400, "", rawUpstreamGeminiBody); + + assert.ok( + JSON.stringify(wrappedErrorBody).includes("function_declarations"), + "sanity: buildAntigravityUpstreamError should embed the real upstream detail somewhere in the body" + ); + assert.ok( + (wrappedErrorBody as { error: { message: string } }).error.message.includes( + "function_declarations" + ), + "buildAntigravityUpstreamError should surface the real upstream detail directly in error.message" + ); + + const wrappedResponse = new Response(JSON.stringify(wrappedErrorBody), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + + const parsed = await parseUpstreamError(wrappedResponse, "antigravity"); + + assert.ok( + parsed.message.includes("function_declarations"), + `expected parseUpstreamError to surface the real upstream detail, but got: ${JSON.stringify(parsed.message)}` + ); +}); + +test("issue #13591: geo-blocked branch keeps its explicit hint message untouched", () => { + const geoBlockedBody = JSON.stringify({ + error: { + message: "User location is not supported for the API use.", + }, + }); + + const wrappedErrorBody = buildAntigravityUpstreamError(400, "Bad Request", geoBlockedBody) as { + error: { message: string }; + }; + + assert.ok( + wrappedErrorBody.error.message.includes( + "not offered from this server's current egress location" + ), + "geo-blocked responses must keep the operator-facing hint, not the raw upstream text" + ); +}); + +test("issue #13591: non-JSON upstream body falls back to the generic templated message without throwing", () => { + const htmlErrorPage = "502 Bad Gateway"; + + const wrappedErrorBody = buildAntigravityUpstreamError(502, "Bad Gateway", htmlErrorPage) as { + error: { message: string }; + }; + + assert.equal(wrappedErrorBody.error.message, "Antigravity upstream error (502): Bad Gateway"); +});