diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index c0259ff9cd..4e548593a6 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -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 diff --git a/src/adapters/google-errors.ts b/src/adapters/google-errors.ts index d78ee1fb9b..395c362ed5 100644 --- a/src/adapters/google-errors.ts +++ b/src/adapters/google-errors.ts @@ -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 } { @@ -68,6 +69,12 @@ function classifyGoogle(label: string, status: number | undefined, enumStatus: s if (status === 403 || enumStatus === "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). Auth / quota / permission enums above keep precedence. + if (isLocationUnsupportedMessage(lower)) { + return `${label} location not supported`; + } if (status === 503 || enumStatus === "UNAVAILABLE" || lower.includes("overloaded") || lower.includes("unavailable")) { return `${label} server overloaded`; } diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 624917507c..d685efbe89 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -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), @@ -244,6 +266,11 @@ export function classifyError(status: number, type: string, message: string): Oc ) { return { message, type: "authentication_error", code: "invalid_api_key" }; } + // Location denials outrank generic permission / subscription wording so the caller sees + // a stable `location_not_supported` code instead of `permission_denied`. + if (type === "location_not_supported" || isLocationUnsupportedMessage(text)) { + 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") && @@ -352,6 +379,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 diff --git a/tests/adapters/google/google-errors.test.ts b/tests/adapters/google/google-errors.test.ts index c31da10556..026a83f5b9 100644 --- a/tests/adapters/google/google-errors.test.ts +++ b/tests/adapters/google/google-errors.test.ts @@ -87,3 +87,55 @@ 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"); + }); +}); diff --git a/tests/adapters/google/google-vertex-http.test.ts b/tests/adapters/google/google-vertex-http.test.ts index 8ad138741d..7d91793226 100644 --- a/tests/adapters/google/google-vertex-http.test.ts +++ b/tests/adapters/google/google-vertex-http.test.ts @@ -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 }); diff --git a/tests/server/error-fidelity.test.ts b/tests/server/error-fidelity.test.ts index c3044aaa4e..00409c0025 100644 --- a/tests/server/error-fidelity.test.ts +++ b/tests/server/error-fidelity.test.ts @@ -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"; @@ -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",