feat(antigravity): auto-rotate BYOP accounts to siblings on GCP_PROJECT_REQUIRED (#10470)

Obrigado — follow-up bem feito do #10424: um 422 GCP_PROJECT_REQUIRED (BYOP — a conta Google precisa trazer seu próprio GCP Project) é específico da CONTA, não do provider inteiro, então o fast-fail anterior falhava mesmo quando uma conta antigravity irmã saudável poderia atender o request. Agora rotaciona automaticamente para a conta irmã (excluindo a conta BYOP por 24h) e só surfaça o erro acionável quando não há irmã disponível. Estado de rotação rastreado separado de maxAttempts, então falhas normais do antigravity nunca ganham uma segunda chance (sem dispatch duplo).

Validação (worktree própria a partir de origin/release/v3.8.50, merge limpo — auto-merge em chatCore.ts, 0 conflitos reais):
- typecheck:core limpo, complexity/cognitive-complexity dentro do baseline
- tests/unit/antigravity-byop-account-rotation.test.ts + error-classifier.test.ts — 36/36 passando
This commit is contained in:
Rouzbeh†
2026-08-20 23:30:43 +03:30
committed by GitHub
parent c40ff16a1d
commit 25ba4f2a34
6 changed files with 386 additions and 1 deletions

View File

@@ -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

View File

@@ -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,
};
/**

View File

@@ -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

View File

@@ -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;

View File

@@ -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();
}
});

View File

@@ -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);
});