Compare commits

...

2 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza
1943a0c9e7 chore: sync release/v3.8.51 into fix/13591-antigravity-opaque-400-error-swallowed (base-red fix #13747) 2026-09-15 23:25:24 -03:00
diegosouzapw
354532d6ea fix(providers): surface real Antigravity upstream error detail (#13591)
Antigravity double-wraps its own errors: buildAntigravityUpstreamError()
replaced error.message with the generic "Antigravity upstream error (400)"
template and buried the real Gemini-dialect detail under upstream_details.
chatCore's shared failure path re-parses that already-wrapped body with the
generic parseUpstreamError(), which only reads the outer error.message, so
the generic text is what ends up in createErrorResult, the persisted call
logs, and the [ProxyEgress] antigravity status=error lines.

buildAntigravityUpstreamError() now composes error.message from the real
upstream detail when present, so both the HTTP response and every
log/telemetry consumer that reads error.message get the actual detail.

Regression test: tests/unit/antigravityUpstreamError.test.ts
2026-09-15 18:09:40 -03:00
3 changed files with 97 additions and 1 deletions

View File

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

View File

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

View File

@@ -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 = "<html><body>502 Bad Gateway</body></html>";
const wrappedErrorBody = buildAntigravityUpstreamError(502, "Bad Gateway", htmlErrorPage) as {
error: { message: string };
};
assert.equal(wrappedErrorBody.error.message, "Antigravity upstream error (502): Bad Gateway");
});