diff --git a/changelog.d/fixes/10420-antigravity-geoblock-resilience.md b/changelog.d/fixes/10420-antigravity-geoblock-resilience.md new file mode 100644 index 0000000000..cb465299b2 --- /dev/null +++ b/changelog.d/fixes/10420-antigravity-geoblock-resilience.md @@ -0,0 +1,2 @@ +- **fix(antigravity):** geo-blocked egress (Google "User location is not supported") is now classified (scoped to the Google AI surfaces that emit it: Cloud Code/Gemini Code Assist, Gemini API, Vertex), cached as a 24h per-account exclusion so routing continues with other accounts, and surfaced with an actionable message; the dashboard connection test now probes the real `streamGenerateContent` model surface instead of the non-geo-restricted OAuth userinfo endpoint ([#10420](https://github.com/diegosouzapw/OmniRoute/pull/10420)) — thanks @rqzbeh +- **fix(antigravity):** strip competing-agent identity sentences from system prompts (e.g. "You are a Claude agent, built on Anthropic's Claude Agent SDK.") that Antigravity flags and answers with 429 RESOURCE_EXHAUSTED (port of decolua/9router b566b20) ([#10420](https://github.com/diegosouzapw/OmniRoute/pull/10420)) — thanks @rqzbeh diff --git a/open-sse/config/errorConfig.ts b/open-sse/config/errorConfig.ts index 3081f53fb9..b326af7378 100644 --- a/open-sse/config/errorConfig.ts +++ b/open-sse/config/errorConfig.ts @@ -77,6 +77,10 @@ export const COOLDOWN_MS = { rateLimit: 2 * 60 * 1000, serviceUnavailable: 2 * 1000, authExpired: 2 * 60 * 1000, + // Google regional-availability refusal: nothing changes region-wise on the + // account, so re-probe only after a long window (or when the operator routes + // egress through a supported-region proxy). + geoBlocked: 24 * 60 * 60 * 1000, }; /** diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 7f949284c6..137b996920 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -339,6 +339,45 @@ function asRecord(value: unknown): Record | null { : null; } +/** + * Known competing-agent identity sentences that Antigravity's server-side + * filter flags, answering with a 429 RESOURCE_EXHAUSTED (port of + * decolua/9router b566b20, generalized). Only the identity sentence is + * removed — surrounding instruction text is untouched. + */ +const COMPETITIVE_AGENT_PROMPT_PATTERNS: RegExp[] = [ + /\byou are a claude agent\b[^\n]*/i, + /\bbuilt on anthropic's claude agent sdk\b[^\n]*/i, + /\byou are claude code\b[^\n]*/i, + /\byou are an ai assistant created by anthropic\b[^\n]*/i, +]; + +/** + * Strip competing-agent identity sentences from systemInstruction.parts. + * Returns the original reference when nothing matched (no allocation). + */ +export function stripCompetitiveAgentPrompts(systemInstruction: unknown): unknown { + const record = asRecord(systemInstruction); + const parts = Array.isArray(record?.parts) ? (record.parts as Array>) : []; + if (parts.length === 0) return systemInstruction; + + let changed = false; + const newParts = parts.map((part) => { + if (typeof part.text !== "string" || part.text.length === 0) return part; + let text = part.text; + for (const pattern of COMPETITIVE_AGENT_PROMPT_PATTERNS) { + const stripped = text.replace(pattern, "").replace(/\n{3,}/g, "\n\n").trimStart(); + if (stripped !== text) { + changed = true; + text = stripped; + } + } + return text === part.text ? part : { ...part, text }; + }); + + return changed ? { ...record, parts: newParts } : systemInstruction; +} + function getAntigravitySafetySettings(safetySettings: unknown): unknown[] | undefined { if (!Array.isArray(safetySettings)) return undefined; @@ -358,7 +397,10 @@ function sanitizeAntigravityGeminiRequest( } if (asRecord(request.systemInstruction)) { - clean.systemInstruction = request.systemInstruction; + // #10420: strip competing-agent identity sentences (e.g. "You are a + // Claude agent, built on Anthropic's Claude Agent SDK.") that Antigravity + // flags and answers with 429 RESOURCE_EXHAUSTED. + clean.systemInstruction = stripCompetitiveAgentPrompts(request.systemInstruction); } clean.generationConfig = asRecord(request.generationConfig) diff --git a/open-sse/executors/antigravityUpstreamError.ts b/open-sse/executors/antigravityUpstreamError.ts index 7b285c1ea0..074824ef17 100644 --- a/open-sse/executors/antigravityUpstreamError.ts +++ b/open-sse/executors/antigravityUpstreamError.ts @@ -8,12 +8,20 @@ * `buildErrorBody` instead so the client sees a proper error (hard rule #12). */ import { buildErrorBody } from "../utils/error.ts"; +import { isGeoBlockedError } from "../services/errorClassifier.ts"; -export function buildAntigravityUpstreamError( - status: number, - statusText: string, - rawBody: string -) { +// The dashboard "Test Connection" for antigravity only probes the OAuth userinfo +// endpoint (https://www.googleapis.com/oauth2/v1/userinfo), which is NOT +// geo-restricted — so a green tick does not prove the model path works. Spell +// this out in the geo-block message so operators stop chasing accounts. +const GEO_BLOCKED_HINT = + "The Cloud Code API is not offered from this server's current egress location " + + '("User location is not supported for the API use."). This is not an account ' + + "problem: the connection test only validates the Google OAuth token and does not " + + "call the model API. Route antigravity/agy egress through a proxy in a " + + "supported region (e.g. US/EU) or use a different provider."; + +export function buildAntigravityUpstreamError(status: number, statusText: string, rawBody: string) { let upstreamDetails: unknown; try { upstreamDetails = JSON.parse(rawBody); @@ -21,5 +29,12 @@ export function buildAntigravityUpstreamError( // upstream body is not JSON (e.g. HTML error page) — omit structured details } const suffix = statusText ? `: ${statusText}` : ""; + if (isGeoBlockedError(rawBody)) { + return buildErrorBody( + status, + `Antigravity upstream error (${status})${suffix}. ${GEO_BLOCKED_HINT}`, + upstreamDetails + ); + } return buildErrorBody(status, `Antigravity upstream error (${status})${suffix}`, upstreamDetails); } diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index ce0656323d..cdd93178c9 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -3932,6 +3932,28 @@ export async function handleChatCore({ console.warn( `[provider] Node ${errorConnectionId} project routing error (${statusCode}) — not banning` ); + } else if (errorType === PROVIDER_ERROR_TYPES.GEO_BLOCKED) { + // Google regional-availability refusal (e.g. "User location is not + // supported for the API use."). Account-independent and non-terminal: + // exclude the connection for the cooldown window so routing moves to + // other accounts instead of re-selecting this one on every request, + // and never mark it banned/expired. It becomes usable again once + // egress is routed through a supported-region proxy. + const geoCooldownMs = COOLDOWN_MS.geoBlocked ?? 24 * 60 * 60 * 1000; + await updateProviderConnection(errorConnectionId, { + lastErrorType: errorType, + lastError: message, + errorCode: statusCode, + }); + try { + const { setConnectionRateLimitUntil } = await import("@/lib/db/providers"); + setConnectionRateLimitUntil(errorConnectionId, Date.now() + geoCooldownMs); + } catch { + // DB write failure must never break the fallback loop + } + console.warn( + `[provider] Node ${errorConnectionId} geo-blocked (${statusCode}) — excluded for ${Math.ceil(geoCooldownMs / 1000)}s, trying other accounts` + ); } else if (errorType === PROVIDER_ERROR_TYPES.MODEL_NOT_FOUND) { // 404 — model/endpoint does not exist upstream. Lock the model so the // retry/backoff loop stops hammering the dead endpoint (which would diff --git a/open-sse/services/errorClassifier.ts b/open-sse/services/errorClassifier.ts index daa3bab657..43c1aa3079 100644 --- a/open-sse/services/errorClassifier.ts +++ b/open-sse/services/errorClassifier.ts @@ -79,6 +79,7 @@ export const PROVIDER_ERROR_TYPES = { EMPTY_CONTENT: "empty_content", MODEL_NOT_FOUND: "model_not_found", FINGERPRINT_REJECTION: "fingerprint_rejection", + GEO_BLOCKED: "geo_blocked", }; export const CONTEXT_OVERFLOW_SIGNALS = [ @@ -114,6 +115,61 @@ export function containsModelUnavailableMessage(errorMessage: string): boolean { return MODEL_NAMED_UNSUPPORTED_REGEX.test(String(errorMessage || "").toLowerCase()); } +// Google regional-availability rejection: the Cloud Code / Gemini Code Assist +// API is not offered from every country, and the upstream answers with a 400 +// FAILED_PRECONDITION like "User location is not supported for the API use." +// This is an ACCOUNT-INDEPENDENT, location-scoped refusal: every account on +// this server egresses from the same region, so retrying another credential +// cannot help — but routing egress through a proxy in a supported region can. +// Detected here so routing treats it as a non-terminal, cached-per-connection +// exclusion instead of a generic 400 (which would keep re-selecting the same +// account and surface a cryptic "upstream error (400)"). +const GEO_BLOCK_SIGNALS = [ + "user location is not supported", + "location is not supported", + "not supported for the api use", + "region is not supported", + "unsupported location", + "not available in your location", + "not available in your region", +]; + +export function isGeoBlockedError(errorMessage: string): boolean { + const lower = String(errorMessage || "").toLowerCase(); + return GEO_BLOCK_SIGNALS.some((signal) => lower.includes(signal)); +} + +// Providers whose upstream surface emits Google's regional-availability +// refusal (GEO_BLOCK_SIGNALS above): Cloud Code / Gemini Code Assist — the +// antigravity executor (antigravity, agy) — and the Gemini Developer API +// (generativelanguage.googleapis.com; gemini, vertex). The gate matters +// because classifyProviderError is shared across every provider: an unrelated +// upstream returning a lookalike "not available in your region" must NOT be +// classified as an egress-fixable geo block, or it would get the non-terminal +// 24h exclusion treatment instead of that provider's own (possibly terminal) +// path. +function isGeoBlockEligibleProvider(provider?: string | null): boolean { + const p = (provider || "").toLowerCase(); + if ( + p === "antigravity" || + p === "agy" || + p === "gemini" || + p === "gemini-cli" || + p === "vertex" + ) { + return true; + } + if (p.includes("cloudcode") || p.includes("cloud-code")) return true; + // Registry-driven fallback: any provider whose upstream surface is the Cloud + // Code API (executor/format "antigravity") or the Gemini API (format + // "gemini") stays eligible even when a new provider id is added later. + if (!provider) return false; + const entry = getRegistryEntry(provider); + if (!entry) return false; + const surface = `${entry.executor || ""} ${entry.format || ""}`.toLowerCase(); + return surface.includes("antigravity") || surface.includes("gemini"); +} + // Cloudflare 1010 "Access denied ... blocked based on your browser's signature" — // a fingerprint/browser-like rejection issued by the CDN in front of an upstream // (e.g. opencode.ai/zen/v1), carrying error_code 1010 or error_name @@ -242,6 +298,24 @@ export function classifyProviderError( } if (statusCode === 402) return PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED; + + // Google regional-availability refusal (400 FAILED_PRECONDITION "... location + // is not supported ..."), scoped to the Google AI surfaces that emit it + // (Cloud Code / Gemini Code Assist + Gemini Developer API — see + // isGeoBlockEligibleProvider). Account-independent: every credential egresses + // from the same server region, so fallback to another account cannot succeed + // — but the connection must be cached as excluded so routing does not + // re-select it on every request and surface a cryptic generic 400. + // Non-terminal, like PROJECT_ROUTE_ERROR: the account becomes usable again + // once egress is routed through a supported-region proxy. + if ( + (statusCode === 400 || statusCode === 403) && + isGeoBlockEligibleProvider(provider) && + isGeoBlockedError(bodyStr) + ) { + return PROVIDER_ERROR_TYPES.GEO_BLOCKED; + } + if (statusCode === 403 && isCloudflareFingerprintRejection(bodyStr)) { // Cloudflare 1010 / error_name "browser_signature_banned": the CDN in front of the // upstream (e.g. opencode.ai/zen/v1) rejected the CLIENT's TLS/UA signature, not the diff --git a/src/app/api/providers/[id]/test/oauthTestConfig.ts b/src/app/api/providers/[id]/test/oauthTestConfig.ts index 52f8713ee2..41a9aa3f20 100644 --- a/src/app/api/providers/[id]/test/oauthTestConfig.ts +++ b/src/app/api/providers/[id]/test/oauthTestConfig.ts @@ -1,4 +1,38 @@ import { buildGitLabOAuthEndpoints, resolveGitLabOAuthBaseUrl } from "@/lib/oauth/gitlab"; +import { ANTIGRAVITY_RUNTIME_BASE_URLS } from "@omniroute/open-sse/config/antigravityUpstream.ts"; +import { getAntigravityContentHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts"; +import { getAntigravityClientProfile } from "@omniroute/open-sse/services/antigravityClientProfile.ts"; + +// Real model-surface probe for antigravity/agy. The previous probe only hit the +// OAuth userinfo endpoint, which is NOT geo-restricted — so "Test Connection" +// stayed green while every model call failed with "User location is not +// supported for the API use." Probe the actual Cloud Code model endpoint +// (streamGenerateContent) with a minimal body: +// 2xx -> model path reachable (auth ok) +// 400 geo -> egress location blocked (auth ok — NOT an account problem) +// 401/403 -> token bad +// Mirrors AntigravityExecutor.buildUrl/buildHeaders so the probe exercises the +// exact same surface as real requests. +function buildAntigravityProbe( + connection: { providerSpecificData?: unknown }, + accessToken: string +) { + const profile = getAntigravityClientProfile(connection as never); + return { + url: `${ANTIGRAVITY_RUNTIME_BASE_URLS[0]}/v1internal:streamGenerateContent?alt=sse`, + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + Accept: "text/event-stream", + ...getAntigravityContentHeaders(profile, accessToken), + }, + body: JSON.stringify({ + contents: [{ role: "user", parts: [{ text: "ping" }] }], + generationConfig: { maxOutputTokens: 1 }, + }), + }; +} const CLINE_OAUTH_TEST_CONFIG = { // Cline does not expose a stable lightweight auth probe. Validate token @@ -27,7 +61,34 @@ const XAI_CHAT_OAUTH_TEST_CONFIG = { // OAuth provider test endpoints. Extracted from route.ts (#7610) so adding a // provider entry doesn't grow the frozen route.ts file past its check-file-size // cap — this module carries no logic of its own beyond the GitLab URL builder. -export const OAUTH_TEST_CONFIG = { +// Probe request built at test time by provider-specific configs (e.g. +// antigravity), which need dynamic headers (client profile) the static fields +// cannot express. +export interface OAuthTestProbeRequest { + url: string; + method: string; + headers: Record; + body?: string; +} + +export interface OAuthTestConfigEntry { + url?: string; + method?: string; + authHeader?: string; + authPrefix?: string; + extraHeaders?: Record; + body?: string; + acceptStatuses?: number[]; + checkExpiry?: boolean; + refreshable?: boolean; + getUrl?: (connection: any) => string; + buildProbe?: ( + connection: any, + accessToken: string + ) => OAuthTestProbeRequest | Promise; +} + +export const OAUTH_TEST_CONFIG: Record = { claude: { // Claude doesn't have userinfo, we verify token exists and not expired checkExpiry: true, @@ -62,22 +123,18 @@ export const OAUTH_TEST_CONFIG = { refreshable: true, }, antigravity: { - url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", - method: "GET", - authHeader: "Authorization", - authPrefix: "Bearer ", + // Real model-surface probe (see buildAntigravityProbe above): userinfo-only + // probing stayed green while the model API was geo-blocked. + buildProbe: buildAntigravityProbe, refreshable: true, }, // `agy` is a separate connection id that shares the Antigravity backend and the same // Google OAuth token lifecycle (tokenRefresh.ts routes it to refreshGoogleToken), but // it was missing here — so "Test Connection" fell through to "Provider test not // supported", recorded testStatus="error", and painted the home topology node red on a - // perfectly good account. Probe the same userinfo endpoint as antigravity. + // perfectly good account. Probe the same model surface as antigravity. agy: { - url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", - method: "GET", - authHeader: "Authorization", - authPrefix: "Bearer ", + buildProbe: buildAntigravityProbe, refreshable: true, }, xai: XAI_CHAT_OAUTH_TEST_CONFIG, diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index 1201826c83..66cf2f6df9 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -25,6 +25,7 @@ import { removeConnectionHealth } from "@omniroute/open-sse/services/apiKeyRotat import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth"; import { buildApiKeyConnectionTestResult } from "./apiKeyTestResult"; import { OAUTH_TEST_CONFIG } from "./oauthTestConfig"; +import { isGeoBlockedError } from "@omniroute/open-sse/services/errorClassifier.ts"; // Bound the OAuth probe so a hung upstream can't block the connection-test queue // forever (#1449). Mirrors the 30s timeout the API-key path uses via validateProviderApiKey. @@ -438,20 +439,34 @@ export async function testOAuthConnection( // Call test endpoint try { - const headers = { - [config.authHeader]: `${config.authPrefix}${accessToken}`, - ...config.extraHeaders, - }; + // Provider-specific probe builders (e.g. antigravity) construct the full + // request — url/method/headers/body — because the real surface needs + // dynamic headers (client profile) that the static config cannot express. + const builtProbe = + typeof config.buildProbe === "function" + ? await config.buildProbe(connection, accessToken) + : null; + const headers = builtProbe + ? builtProbe.headers + : { + [config.authHeader]: `${config.authPrefix}${accessToken}`, + ...config.extraHeaders, + }; - const url = typeof config.getUrl === "function" ? config.getUrl(connection) : config.url; + const url = builtProbe + ? builtProbe.url + : typeof config.getUrl === "function" + ? config.getUrl(connection) + : config.url; const fetchInit: RequestInit = { - method: config.method, + method: builtProbe?.method ?? config.method, headers, signal: AbortSignal.timeout(timeoutMs), }; // Port of decolua/9router#347: providers like Codex must send a body so the // upstream returns 400 (auth ok) instead of 405/415. - if (config.body) fetchInit.body = config.body; + if (config.body && !builtProbe) fetchInit.body = config.body; + if (builtProbe?.body) fetchInit.body = builtProbe.body; const res = await fetch(url, fetchInit); // Port of decolua/9router#347: some providers (Codex) intentionally trigger a @@ -497,14 +512,20 @@ export async function testOAuthConnection( if (tokens) { // Retry with new token const retryInit: RequestInit = { - method: config.method, - headers: { - [config.authHeader]: `${config.authPrefix}${tokens.accessToken}`, - ...config.extraHeaders, - }, + method: builtProbe?.method ?? config.method, + headers: builtProbe + ? { + ...builtProbe.headers, + Authorization: `Bearer ${tokens.accessToken ?? accessToken}`, + } + : { + ...headers, + [config.authHeader]: `${config.authPrefix}${tokens.accessToken ?? accessToken}`, + }, signal: AbortSignal.timeout(timeoutMs), }; - if (config.body) retryInit.body = config.body; + if (builtProbe?.body) retryInit.body = builtProbe.body; + else if (config.body) retryInit.body = config.body; const retryRes = await fetch(url, retryInit); const retryAccepted = @@ -546,16 +567,25 @@ export async function testOAuthConnection( // #1444: read a 401/403 body so a deactivated account is labeled distinctly from a // revoked token. (The body is unread here for non-gitlab providers; the guard keeps - // it safe if it was already consumed.) + // it safe if it was already consumed.) antigravity/agy read any failure body so a + // geo-blocked egress location is labeled with an actionable message instead of a + // generic "API returned 400". const bodyText = - res.status === 401 || res.status === 403 ? await res.text().catch(() => "") : ""; - const error = isAccountDeactivatedMessage(bodyText) - ? "Account deactivated by the provider" - : res.status === 401 - ? "Token invalid or revoked" - : res.status === 403 - ? "Access denied" - : `API returned ${res.status}`; + res.status === 401 || + res.status === 403 || + connection.provider === "antigravity" || + connection.provider === "agy" + ? await res.text().catch(() => "") + : ""; + const error = isGeoBlockedError(bodyText) + ? "Egress location blocked by Google (User location is not supported). The Cloud Code API is not offered from this server's proxy exit region — route antigravity/agy through a proxy in a supported region (e.g. US/EU) or use a different provider. This is NOT an account problem." + : isAccountDeactivatedMessage(bodyText) + ? "Account deactivated by the provider" + : res.status === 401 + ? "Token invalid or revoked" + : res.status === 403 + ? "Access denied" + : `API returned ${res.status}`; return { valid: false, diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index f3ec47870a..da70522843 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -387,6 +387,7 @@ function resolveTerminalConnectionStatus( if (result.creditsExhausted || status === 402) return "credits_exhausted"; if ( providerErrorType === PROVIDER_ERROR_TYPES.PROJECT_ROUTE_ERROR || + providerErrorType === PROVIDER_ERROR_TYPES.GEO_BLOCKED || providerErrorType === PROVIDER_ERROR_TYPES.OAUTH_INVALID_TOKEN || // #1010: Cloudflare fingerprint rejection is the CDN refusing the CLIENT's // signature, not the account's credentials — never a terminal account state. diff --git a/tests/unit/antigravity-competitive-prompt-strip.test.ts b/tests/unit/antigravity-competitive-prompt-strip.test.ts new file mode 100644 index 0000000000..c7b6d3bf1e --- /dev/null +++ b/tests/unit/antigravity-competitive-prompt-strip.test.ts @@ -0,0 +1,64 @@ +/** + * Competitive system-prompt strip (port of decolua/9router b566b20, + * generalized): Antigravity's server-side filter flags system prompts + * advertising competing agents ("You are a Claude agent, built on + * Anthropic's Claude Agent SDK.") and answers with 429 RESOURCE_EXHAUSTED. + * The strip removes the identity sentences before dispatch. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { stripCompetitiveAgentPrompts } from "../../open-sse/executors/antigravity.ts"; + +test("strips the exact Claude Agent SDK identity line (9router b566b20 case)", () => { + const input = { + parts: [{ text: "You are a Claude agent, built on Anthropic's Claude Agent SDK." }], + }; + const out = stripCompetitiveAgentPrompts(input) as { parts: Array<{ text: string }> }; + assert.equal(out.parts[0].text, ""); +}); + +test("keeps the instruction text that follows the identity sentence", () => { + const input = { + parts: [ + { + text: + "You are a Claude agent, built on Anthropic's Claude Agent SDK.\n" + + "Answer concisely and cite sources.", + }, + ], + }; + const out = stripCompetitiveAgentPrompts(input) as { parts: Array<{ text: string }> }; + assert.equal(out.parts[0].text, "Answer concisely and cite sources."); +}); + +test("strips 'You are Claude Code' and Anthropic-created assistant lines", () => { + const input = { parts: [{ text: "You are Claude Code, an agentic coding tool." }] }; + const out = stripCompetitiveAgentPrompts(input) as { parts: Array<{ text: string }> }; + assert.equal(out.parts[0].text, ""); +}); + +test("leaves ordinary system prompts untouched (same reference, no allocation)", () => { + const input = { parts: [{ text: "You are a helpful assistant. Be concise." }] }; + const out = stripCompetitiveAgentPrompts(input); + assert.strictEqual(out, input, "must return the original reference when nothing matched"); +}); + +test("only rewrites matching parts in a multi-part system instruction", () => { + const input = { + parts: [ + { text: "You are a Claude agent, built on Anthropic's Claude Agent SDK." }, + { text: "Use the tools when available." }, + ], + }; + const out = stripCompetitiveAgentPrompts(input) as { parts: Array<{ text: string }> }; + assert.equal(out.parts[0].text, ""); + assert.equal(out.parts[1].text, "Use the tools when available."); +}); + +test("returns the input unchanged for non-systemInstruction shapes", () => { + const input = { contents: [{ role: "user", parts: [{ text: "hi" }] }] }; + assert.strictEqual(stripCompetitiveAgentPrompts(input), input); + assert.strictEqual(stripCompetitiveAgentPrompts(null), null); + assert.strictEqual(stripCompetitiveAgentPrompts(undefined), undefined); +}); diff --git a/tests/unit/antigravity-geoblock-resilience.test.ts b/tests/unit/antigravity-geoblock-resilience.test.ts new file mode 100644 index 0000000000..5f85e6f8a4 --- /dev/null +++ b/tests/unit/antigravity-geoblock-resilience.test.ts @@ -0,0 +1,189 @@ +/** + * Antigravity geo-block resilience (#PR): the Cloud Code / Gemini Code Assist + * model API refuses unsupported egress locations with 400 FAILED_PRECONDITION + * "User location is not supported for the API use." Previously this was + * classified as a generic 400 ("Antigravity upstream error (400)"), never + * excluded the account, and the dashboard connection test stayed green because + * it only probed the (non-geo-restricted) OAuth userinfo endpoint. + * + * Coverage: + * 1. classifyProviderError maps the geo refusal to GEO_BLOCKED (non-terminal), + * scoped to the Google AI surfaces that emit it (Cloud Code / Gemini API). + * 2. isGeoBlockedError recognizes the real Google wording and rejects lookalikes. + * 3. classify429 keeps Google's RESOURCE_EXHAUSTED-per-minute as rate_limited + * (established repo behavior — guards against future regressions here). + * 4. buildAntigravityUpstreamError surfaces an actionable geo message. + * 5. The dashboard probe for antigravity/agy hits the REAL model surface + * (streamGenerateContent), not userinfo. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { classifyProviderError, isGeoBlockedError, PROVIDER_ERROR_TYPES } = + await import("../../open-sse/services/errorClassifier.ts"); +const { classify429 } = await import("../../open-sse/services/antigravity429Engine.ts"); +const { buildAntigravityUpstreamError } = + await import("../../open-sse/executors/antigravityUpstreamError.ts"); +const { OAUTH_TEST_CONFIG } = + await import("../../src/app/api/providers/[id]/test/oauthTestConfig.ts"); + +const GEO_BODY = { + error: { + code: 400, + message: "User location is not supported for the API use.", + status: "FAILED_PRECONDITION", + }, +}; + +// ── 1. classifyProviderError ──────────────────────────────────────────────── + +test("geo refusal (400 FAILED_PRECONDITION) -> GEO_BLOCKED", () => { + assert.equal( + classifyProviderError(400, GEO_BODY, "antigravity"), + PROVIDER_ERROR_TYPES.GEO_BLOCKED + ); +}); + +test("geo refusal with a raw text body -> GEO_BLOCKED", () => { + assert.equal( + classifyProviderError( + 400, + '{"error":{"status":"FAILED_PRECONDITION","message":"User location is not supported for the API use."}}', + "agy" + ), + PROVIDER_ERROR_TYPES.GEO_BLOCKED + ); +}); + +test("generic 400 (not geo) does NOT classify as GEO_BLOCKED", () => { + const result = classifyProviderError(400, { error: { message: "bad request" } }, "antigravity"); + assert.notEqual(result, PROVIDER_ERROR_TYPES.GEO_BLOCKED); +}); + +test("429 stays RATE_LIMITED (geo classification is status-scoped)", () => { + assert.equal( + classifyProviderError(429, GEO_BODY, "antigravity"), + PROVIDER_ERROR_TYPES.RATE_LIMITED + ); +}); + +// ── 1b. provider scoping of GEO_BLOCKED ────────────────────────────────────── + +test("geo refusal from Gemini API / Vertex providers -> GEO_BLOCKED", () => { + assert.equal( + classifyProviderError(400, GEO_BODY, "gemini"), + PROVIDER_ERROR_TYPES.GEO_BLOCKED + ); + assert.equal( + classifyProviderError(400, GEO_BODY, "vertex"), + PROVIDER_ERROR_TYPES.GEO_BLOCKED + ); + assert.equal( + classifyProviderError(400, GEO_BODY, "gemini-cli"), + PROVIDER_ERROR_TYPES.GEO_BLOCKED + ); +}); + +test("geo-looking body from a non-Google provider does NOT classify as GEO_BLOCKED", () => { + // Falls through to the generic path (null for an unclassified 400): the 24h + // non-terminal exclusion is reserved for egress-fixable Google blocks — an + // unrelated provider's region wording may mean a permanent block. + assert.equal(classifyProviderError(400, GEO_BODY, "openai"), null); + assert.equal(classifyProviderError(400, GEO_BODY, "anthropic"), null); + assert.equal(classifyProviderError(400, GEO_BODY, "g4f-gemini"), null); + assert.equal( + classifyProviderError(400, "The API is not available in your region.", "mistral"), + null + ); +}); + +test("geo body with no provider does NOT classify as GEO_BLOCKED", () => { + assert.equal(classifyProviderError(400, GEO_BODY, undefined), null); +}); + +test("403 geo refusal stays GEO_BLOCKED for eligible providers", () => { + assert.equal( + classifyProviderError(403, GEO_BODY, "antigravity"), + PROVIDER_ERROR_TYPES.GEO_BLOCKED + ); +}); + +// ── 2. isGeoBlockedError ──────────────────────────────────────────────────── + +test("isGeoBlockedError matches Google wording variants", () => { + assert.equal(isGeoBlockedError("User location is not supported for the API use."), true); + assert.equal( + isGeoBlockedError('{"message":"This location is not supported for the API use"}'), + true + ); + assert.equal(isGeoBlockedError("The API is not available in your region."), true); +}); + +test("isGeoBlockedError rejects lookalike errors", () => { + assert.equal(isGeoBlockedError("Invalid API key"), false); + assert.equal(isGeoBlockedError("Quota exceeded for the API use"), false); + assert.equal(isGeoBlockedError("model not supported"), false); + assert.equal(isGeoBlockedError(""), false); +}); + +// ── 3. classify429: RESOURCE_EXHAUSTED stays rate_limited ─────────────────── + +test("classify429 keeps Google 'Resource has been exhausted (per minute)' as rate_limited", () => { + // Deliberate existing behavior (antigravity-429-quota-cooldown.test.ts): Google + // uses RESOURCE_EXHAUSTED for per-minute rate limits too, and the + // "(e.g. queries per minute limit was reached)" phrasing is the RPM case — + // short cooldown + same-auth retry, NOT a daily quota wall. + assert.equal( + classify429( + "RESOURCE_EXHAUSTED: Resource has been exhausted (e.g. queries per minute limit was reached)." + ), + "rate_limited" + ); + // A genuine quota-wall message still classifies as quota_exhausted. + assert.equal( + classify429("Individual quota reached. Contact your administrator."), + "quota_exhausted" + ); +}); + +// ── 4. buildAntigravityUpstreamError ──────────────────────────────────────── + +test("geo-blocked upstream error body carries an actionable hint", () => { + const body = buildAntigravityUpstreamError(400, "", JSON.stringify(GEO_BODY)) as { + error?: { message?: string }; + }; + assert.match(String(body.error?.message), /location is not supported/i); + assert.match(String(body.error?.message), /proxy in a supported region/i); + assert.match(String(body.error?.message), /connection test/i); +}); + +test("non-geo upstream error body is unchanged in shape", () => { + const body = buildAntigravityUpstreamError(500, "", '{"error":"boom"}') as { + error?: { message?: string }; + }; + assert.match(String(body.error?.message), /Antigravity upstream error \(500\)/); + assert.doesNotMatch(String(body.error?.message), /supported region/i); +}); + +// ── 5. dashboard probe hits the real model surface ────────────────────────── + +test("antigravity/agy connection test probes streamGenerateContent, not userinfo", async () => { + for (const provider of ["antigravity", "agy"]) { + const entry = OAUTH_TEST_CONFIG[provider]; + assert.ok(entry, `${provider} has a test config`); + assert.equal(typeof entry.buildProbe, "function", `${provider} uses a buildProbe`); + + const probe = await entry.buildProbe( + { providerSpecificData: { clientProfile: "ide" } }, + "sk-test-token" + ); + assert.match(probe.url, /v1internal:streamGenerateContent\?alt=sse/); + assert.equal(probe.method, "POST"); + assert.match(probe.headers.Authorization, /Bearer sk-test-token/); + assert.equal(probe.headers["Content-Type"], "application/json"); + assert.ok(probe.body, "probe carries a minimal generation body"); + const parsedBody = JSON.parse(probe.body as string); + assert.ok(Array.isArray(parsedBody.contents)); + assert.equal(parsedBody.generationConfig.maxOutputTokens, 1); + } +});