diff --git a/changelog.d/fixes/10470-antigravity-byop-account-rotation.md b/changelog.d/fixes/10470-antigravity-byop-account-rotation.md new file mode 100644 index 0000000000..9ec58e152a --- /dev/null +++ b/changelog.d/fixes/10470-antigravity-byop-account-rotation.md @@ -0,0 +1 @@ +- **fix(antigravity):** automatically rotate to a sibling account when one is BYOP (GCP Project ID required, `gcp_project_required` 422) — the account is excluded from selection for 24h and the request succeeds via another account instead of failing fast; the actionable 422 is surfaced only when no sibling exists (follow-up to the #10424 BYOP fast-fail) ([#10470](https://github.com/diegosouzapw/OmniRoute/pull/10470)) — thanks @rqzbeh diff --git a/open-sse/config/errorConfig.ts b/open-sse/config/errorConfig.ts index b326af7378..7e7355948f 100644 --- a/open-sse/config/errorConfig.ts +++ b/open-sse/config/errorConfig.ts @@ -81,6 +81,10 @@ export const COOLDOWN_MS = { // 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, + // Antigravity BYOP (GCP_PROJECT_REQUIRED): nothing changes on the account + // until the operator enters a Project ID, so keep the connection excluded + // from selection for a long window (mirrors the geo-blocked treatment). + gcpProjectRequired: 24 * 60 * 60 * 1000, }; /** diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 3555467a40..e955ffd5d3 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -2940,7 +2940,18 @@ export async function handleChatCore({ ? (extractSessionAffinityKey(body, clientRawRequest?.headers) ?? null) : null; - while (attempts < maxAttempts) { + // ── Antigravity BYOP 422 account-rotation state ───────────────────── + // A GCP_PROJECT_REQUIRED 422 is account-specific (that Google + // account lacks a GCP Project ID). Rotate to a sibling antigravity + // account instead of surfacing the error, so multi-account setups + // keep working without user action. Tracked separately from + // maxAttempts so non-BYOP antigravity failures never get a second + // shot (no double upstream calls). + const antigravityByopExcludedIds: string[] = []; + let antigravityByopRotationPending = false; + + while (attempts < maxAttempts || antigravityByopRotationPending) { + antigravityByopRotationPending = false; // consumed per iteration trace("pre_executor", { attempt: attempts }); updatePendingScope(pendingScope, { stage: "sending_to_provider", @@ -3187,6 +3198,55 @@ export async function handleChatCore({ continue; } + // ── Antigravity BYOP 422 account rotation ─────────────────────── + // GCP_PROJECT_REQUIRED (422, code gcp_project_required) means + // THIS Google account must Bring Its Own GCP Project. Mark the + // connection excluded (rateLimitedUntil, best-effort) and rotate + // to a sibling antigravity account so the request succeeds + // without user action. When no sibling exists (or all are BYOP), + // fall through: the error-state block excludes the connection + // and the actionable 422 is surfaced. + if (provider === "antigravity" && res.response.status === 422) { + const byopBody = await res.response + .clone() + .text() + .catch(() => ""); + if (byopBody.includes("gcp_project_required")) { + const byopFailedId = + executionConnectionId || credentials?.connectionId || connectionId; + if (byopFailedId) { + if (!antigravityByopExcludedIds.includes(String(byopFailedId))) { + antigravityByopExcludedIds.push(String(byopFailedId)); + } + try { + const { setConnectionRateLimitUntil } = await import("@/lib/db/providers"); + setConnectionRateLimitUntil( + String(byopFailedId), + Date.now() + COOLDOWN_MS.gcpProjectRequired + ); + } catch { + // best-effort — never break the rotation path + } + } + const byopNextCreds = await getProviderCredentials( + "antigravity", + null, + null, + modelToCall || model || requestedModel || null, + { excludeConnectionIds: [...antigravityByopExcludedIds] } + ).catch(() => null); + if (byopNextCreds && !byopNextCreds.allRateLimited) { + log?.warn?.( + "ANTIGRAVITY_BYOP_ROTATION", + `BYOP 422 on connection ${String(byopFailedId).slice(0, 8)} → rotating to ${String(byopNextCreds.connectionId).slice(0, 8)}` + ); + Object.assign(credentials, byopNextCreds); + antigravityByopRotationPending = true; + continue; + } + } + } + // For streaming: release the semaphore when the client drains or cancels the stream. if (stream) { const originalBody = res.response.body; @@ -4195,6 +4255,27 @@ export async function handleChatCore({ console.warn( `[provider] Node ${errorConnectionId} geo-blocked (${statusCode}) — excluded for ${Math.ceil(geoCooldownMs / 1000)}s, trying other accounts` ); + } else if (errorType === PROVIDER_ERROR_TYPES.GCP_PROJECT_REQUIRED) { + // Antigravity BYOP: the account must Bring Its Own GCP Project. + // Account-specific and fixable by entering a Project ID — never a + // model lockout, never a ban. Exclude the connection for the + // cooldown window so selection prefers sibling accounts; the 422 + // body carries the actionable message when no sibling is available. + const byopCooldownMs = COOLDOWN_MS.gcpProjectRequired ?? 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() + byopCooldownMs); + } catch { + // best-effort — never break the error path + } + console.warn( + `[provider] Node ${errorConnectionId} GCP project required (${statusCode}) — excluded for ${Math.ceil(byopCooldownMs / 1000)}s, routing to other accounts (enter a Project ID to restore)` + ); } 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 43c1aa3079..735f44eb08 100644 --- a/open-sse/services/errorClassifier.ts +++ b/open-sse/services/errorClassifier.ts @@ -80,6 +80,10 @@ export const PROVIDER_ERROR_TYPES = { MODEL_NOT_FOUND: "model_not_found", FINGERPRINT_REJECTION: "fingerprint_rejection", GEO_BLOCKED: "geo_blocked", + // Antigravity BYOP fast-fail (executor 422, code gcp_project_required): the + // Google account must Bring Its Own GCP Project. Account-specific and + // fixable by entering a Project ID — never a model lockout and never a ban. + GCP_PROJECT_REQUIRED: "gcp_project_required", }; export const CONTEXT_OVERFLOW_SIGNALS = [ @@ -385,6 +389,15 @@ export function classifyProviderError( } if (statusCode >= 500) return PROVIDER_ERROR_TYPES.SERVER_ERROR; + // Antigravity BYOP fast-fail (executor emits 422 with code + // gcp_project_required when the Google account must Bring Its Own GCP + // Project). Account-specific and fixable by entering a Project ID in the + // dashboard — classified separately so chatCore rotates to sibling accounts + // and excludes the connection instead of locking the model or banning it. + if (statusCode === 422 && bodyStr.includes("gcp_project_required")) { + return PROVIDER_ERROR_TYPES.GCP_PROJECT_REQUIRED; + } + if (statusCode === 400) { if (isContextOverflow(bodyStr)) { return PROVIDER_ERROR_TYPES.CONTEXT_OVERFLOW; diff --git a/tests/unit/antigravity-byop-account-rotation.test.ts b/tests/unit/antigravity-byop-account-rotation.test.ts new file mode 100644 index 0000000000..cba1318d16 --- /dev/null +++ b/tests/unit/antigravity-byop-account-rotation.test.ts @@ -0,0 +1,253 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createChatPipelineHarness } from "../integration/_chatPipelineHarness.ts"; + +// Regression guard for the Antigravity BYOP account-rotation follow-up: +// a GCP_PROJECT_REQUIRED 422 is account-specific (that Google account lacks +// a GCP Project ID), so chatCore must mark the account excluded and rotate +// to a sibling antigravity account instead of surfacing the error. When no +// sibling exists, the actionable 422 fast-fail is surfaced and the connection +// is excluded so selection prefers any other account. +const harness = await createChatPipelineHarness("antigravity-byop-rotation"); +const { BaseExecutor, buildRequest, handleChat, resetStorage, settingsDb } = harness; +const providersDb = await import("../../src/lib/db/providers.ts"); +const { clearAntigravityProjectCache } = + await import("../../open-sse/services/antigravityProjectBootstrap.ts"); +const { seedAntigravityIdeVersionCache, seedAntigravityCliVersionCache } = + await import("../../open-sse/services/antigravityVersion.ts"); + +test.beforeEach(async () => { + BaseExecutor.RETRY_CONFIG.delayMs = 0; + process.env.ANTIGRAVITY_CREDITS = "off"; + await resetStorage(); + await settingsDb.updateSettings({ requestRetry: 0, maxRetryIntervalSec: 0 }); + clearAntigravityProjectCache(); + seedAntigravityIdeVersionCache("2026.04.17-byop-rotation-test"); + seedAntigravityCliVersionCache("2026.04.17-byop-rotation-test"); +}); + +test.afterEach(() => { + clearAntigravityProjectCache(); + delete process.env.ANTIGRAVITY_CREDITS; +}); + +test.after(async () => { + await harness.cleanup(); +}); + +async function createAntigravityAccount(options: { + name: string; + email: string; + accessToken: string; + refreshToken: string; + priority?: number; +}) { + const connection = await providersDb.createProviderConnection({ + provider: "antigravity", + authType: "oauth", + name: options.name, + email: options.email, + accessToken: options.accessToken, + refreshToken: options.refreshToken, + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + providerSpecificData: {}, + isActive: true, + testStatus: "active", + priority: options.priority, + }); + assert(connection && typeof connection.id === "string"); + return connection; +} + +test("Antigravity BYOP 422 rotates to a sibling account and the request succeeds", async () => { + const byopAccount = await createAntigravityAccount({ + name: "antigravity-byop-a", + email: "byop-a@example.test", + accessToken: "fake-byop-account-a-token", + refreshToken: "fake-byop-account-a-refresh", + priority: 1, // selected first — forces the rotation path + }); + const healthyAccount = await createAntigravityAccount({ + name: "antigravity-healthy-b", + email: "byop-b@example.test", + accessToken: "fake-healthy-account-b-token", + refreshToken: "fake-healthy-account-b-refresh", + priority: 2, + }); + + let onboardCallsForA = 0; + const modelCalls: Array<{ token: string }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + if (request.url.startsWith("https://oauth2.googleapis.com/token")) { + // Antigravity OAuth refreshes mid-flow; echo each account's own token so + // the executor keeps using the per-account identity below. + const form = await request.text().catch(() => ""); + const refreshMatch = form.match(/refresh_token=([^&]+)/); + const refreshToken = refreshMatch ? decodeURIComponent(refreshMatch[1]) : ""; + const accessToken = + refreshToken === "fake-byop-account-a-refresh" + ? "fake-byop-account-a-token" + : "fake-healthy-account-b-token"; + return new Response(JSON.stringify({ access_token: accessToken, expires_in: 3600 }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (request.url.endsWith(":loadCodeAssist")) { + const token = (request.headers.get("authorization") || "").replace(/^Bearer\s+/i, ""); + if (token === "fake-healthy-account-b-token") { + // Sibling account owns a Cloud Code project — discovery succeeds. + return new Response( + JSON.stringify({ cloudaicompanionProject: "projects/healthy-b-project" }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + // BYOP account: empty discovery — forces the onboardUser path. + return new Response("{}", { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (request.url.endsWith(":onboardUser")) { + const token = (request.headers.get("authorization") || "").replace(/^Bearer\s+/i, ""); + if (token === "fake-byop-account-a-token") { + onboardCallsForA += 1; + // 200 done WITHOUT cloudaicompanionProject → Google BYOP (tracked #8491). + return new Response(JSON.stringify({ done: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + // Healthy account: onboarding creates the project. + return new Response( + JSON.stringify({ + done: true, + cloudaicompanionProject: { name: "projects/healthy-b-project" }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + if (request.url.includes("cloudcode-pa.googleapis.com")) { + modelCalls.push({ + token: (request.headers.get("authorization") || "").replace(/^Bearer\s+/i, ""), + }); + // The executor always uses the SSE endpoint (streamGenerateContent?alt=sse), + // even for non-streaming requests. + return new Response( + 'data: {"response":{"candidates":[{"content":{"parts":[{"text":"ok from account B"}]},"finishReason":"STOP"}]}}\n\n', + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ); + } + throw new Error(`Unexpected external fetch: ${request.url}`); + }; + + try { + const response = await handleChat( + buildRequest({ + body: { + model: "antigravity/gemini-2.5-flash", + stream: false, + messages: [{ role: "user", content: "hello" }], + }, + }) + ); + + assert.equal(response.status, 200); + const bodyText = await response.text().catch(() => ""); + assert.match(bodyText, /ok from account B/); + + // The model call must have gone out with the SIBLING account's token. + assert.ok(modelCalls.length >= 1, "model call should have been made"); + assert.equal(modelCalls[0].token, "fake-healthy-account-b-token"); + + // BYOP detection ran exactly once for account A (cached per token). + assert.equal(onboardCallsForA, 1); + + // Account A is excluded (rateLimitedUntil set in the future). + const updatedA = await providersDb.getProviderConnectionById(byopAccount.id); + assert.ok( + updatedA && Number(updatedA.rateLimitedUntil) > Date.now(), + "BYOP account should be excluded from selection" + ); + // Account B must NOT be excluded. + const updatedB = await providersDb.getProviderConnectionById(healthyAccount.id); + assert.ok( + !updatedB || + !Number(updatedB.rateLimitedUntil) || + Number(updatedB.rateLimitedUntil) <= Date.now(), + "healthy sibling account must not be excluded" + ); + } finally { + globalThis.fetch = originalFetch; + clearAntigravityProjectCache(); + } +}); + +test("Antigravity BYOP with no sibling account surfaces the actionable 422 and excludes the connection", async () => { + const byopAccount = await createAntigravityAccount({ + name: "antigravity-byop-only", + email: "byop-only@example.test", + accessToken: "fake-byop-only-token", + refreshToken: "fake-byop-only-refresh", + priority: 1, + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + if (request.url.startsWith("https://oauth2.googleapis.com/token")) { + return new Response( + JSON.stringify({ access_token: "fake-byop-only-token", expires_in: 3600 }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + if (request.url.endsWith(":loadCodeAssist")) { + return new Response("{}", { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (request.url.endsWith(":onboardUser")) { + // BYOP: 200 done WITHOUT cloudaicompanionProject. + return new Response(JSON.stringify({ done: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`Unexpected external fetch: ${request.url}`); + }; + + try { + const response = await handleChat( + buildRequest({ + body: { + model: "antigravity/gemini-2.5-flash", + stream: false, + messages: [{ role: "user", content: "hello" }], + }, + }) + ); + const payload = (await response.json()) as { + error?: { code?: string; message?: string }; + }; + + assert.equal(response.status, 422); + // chatCore's error formatter rebuilds the body, so the code may be + // generic — the actionable message must survive (same assertion as the + // existing BYOP chat test). + assert.match(String(payload.error?.message), /GCP_PROJECT_REQUIRED/); + + // No sibling exists, so the connection is excluded for future requests. + const updated = await providersDb.getProviderConnectionById(byopAccount.id); + assert.ok( + updated && Number(updated.rateLimitedUntil) > Date.now(), + "BYOP account should be excluded from selection" + ); + } finally { + globalThis.fetch = originalFetch; + clearAntigravityProjectCache(); + } +}); diff --git a/tests/unit/error-classifier.test.ts b/tests/unit/error-classifier.test.ts index d9259f5355..c4b7e8b2d4 100644 --- a/tests/unit/error-classifier.test.ts +++ b/tests/unit/error-classifier.test.ts @@ -368,3 +368,36 @@ test("isCloudflareFingerprintRejection: space-separated and URL-path forms match "URL path" ); }); + +test("classifyProviderError: 422 + gcp_project_required => GCP_PROJECT_REQUIRED (BYOP fast-fail)", () => { + const body = JSON.stringify({ + error: { + message: + "GCP_PROJECT_REQUIRED: Google Antigravity now requires a free GCP Project ID. " + + "Create one at console.cloud.google.com and enter it in Providers → Antigravity.", + type: "gcp_project_required", + code: "gcp_project_required", + }, + }); + assert.equal( + classifyProviderError(422, body, "antigravity"), + PROVIDER_ERROR_TYPES.GCP_PROJECT_REQUIRED + ); +}); + +test("classifyProviderError: 422 without the BYOP code stays unclassified (no model lockout)", () => { + // The sibling missing-project error (code missing_project_id) and any other + // 422 must NOT map to GCP_PROJECT_REQUIRED — and never to MODEL_NOT_FOUND, + // so chatCore keeps its fail-closed behavior without locking the model. + assert.equal( + classifyProviderError( + 422, + JSON.stringify({ + error: { code: "missing_project_id", message: "Missing Google projectId" }, + }), + "antigravity" + ), + null + ); + assert.equal(classifyProviderError(422, "some other body", "antigravity"), null); +});