diff --git a/changelog.d/fixes/9908-antigravity-image-quota-account-rotation.md b/changelog.d/fixes/9908-antigravity-image-quota-account-rotation.md new file mode 100644 index 0000000000..6f6f802c23 --- /dev/null +++ b/changelog.d/fixes/9908-antigravity-image-quota-account-rotation.md @@ -0,0 +1 @@ +- Fix Antigravity image generation not rotating to another account when the upstream returns an explicit quota-exhausted 429, so a second configured account with available quota is no longer stuck behind the first account's terminal quota error. diff --git a/src/sse/services/imageCredentialRetry.ts b/src/sse/services/imageCredentialRetry.ts index 6f804bbf1f..beda34ac63 100644 --- a/src/sse/services/imageCredentialRetry.ts +++ b/src/sse/services/imageCredentialRetry.ts @@ -1,3 +1,4 @@ +import { classify429 } from "@omniroute/open-sse/services/antigravity429Engine.ts"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; import { getProviderCredentialsWithQuotaPreflight } from "./auth"; @@ -50,6 +51,29 @@ function isCredentialSentinel(credentials: any): boolean { return Boolean(credentials?.allRateLimited || credentials?.allExpired); } +/** + * Image generation is non-idempotent, so account rotation stays deliberately + * narrower than chat failover. Antigravity's explicit exhausted-quota signal + * is safe to retry on another account; an ordinary 429 is not evidence that a + * different account helps and must not cause account rotation. + */ +export function isAntigravityImageQuotaExhausted( + provider: string, + result: ImageGenerationResult +): boolean { + if (provider !== "antigravity" || Number(result.status) !== 429) return false; + + let errorText = ""; + try { + errorText = + typeof result.error === "string" ? result.error : JSON.stringify(result.error ?? ""); + } catch { + return false; + } + + return classify429(errorText) === "quota_exhausted"; +} + async function defaultSelectNextCredentials( provider: string, requestedModel: string | null, @@ -110,8 +134,11 @@ export async function executeImageWithCredentialFallback({ lastCredentials = currentCredentials; lastResult = await execute(currentCredentials); - const isAuthFailure = Number(lastResult.status) === 401 || lastResult.retryable === true; - if (lastResult.success || !isAuthFailure || !connectionId) { + const shouldTryAnotherAccount = + Number(lastResult.status) === 401 || + lastResult.retryable === true || + isAntigravityImageQuotaExhausted(provider, lastResult); + if (lastResult.success || !shouldTryAnotherAccount || !connectionId) { return { credentials: lastCredentials, result: lastResult }; } diff --git a/tests/unit/antigravity-image-credential-retry.test.ts b/tests/unit/antigravity-image-credential-retry.test.ts new file mode 100644 index 0000000000..bd42bc268e --- /dev/null +++ b/tests/unit/antigravity-image-credential-retry.test.ts @@ -0,0 +1,48 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { isAntigravityImageQuotaExhausted } from "../../src/sse/services/imageCredentialRetry.ts"; + +test("Antigravity image quota-exhausted 429 is the only 429 eligible for account rotation", () => { + assert.equal( + isAntigravityImageQuotaExhausted("antigravity", { + success: false, + status: 429, + error: { error: { message: "Individual quota reached" } }, + }), + true + ); +}); + +test("ordinary Hermes-shaped 429 does not create a rotation/cooldown signal", () => { + assert.equal( + isAntigravityImageQuotaExhausted("antigravity", { + success: false, + status: 429, + error: { error: { message: "RESOURCE_EXHAUSTED: malformed system payload" } }, + }), + false + ); +}); + +test("ordinary image rate-limit 429 does not create a rotation/cooldown signal", () => { + assert.equal( + isAntigravityImageQuotaExhausted("antigravity", { + success: false, + status: 429, + error: { error: { message: "too many requests; retry later" } }, + }), + false + ); +}); + +test("non-Antigravity 429 is not eligible for Antigravity account rotation", () => { + assert.equal( + isAntigravityImageQuotaExhausted("openai", { + success: false, + status: 429, + error: { error: { message: "Individual quota reached" } }, + }), + false + ); +});