From fd1dbbedbf6a347e5208567f873137756a701158 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 5 Sep 2026 07:52:53 +0900 Subject: [PATCH 1/2] fix(google): classify location-not-supported as a permission error, not an invalid request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google Cloud Code Assist (Antigravity) and Vertex reject unsupported geographic or datacenter locations with HTTP 400 FAILED_PRECONDITION "User location is not supported for the API use." classifyGoogle folded every 400 into "invalid request", and the shared classifier then emitted invalid_request_error — telling the user their prompt was malformed when the network location was refused. - classifyGoogle: new "location not supported" branch after auth/quota/permission enums and before the generic 400 fallthrough, so UNAUTHENTICATED / RESOURCE_EXHAUSTED / PERMISSION_DENIED keep precedence. - classifyError: location denials map to permission_error / location_not_supported, placed after the authoritative 401 block. - inferHttpStatusFromAdapterMessage: message-only terminals infer 403 so they agree with the classified envelope instead of falling through to 502. - Direct HTTP responses keep the upstream 400. Carries PR #3469 with the precedence fix and without the VPN/TUN console warning. Closes #3467 Co-authored-by: agentHits --- .../src/content/docs/reference/adapters.md | 7 +++ src/adapters/google-errors.ts | 7 +++ src/lib/errors.ts | 30 +++++++++++ tests/adapters/google/google-errors.test.ts | 52 +++++++++++++++++++ .../google/google-vertex-http.test.ts | 13 +++++ tests/server/error-fidelity.test.ts | 50 +++++++++++++++++- 6 files changed, 158 insertions(+), 1 deletion(-) 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", From 10d76750e7201258c7bf0b28087f5a627771bb7b Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 5 Sep 2026 12:46:57 +0900 Subject: [PATCH 2/2] fix(google): preserve authoritative error precedence over location hints Restrict Google location inference to 400 precondition envelopes and preserve 5xx and explicit PERMISSION_DENIED classification. Add regression assertions without running local tests. Co-authored-by: agentHits --- src/adapters/google-errors.ts | 7 +++-- src/lib/errors.ts | 10 +++++-- tests/adapters/google/google-errors.test.ts | 20 +++++++++++++ tests/server/error-fidelity.test.ts | 32 +++++++++++++++++++++ 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/adapters/google-errors.ts b/src/adapters/google-errors.ts index 395c362ed5..c0eb92e514 100644 --- a/src/adapters/google-errors.ts +++ b/src/adapters/google-errors.ts @@ -66,13 +66,14 @@ 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). Auth / quota / permission enums above keep precedence. - if (isLocationUnsupportedMessage(lower)) { + // "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")) { diff --git a/src/lib/errors.ts b/src/lib/errors.ts index d685efbe89..78cae9dc8c 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -266,9 +266,13 @@ 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)) { + // 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))) { return { message, type: "permission_error", code: "location_not_supported" }; } // Subscription labels are valid only in a known permission context. diff --git a/tests/adapters/google/google-errors.test.ts b/tests/adapters/google/google-errors.test.ts index 026a83f5b9..47481457ec 100644 --- a/tests/adapters/google/google-errors.test.ts +++ b/tests/adapters/google/google-errors.test.ts @@ -138,4 +138,24 @@ describe("google location denial classification (#3467)", () => { }); 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}`); + } + }); }); diff --git a/tests/server/error-fidelity.test.ts b/tests/server/error-fidelity.test.ts index 00409c0025..151637088a 100644 --- a/tests/server/error-fidelity.test.ts +++ b/tests/server/error-fidelity.test.ts @@ -204,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");