mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-16 20:22:21 +03:00
fix(antigravity): classify geo-blocked egress, exclude account, real connection probe (#10420)
* fix(antigravity): classify geo-blocked egress, exclude account, real connection probe Google refuses the Cloud Code model API from unsupported egress locations with 400 FAILED_PRECONDITION "User location is not supported for the API use." Previously this surfaced as a cryptic "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. - errorClassifier: new GEO_BLOCKED type + isGeoBlockedError detection (400/403 + location-not-supported wording); non-terminal classification. - chatCore fallback: GEO_BLOCKED marks the connection and caches a 24h rate-limit-until exclusion so routing moves to other accounts instead of re-selecting the same one; never bans/expires the account. - auth: GEO_BLOCKED joins the non-terminal group (no banned/expired state). - antigravityUpstreamError: geo refusals carry an actionable message (egress location vs account problem, proxy-in-supported-region guidance). - connection test: antigravity/agy now probe the REAL streamGenerateContent surface (buildProbe), so a green tick means the model path actually works and a geo-blocked egress shows red with a clear diagnosis. * chore(changelog): fragment for #10420 antigravity geo-block resilience * chore(pr): drop prettier-version drift noise, keep only real hunks The earlier format pass (local prettier differs from the repo's pinned version) rewrapped unrelated lines in chatCore.ts and the provider test route. Restore the base formatting and re-apply only the GEO_BLOCKED fallback branch and the buildProbe connection-test changes. * fix(antigravity): strip competing-agent system prompts (429 RESOURCE_EXHAUSTED) Port decolua/9router b566b20, generalized: Antigravity flags system prompts advertising competing agents ('You are a Claude agent, built on Anthropic's Claude Agent SDK.' — Zed, Claude Code, etc.) and answers with a 429 quota error. sanitizeAntigravityGeminiRequest now strips known competitor identity sentences from systemInstruction.parts before dispatch; surrounding instruction text is untouched and non-matching prompts pass through without allocation. * chore(changelog): cover competitive prompt strip in #10420 fragment * fix(antigravity): scope GEO_BLOCKED classification to Google AI surfaces Address reviewer feedback: classifyProviderError is shared across every provider, so a lookalike 'not available in your region' body from an unrelated upstream must not receive the egress-fixable 24h exclusion treatment. Gate GEO_BLOCKED behind isGeoBlockEligibleProvider, which matches the surfaces that actually emit Google's regional-availability refusal: Cloud Code / Gemini Code Assist (antigravity, agy, cloudcode*), the Gemini Developer API (gemini, gemini-cli, vertex), plus a registry-driven fallback on executor/format. Non-Google providers fall through to their existing 400/403 classification (typically null for an unclassified 400), so a permanent block still follows its own path. * ci: re-run quality gates Trigger a fresh CI run for the PR: the previous run's 'Vitest (fast-path)' job failed in 'npm ci' because the onnxruntime-node postinstall could not download its binary from the Microsoft CDN (connect ETIMEDOUT 150.171.109.118:443). No tests ran; no code changed in this commit. * fix(antigravity): guard provider before registry lookup in geo-block gate isGeoBlockEligibleProvider passes the raw provider (string | null | undefined) to getRegistryEntry(provider: string), failing typecheck:core and the ts7-diagnostics ratchet (TS2345 at errorClassifier.ts:166). Add an explicit null guard; runtime behavior is unchanged — a falsy provider already resolved to !entry -> false. * ci: re-run quality gates (vitest npm ci onnxruntime CDN flake) --------- Co-authored-by: Rouzbeh <rqzbeh@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -339,6 +339,45 @@ function asRecord(value: unknown): Record<string, unknown> | 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<Record<string, unknown>>) : [];
|
||||
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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string>;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
export interface OAuthTestConfigEntry {
|
||||
url?: string;
|
||||
method?: string;
|
||||
authHeader?: string;
|
||||
authPrefix?: string;
|
||||
extraHeaders?: Record<string, string>;
|
||||
body?: string;
|
||||
acceptStatuses?: number[];
|
||||
checkExpiry?: boolean;
|
||||
refreshable?: boolean;
|
||||
getUrl?: (connection: any) => string;
|
||||
buildProbe?: (
|
||||
connection: any,
|
||||
accessToken: string
|
||||
) => OAuthTestProbeRequest | Promise<OAuthTestProbeRequest>;
|
||||
}
|
||||
|
||||
export const OAUTH_TEST_CONFIG: Record<string, OAuthTestConfigEntry> = {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
64
tests/unit/antigravity-competitive-prompt-strip.test.ts
Normal file
64
tests/unit/antigravity-competitive-prompt-strip.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
189
tests/unit/antigravity-geoblock-resilience.test.ts
Normal file
189
tests/unit/antigravity-geoblock-resilience.test.ts
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user