Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,13 @@ of the HTTP retry loop.
`/v1beta/models/{model}:streamGenerateContent`; the other modes use their native Google endpoints.
**Auth:** API key, Vertex ADC, or Google Antigravity OAuth, selected by `googleMode`.

- **Location denials are permission errors, not invalid requests.** Google rejects unsupported
geographic or datacenter locations with HTTP 400 `FAILED_PRECONDITION: User location is not
supported for the API use.` The proxy reports this as `… location not supported: …` and
classifies it as `permission_error` with code `location_not_supported`, so a client does not
misread a network-location refusal as a malformed prompt. The direct HTTP response keeps the
upstream 400; message-only terminal paths infer 403 (permission class). The restriction itself
is Google's — the proxy does not route around it.
- System prompt → `systemInstruction`; messages → `contents[]` (assistant → `model`); tools →
`functionDeclarations`. Data-URL images → `inline_data`.
- Tool-call ids are synthesized when Gemini omits them. Vertex and Antigravity preserve and replay
Expand Down
10 changes: 9 additions & 1 deletion src/adapters/google-errors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { parseUpstreamJsonPayload, safeUpstreamErrorString, sanitizeUpstreamErrorText } from "./upstream-http-error";
import { isLocationUnsupportedMessage } from "../lib/errors";

/** Pull the human detail out of the Google API error envelope `{error:{message,status,code}}`. */
function googleErrorDetail(payloadText: string): { message?: string; status?: string } {
Expand Down Expand Up @@ -65,9 +66,16 @@ function classifyGoogle(label: string, status: number | undefined, enumStatus: s
if (status === 401 || enumStatus === "UNAUTHENTICATED" || lower.includes("unauthenticated") || lower.includes("invalid authentication") || lower.includes("expired")) {
return `${label} authentication failed`;
}
if (status === 403 || enumStatus === "PERMISSION_DENIED" || lower.includes("permission denied") || lower.includes("access denied")) {
if (status === 403 || enumStatus === "PERMISSION_DENIED" || lower.includes("permission_denied") || lower.includes("permission denied") || lower.includes("access denied")) {
return `${label} access denied`;
}
// Google rejects unsupported geographic / datacenter locations with HTTP 400
// FAILED_PRECONDITION. The payload is not malformed, so it must not fall through to
// "invalid request" (#3467). Only the observed 400/precondition envelope permits
// this inference; other explicit enums and server statuses remain authoritative.
if (status === 400 && (!enumStatus || enumStatus === "FAILED_PRECONDITION") && isLocationUnsupportedMessage(lower)) {
return `${label} location not supported`;
}
if (status === 503 || enumStatus === "UNAVAILABLE" || lower.includes("overloaded") || lower.includes("unavailable")) {
return `${label} server overloaded`;
}
Expand Down
34 changes: 34 additions & 0 deletions src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,28 @@ function isPermissionMessage(text: string): boolean {
);
}

/**
* Geographic / network-location denials. Google's Cloud Code Assist API returns these as
* HTTP 400 `FAILED_PRECONDITION: User location is not supported for the API use.` — the
* request is well-formed, the caller's location is refused. Treated as a permission-class
* rejection, never as an invalid request (#3467).
*/
const LOCATION_UNSUPPORTED_PATTERNS = [
"location is not supported",
"location not supported",
"unsupported location",
"region is not supported",
"unsupported region",
"country is not supported",
"not supported in your country",
"not supported in your region",
] as const;

export function isLocationUnsupportedMessage(text: string): boolean {
const lower = text.toLowerCase();
return LOCATION_UNSUPPORTED_PATTERNS.some(needle => lower.includes(needle));
}

/**
* Client cancelled / closed the turn. Matches ONLY abort phrases this codebase
* produces — "client closed request during web-search" (src/web-search/loop.ts),
Expand Down Expand Up @@ -244,6 +266,15 @@ export function classifyError(status: number, type: string, message: string): Oc
) {
return { message, type: "authentication_error", code: "invalid_api_key" };
}
// An explicit permission enum must not acquire a more specific inferred reason.
if (type === "PERMISSION_DENIED" || text.includes("permission_denied")) {
return { message, type: "permission_error", code: "permission_denied" };
}
// Location denials outrank generic permission / subscription wording, but never an
// authoritative 5xx. Message-only adapter terminals arrive here with inferred 403.
if (status < 500 && (type === "location_not_supported" || isLocationUnsupportedMessage(text))) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve explicit Google enums through final classification

When Vertex or Antigravity returns PERMISSION_DENIED or INVALID_ARGUMENT with location wording, safeGoogleHttpErrorMessage correctly normalizes it to access denied or invalid request, but the enum itself is then discarded. The Responses path subsequently calls classifyError, where this condition matches the retained detail before the generic permission/invalid-request branches, changing both cases to location_not_supported. Thus the precedence asserted by the new Google tests is not preserved in the client-facing envelope; carry the classified reason/code through normalization or recognize the authoritative normalized prefix before applying location inference.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

return { message, type: "permission_error", code: "location_not_supported" };
}
// Subscription labels are valid only in a known permission context.
if (
(status === 403 || type === "permission_error") &&
Expand Down Expand Up @@ -352,6 +383,9 @@ export function inferHttpStatusFromAdapterMessage(message: string): number {
// Strong authentication signals win when a message contains mixed auth and
// subscription/permission wording.
if (isAuthenticationMessage(lower)) return 401;
// A location denial is a permission-class rejection; keep it aligned with the
// `permission_error` envelope status so message-only and classified paths agree.
if (isLocationUnsupportedMessage(lower)) return 403;
if (isSubscriptionGateMessage(lower) || isPermissionMessage(lower)) return 403;
// Same precedence rule as classifyCursorError: an explicit gRPC FAILED_PRECONDITION is a
// structured, deterministic rejection, so it outranks the overload keywords that routinely
Expand Down
72 changes: 72 additions & 0 deletions tests/adapters/google/google-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,75 @@ describe("google error classification & quota exhaustion", () => {
expect(isQuotaExhaustedBody(jsonBody)).toBe(false);
});
});

describe("google location denial classification (#3467)", () => {
const locationBody = JSON.stringify({
error: {
code: 400,
status: "FAILED_PRECONDITION",
message: "User location is not supported for the API use.",
},
});

test("HTTP 400 FAILED_PRECONDITION location denial is not an invalid request", () => {
expect(safeAntigravityHttpErrorMessage(400, locationBody))
.toBe("Antigravity location not supported: User location is not supported for the API use.");
expect(safeVertexHttpErrorMessage(400, locationBody)).toContain("Vertex AI location not supported");
expect(safeGoogleHttpErrorMessage("Gemini", 400, locationBody)).toContain("Gemini location not supported");
});

test("alternate location / region / country phrasings classify the same way", () => {
for (const message of [
"unsupported location for this API",
"The region is not supported",
"This model is not supported in your country",
]) {
const body = JSON.stringify({ error: { code: 400, status: "FAILED_PRECONDITION", message } });
expect(safeAntigravityHttpErrorMessage(400, body)).toContain("Antigravity location not supported");
}
});

test("a generic FAILED_PRECONDITION without location wording stays an invalid request", () => {
const body = JSON.stringify({
error: { code: 400, status: "FAILED_PRECONDITION", message: "Precondition check failed." },
});
expect(safeAntigravityHttpErrorMessage(400, body)).toContain("Antigravity invalid request");
});

test("auth, quota and permission enums keep precedence over location wording", () => {
const unauth = JSON.stringify({
error: { code: 401, status: "UNAUTHENTICATED", message: "location is not supported (token expired)" },
});
expect(safeAntigravityHttpErrorMessage(401, unauth)).toContain("Antigravity authentication failed");

const exhausted = JSON.stringify({
error: { code: 429, status: "RESOURCE_EXHAUSTED", message: "Rate limit hit; location not supported" },
});
expect(safeAntigravityHttpErrorMessage(429, exhausted)).toContain("Antigravity rate limit exceeded");

const denied = JSON.stringify({
error: { code: 403, status: "PERMISSION_DENIED", message: "location is not supported for this project" },
});
expect(safeAntigravityHttpErrorMessage(403, denied)).toContain("Antigravity access denied");
});

test("server statuses and explicit non-location enums do not infer a location reason", () => {
const message = "User location is not supported for the API use.";
expect(safeAntigravityHttpErrorMessage(400, `PERMISSION_DENIED: ${message}`))
.toBe(`Antigravity access denied: PERMISSION_DENIED: ${message}`);
for (const status of [500, 502, 503, 504]) {
const body = JSON.stringify({ error: { code: status, status: "FAILED_PRECONDITION", message } });
expect(safeAntigravityHttpErrorMessage(status, body)).toBe(
`Antigravity ${status === 503 ? "server overloaded" : "upstream error"}: ${message}`,
);
}
for (const [status, prefix] of [
["PERMISSION_DENIED", "access denied"],
["INVALID_ARGUMENT", "invalid request"],
["UNAVAILABLE", "server overloaded"],
]) {
const body = JSON.stringify({ error: { code: 400, status, message } });
expect(safeAntigravityHttpErrorMessage(400, body)).toBe(`Antigravity ${prefix}: ${message}`);
}
});
});
13 changes: 13 additions & 0 deletions tests/adapters/google/google-vertex-http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,19 @@ describe("vertex retry fetch", () => {
expect(text).not.toContain("secret-token");
});

test("Antigravity location denial surfaces as location-not-supported with the upstream 400 (#3467)", async () => {
const mock = mockFetch([new Response(
vertexError(400, "FAILED_PRECONDITION", "User location is not supported for the API use."),
{ status: 400 },
)]);
const res = await fetchAntigravityWithRetry(request, { timeoutMs: 5_000 });
expect(res.status).toBe(400);
const text = await res.text();
expect(text).toContain("Antigravity location not supported");
expect(text).not.toContain("invalid request");
expect(mock.calls).toHaveLength(1);
});

test("does not retry 401/403 (single attempt)", async () => {
const mock401 = mockFetch([new Response(vertexError(401, "UNAUTHENTICATED", "bad token"), { status: 401 })]);
const res401 = await fetchVertexWithRetry(request, { timeoutMs: 5_000 });
Expand Down
82 changes: 81 additions & 1 deletion tests/server/error-fidelity.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, expect, test } from "bun:test";
import { bridgeToResponsesSSE, formatErrorResponse } from "../../src/bridge";
import { classifyError } from "../../src/lib/errors";
import {
adapterFailureFromMessage,
classifyError,
httpStatusFromTerminalError,
isLocationUnsupportedMessage,
} from "../../src/lib/errors";
import { sanitizePassthroughHeaders } from "../../src/server";
import type { AdapterEvent } from "../../src/types";

Expand Down Expand Up @@ -146,6 +151,49 @@ describe("error fidelity", () => {
});

describe("overload and transient-429 classification (F3)", () => {
test("location denials classify as permission_error / location_not_supported, not invalid_request (#3467)", async () => {
const message = "Antigravity location not supported: User location is not supported for the API use.";
expect(isLocationUnsupportedMessage(message)).toBe(true);
expect(isLocationUnsupportedMessage("Precondition check failed.")).toBe(false);

expect(classifyError(400, "upstream_error", message)).toMatchObject({
type: "permission_error",
code: "location_not_supported",
});
expect(classifyError(400, "location_not_supported", "denied")).toMatchObject({
type: "permission_error",
code: "location_not_supported",
});
// Raw upstream wording (no adapter normalization) classifies the same way.
expect(classifyError(400, "upstream_error", "USER LOCATION IS NOT SUPPORTED for the API use.")).toMatchObject({
code: "location_not_supported",
});

// The direct HTTP envelope keeps the upstream status.
const response = formatErrorResponse(400, "upstream_error", message);
expect(response.status).toBe(400);
expect((await response.json() as { error: unknown }).error).toMatchObject({
type: "permission_error",
code: "location_not_supported",
});

// Message-only adapter terminals and classified terminal envelopes agree on 403 (permission class).
const failure = adapterFailureFromMessage(message);
expect(failure.httpStatus).toBe(403);
expect(failure.error).toMatchObject({ type: "permission_error", code: "location_not_supported" });
expect(httpStatusFromTerminalError(failure.error)).toBe(403);
});

test("authentication and rate-limit signals outrank location wording", () => {
expect(classifyError(401, "upstream_error", "location is not supported (token expired)")).toMatchObject({
type: "authentication_error",
});
expect(classifyError(429, "upstream_error", "rate limit exceeded; location not supported")).toMatchObject({
type: "rate_limit_error",
});
expect(adapterFailureFromMessage("Antigravity authentication failed: location is not supported").httpStatus).toBe(401);
});

test("503 / overloaded maps to the Codex-recognized server_is_overloaded", () => {
expect(classifyError(503, "upstream_error", "The server is overloaded")).toMatchObject({
type: "server_error",
Expand All @@ -156,6 +204,38 @@ describe("overload and transient-429 classification (F3)", () => {
});
});

test("authoritative 5xx statuses outrank mixed location wording (#3467)", () => {
const message = "User location is not supported for the API use.";
for (const status of [500, 502, 503, 504]) {
expect(classifyError(status, "upstream_error", message)).toEqual({
message,
type: "server_error",
code: status === 503 ? "server_is_overloaded" : "upstream_server_error",
});
}
expect(classifyError(503, "server_error", `Server temporarily unavailable: ${message}`)).toMatchObject({
type: "server_error",
code: "server_is_overloaded",
});
});

test("explicit PERMISSION_DENIED wording keeps its reason beside location wording (#3467)", () => {
const message = "PERMISSION_DENIED: User location is not supported for the API use.";
expect(classifyError(400, "upstream_error", message)).toEqual({
message,
type: "permission_error",
code: "permission_denied",
});
expect(adapterFailureFromMessage(message)).toMatchObject({
httpStatus: 403,
error: { type: "permission_error", code: "permission_denied" },
});
expect(classifyError(400, "PERMISSION_DENIED", "location not supported")).toMatchObject({
type: "permission_error",
code: "permission_denied",
});
});

test("transient 429 quota bucket stays retryable (rate_limit_exceeded), delay text preserved", () => {
const r = classifyError(429, "upstream_error", "You have exceeded your quota for requests per min. Please try again in 5s");
expect(r.code).toBe("rate_limit_exceeded");
Expand Down
Loading