Skip to content
Closed
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
6 changes: 6 additions & 0 deletions 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, LOCATION_UNSUPPORTED_PATTERNS } 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 @@ -54,11 +55,16 @@ export function isGoogleQuotaExhaustedText(text: string): boolean {
return GOOGLE_QUOTA_EXHAUSTED_NEEDLES.some(needle => lower.includes(needle));
}

export const GOOGLE_LOCATION_UNSUPPORTED_PATTERNS = LOCATION_UNSUPPORTED_PATTERNS;
export const isGoogleLocationUnsupportedText = isLocationUnsupportedMessage;

function classifyGoogle(label: string, status: number | undefined, enumStatus: string | undefined, text: string): string {
const lower = `${enumStatus ?? ""} ${text}`.toLowerCase();
const quotaExhausted = isGoogleQuotaExhaustedText(lower);
if ((!enumStatus || enumStatus === "RESOURCE_EXHAUSTED") && quotaExhausted) return `${label} quota exhausted`;
if (isGoogleLocationUnsupportedText(lower)) {
return `${label} location not supported`;
}
if (status === 429 || enumStatus === "RESOURCE_EXHAUSTED" || lower.includes("rate limit")) {
return `${label} rate limit exceeded`;
}
Expand Down
18 changes: 16 additions & 2 deletions src/adapters/google-http.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { AdapterFetchContext, AdapterRequest } from "./base";
import { isQuotaExhaustedBody, retryableGoogleStatus, safeGoogleHttpErrorMessage } from "./google-errors";
import {
isGoogleLocationUnsupportedText,
isQuotaExhaustedBody,
retryableGoogleStatus,
safeGoogleHttpErrorMessage,
} from "./google-errors";
import { repairGoogleInvalidRequestBody } from "./google-wire-compiler";
import { normalizeUpstreamHttpErrorResponse, readDisplaySafeErrorPayloadText } from "./upstream-http-error";
import {
Expand All @@ -22,7 +27,16 @@ export interface GoogleRetryOptions {
async function normalizeFinalGoogleError(label: string, res: Response, signal?: AbortSignal): Promise<Response> {
return normalizeUpstreamHttpErrorResponse(res, {
signal,
formatMessage: payloadText => safeGoogleHttpErrorMessage(label, res.status, payloadText),
formatMessage: payloadText => {
const message = safeGoogleHttpErrorMessage(label, res.status, payloadText);
if (isGoogleLocationUnsupportedText(payloadText)) {
console.warn(
`[opencodex] ${label} request was rejected because the client location is not supported. `
+ "If using a proxy or VPN, verify TUN mode is enabled and check for IPv6 / direct routing leaks.",
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return message;
},
});
}

Expand Down
22 changes: 22 additions & 0 deletions src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,22 @@ function isPermissionMessage(text: string): boolean {
);
}

export 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 @@ -251,6 +267,12 @@ export function classifyError(status: number, type: string, message: string): Oc
) {
return { message, type: "permission_error", code: "subscription_required" };
}
if (
type === "location_not_supported" ||
isLocationUnsupportedMessage(text)
) {
return { message, type: "permission_error", code: "location_not_supported" };
}
if (
status === 403 ||
type === "permission_error" ||
Expand Down
23 changes: 23 additions & 0 deletions tests/adapters/google/google-errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import {
isGoogleLocationUnsupportedText,
isGoogleQuotaExhaustedText,
isQuotaExhaustedBody,
safeAntigravityHttpErrorMessage,
Expand Down Expand Up @@ -86,4 +87,26 @@ describe("google error classification & quota exhaustion", () => {
});
expect(isQuotaExhaustedBody(jsonBody)).toBe(false);
});

test("classifies location unsupported as location not supported instead of invalid request", () => {
const locationPhrases = [
"User location is not supported for the API use.",
"Location not supported in your region.",
"This model is not supported in your country.",
"Unsupported location for the API use.",
];

for (const phrase of locationPhrases) {
expect(isGoogleLocationUnsupportedText(phrase)).toBe(true);
const jsonBody = JSON.stringify({
error: {
code: 400,
status: "FAILED_PRECONDITION",
message: phrase,
},
});
expect(safeAntigravityHttpErrorMessage(400, jsonBody)).toContain("Antigravity location not supported");
expect(safeVertexHttpErrorMessage(400, jsonBody)).toContain("Vertex AI location not supported");
}
});
});
28 changes: 28 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,34 @@ describe("vertex retry fetch", () => {
expect(text).not.toContain("secret-token");
});

test("Antigravity location unsupported logs diagnostic warning and classifies correctly", async () => {
const warnings: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(" ")); };
try {
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 });
const text = await res.text();
expect(text).toContain("Antigravity location not supported");
expect(warnings.some(w => w.includes("client location is not supported") && w.includes("TUN mode"))).toBe(true);
} finally {
console.warn = origWarn;
}
});

test("Antigravity non-location error does not log location diagnostic warning", async () => {
const warnings: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(" ")); };
try {
mockFetch([new Response(vertexError(400, "INVALID_ARGUMENT", "syntax error"), { status: 400 })]);
await fetchAntigravityWithRetry(request, { timeoutMs: 5_000 });
expect(warnings.some(w => w.includes("client location is not supported"))).toBe(false);
} finally {
console.warn = origWarn;
}
});

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
21 changes: 20 additions & 1 deletion tests/server/error-fidelity.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { bridgeToResponsesSSE, formatErrorResponse } from "../../src/bridge";
import { classifyError } from "../../src/lib/errors";
import { classifyError, isLocationUnsupportedMessage } from "../../src/lib/errors";
import { sanitizePassthroughHeaders } from "../../src/server";
import type { AdapterEvent } from "../../src/types";

Expand Down Expand Up @@ -91,6 +91,25 @@ describe("error fidelity", () => {
type: "insufficient_quota",
code: "insufficient_quota",
});
expect(classifyError(400, "upstream_error", "Antigravity location not supported: User location is not supported for the API use.")).toMatchObject({
type: "permission_error",
code: "location_not_supported",
});
expect(classifyError(400, "upstream_error", "User location is not supported for the API use.")).toMatchObject({
type: "permission_error",
code: "location_not_supported",
});
expect(classifyError(403, "location_not_supported", "Region is not supported")).toMatchObject({
type: "permission_error",
code: "location_not_supported",
});
expect(classifyError(400, "upstream_error", "USER LOCATION IS NOT SUPPORTED IN YOUR REGION")).toMatchObject({
type: "permission_error",
code: "location_not_supported",
});
expect(isLocationUnsupportedMessage("USER LOCATION IS NOT SUPPORTED")).toBe(true);
expect(isLocationUnsupportedMessage("Region Is Not Supported")).toBe(true);
expect(isLocationUnsupportedMessage("not supported for the api use")).toBe(false);
});

test("formatErrorResponse returns OpenAI-compatible classified error envelope", async () => {
Expand Down
Loading