mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-22 15:12:23 +03:00
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:
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
253
tests/unit/antigravity-byop-account-rotation.test.ts
Normal file
253
tests/unit/antigravity-byop-account-rotation.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user