diff --git a/src/oauth/account-quota-rank.ts b/src/oauth/account-quota-rank.ts index 6582a29d3b0..e72a44b0c56 100644 --- a/src/oauth/account-quota-rank.ts +++ b/src/oauth/account-quota-rank.ts @@ -14,8 +14,8 @@ import { getCachedProviderAccountQuota, hasPassiveAccountQuota } from "../provid import { kiroAccountEvidence } from "../providers/kiro-usage"; import type { ProviderAccount } from "./types"; -/** Antigravity hosts Gemini and Claude windows on one account; ranking must not mix them. */ -export type QuotaModelFamily = "gem" | "cla"; +/** Antigravity hosts Gemini, Claude, and Argon windows on one account; ranking must not mix them. */ +export type QuotaModelFamily = "gem" | "cla" | "argon"; export function classifyModelFamilyForQuota( provider: string, @@ -27,6 +27,8 @@ export function classifyModelFamilyForQuota( const id = modelId.toLowerCase(); // Gemma is not Gemini: a substring/prefix match would poison Gemini ranking. if (/(?:^|[^a-z])gemma(?:[^a-z]|$)/.test(id)) return undefined; + // Gemini 4 Argon has a separate dedicated weekly quota bucket from standard Gemini 3.x models. + if (/(?:^|[^a-z])argon(?:[^a-z]|$)/.test(id)) return "argon"; // Catalog ids are gemini-*, never a bare gem- token. Window labels still match Gem via // windowMatchesFamily; this classifier is only for request model ids. if (/(?:^|[^a-z])gemini(?:[^a-z]|$)/.test(id)) return "gem"; @@ -42,6 +44,7 @@ export function classifyModelFamilyForQuota( function windowMatchesFamily(label: string, family: QuotaModelFamily): boolean { const token = label.trim().split(/[\s(/]+/)[0] ?? ""; + if (family === "argon") return /^argon$/i.test(token); if (family === "gem") return /^gem(?:ini)?$/i.test(token); return /^cla(?:ude)?$/i.test(token); } diff --git a/src/oauth/generic-account-failover.ts b/src/oauth/generic-account-failover.ts index 30fa971eef0..94d7fd1121a 100644 --- a/src/oauth/generic-account-failover.ts +++ b/src/oauth/generic-account-failover.ts @@ -42,7 +42,7 @@ import type { KiroRefusalKind } from "../adapters/kiro-refusal"; import { kiroAccountEvidence } from "../providers/kiro-usage"; import { kiroEvidenceIdentity } from "../providers/kiro-account-state-disk"; import { kiroAccountSupportsModel } from "../providers/kiro-model-catalog"; -import { antigravityAccountSupportsModel } from "../providers/antigravity-account-roster"; +import { antigravityAccountSupportsModel, recordAntigravityModelDenial } from "../providers/antigravity-account-roster"; import { ACCOUNT_QUOTA_TTL_MS } from "../providers/quota-wire"; import { sweepExpiredOnWrite } from "../lib/state-store-sweeper"; import { subscribeOAuthAccountPauseChanges } from "../lib/account-selection-events"; @@ -435,6 +435,29 @@ export function rotateAntigravityAccountOnAuthRefusal( return ring.find(id => id !== failedAccountId && eligible.has(id)) ?? null; } +/** Select an alternate Antigravity account when Google CCA rejects a model with 404. */ +export function rotateAntigravityAccountOnModelNotFound( + poolActivated: boolean, + failedAccountId: string, + requestedModelId: string | null | undefined, + now = Date.now(), +): string | null { + if (!poolActivated || !requestedModelId) return null; + recordAntigravityModelDenial("google-antigravity", failedAccountId, requestedModelId, now); + const set = getAccountSet("google-antigravity"); + if (!set || set.accounts.length < 2) return null; + const family = classifyModelFamilyForQuota("google-antigravity", requestedModelId); + const eligibleIds = eligibleIdsIn(set, "google-antigravity", now, family); + const candidates = eligibleIds.filter(id => accountListsModel("google-antigravity", id, requestedModelId) !== false); + if (candidates.length === 0) return null; + const positive = candidates.filter(id => accountListsModel("google-antigravity", id, requestedModelId) === true); + const pool = positive.length > 0 ? positive : candidates; + const order = set.accounts.map(row => row.id); + const start = order.indexOf(failedAccountId); + const ring = start >= 0 ? [...order.slice(start + 1), ...order.slice(0, start)] : order; + return ring.find(id => id !== failedAccountId && pool.includes(id)) ?? null; +} + /** Generic pool strategies the kernel can actually run. `quota` IS the pre-kernel path. */ type ActiveGenericStrategy = "round-robin" | "fill-first" | "reset-first" | "least-loaded"; @@ -754,13 +777,19 @@ export function preferredInitialAccount( const modelEligible = preferKiroModelSupport(providerName, eligibleFailoverAccounts(providerName, now, classifyModelFamilyForQuota(providerName, requestedModelId)), requestedModelId, config); + const activeListed = active && requestedModelId ? accountListsModel(providerName, active, requestedModelId) : undefined; const activeLacks = (providerName === "kiro" || providerName === "google-antigravity") && !!active && !!requestedModelId - && accountListsModel(providerName, active, requestedModelId) === false; + && activeListed === false; + // Only a PROVEN omission moves the request: an account with no cached roster stays usable, + // which is why the widened search here is the reactively-denied sibling, not any unknown one. const listingSibling = activeLacks ? modelEligible.find(id => id !== active && (providerName === "kiro" - ? kiroModelListingHasRoom(config, id, requestedModelId!) - : accountListsModel(providerName, id, requestedModelId!) === true)) + ? kiroModelListingHasRoom(config, id, requestedModelId!) + : accountListsModel(providerName, id, requestedModelId!) === true)) + ?? (providerName === "google-antigravity" + ? modelEligible.find(id => id !== active && accountListsModel(providerName, id, requestedModelId!) === undefined) + : undefined) : undefined; if (listingSibling) return listingSibling; diff --git a/src/providers/antigravity-account-roster.ts b/src/providers/antigravity-account-roster.ts index 051798f3aa9..a95c4b2a47b 100644 --- a/src/providers/antigravity-account-roster.ts +++ b/src/providers/antigravity-account-roster.ts @@ -22,6 +22,9 @@ import { ANTIGRAVITY_MODEL_ALIASES, canonicalAntigravityUsageModel, type Antigra /** A roster older than this is treated as unknown; entitlements change with plan and rollout. */ export const ANTIGRAVITY_ACCOUNT_ROSTER_TTL_MS = 6 * 60 * 60 * 1000; +/** A model denial is remembered for one day; a fresh roster clears it sooner. */ +export const ANTIGRAVITY_MODEL_DENIAL_TTL_MS = 24 * 60 * 60 * 1000; + interface AccountRoster { readonly ids: ReadonlySet; readonly modelIds: readonly string[]; @@ -29,11 +32,35 @@ interface AccountRoster { } const rosters = new Map(); +const modelDenials = new Map>(); function rosterKey(providerName: string, accountId: string): string { return `${getConfigDir()}\0${providerName}\0${accountId}`; } +/** Record one account's denial for a model (e.g. from upstream HTTP 404 "Requested entity was not found"). */ +export function recordAntigravityModelDenial( + providerName: string, + accountId: string, + modelId: string, + now = Date.now(), +): void { + if (!providerName || !accountId) return; + const id = modelId?.trim(); + if (!id) return; + const key = rosterKey(providerName, accountId); + let accountDenials = modelDenials.get(key); + if (!accountDenials) { + accountDenials = new Map(); + modelDenials.set(key, accountDenials); + } + accountDenials.set(id, now); + const canonical = canonicalAntigravityUsageModel(id); + accountDenials.set(canonical, now); + const alias = Object.hasOwn(ANTIGRAVITY_MODEL_ALIASES, id) ? ANTIGRAVITY_MODEL_ALIASES[id] : undefined; + if (alias) accountDenials.set(alias, now); +} + /** Record one account's discovered roster: picker ids, representative wire ids, and tier wire ids. */ export function recordAntigravityAccountRoster( providerName: string, @@ -48,7 +75,9 @@ export function recordAntigravityAccountRoster( ids.add(model.wireModelId); for (const wire of Object.values(model.effortWireModelIds ?? {})) if (wire) ids.add(wire); } - rosters.set(rosterKey(providerName, accountId), { + const key = rosterKey(providerName, accountId); + modelDenials.delete(key); + rosters.set(key, { ids, modelIds: models.map(model => model.id), observedAt: now, @@ -73,7 +102,8 @@ export function antigravityAccountRosterModelIds( /** * Whether one account's roster lists `modelId`: `true` / `false` from its last discovery, * `undefined` when that account has no fresh roster. A picker id, one of its tier wire ids, or a - * compatibility alias of either all count as listed. + * compatibility alias of either all count as listed. Denials recorded from upstream 404 responses + * immediately answer `false`. */ export function antigravityAccountSupportsModel( providerName: string, @@ -81,22 +111,41 @@ export function antigravityAccountSupportsModel( modelId: string, now = Date.now(), ): boolean | undefined { - const roster = freshRoster(providerName, accountId, now); - if (!roster) return undefined; const id = modelId.trim(); if (!id) return undefined; - if (roster.ids.has(id)) return true; const canonical = canonicalAntigravityUsageModel(id); - if (roster.ids.has(canonical)) return true; const alias = Object.hasOwn(ANTIGRAVITY_MODEL_ALIASES, id) ? ANTIGRAVITY_MODEL_ALIASES[id] : undefined; + + const key = rosterKey(providerName, accountId); + const accountDenials = modelDenials.get(key); + if (accountDenials) { + for (const candidate of [id, canonical, alias]) { + if (!candidate) continue; + const deniedAt = accountDenials.get(candidate); + if (deniedAt !== undefined) { + if (now - deniedAt <= ANTIGRAVITY_MODEL_DENIAL_TTL_MS) { + return false; + } + accountDenials.delete(candidate); + } + } + } + + const roster = freshRoster(providerName, accountId, now); + if (!roster) return undefined; + if (roster.ids.has(id)) return true; + if (roster.ids.has(canonical)) return true; return alias !== undefined && roster.ids.has(alias); } -/** Forget rosters (one account, or all). For account removal and tests. */ +/** Forget rosters and denials (one account, or all). For account removal and tests. */ export function clearAntigravityAccountRosters(providerName?: string, accountId?: string): void { if (providerName && accountId) { - rosters.delete(rosterKey(providerName, accountId)); + const key = rosterKey(providerName, accountId); + rosters.delete(key); + modelDenials.delete(key); return; } rosters.clear(); + modelDenials.clear(); } diff --git a/src/providers/quota-decision-publication.ts b/src/providers/quota-decision-publication.ts index d9908c35f9a..9c75b7b55c4 100644 --- a/src/providers/quota-decision-publication.ts +++ b/src/providers/quota-decision-publication.ts @@ -35,11 +35,11 @@ export function providerDecisionWindows(quota: ProviderQuota): DecisionQuotaWind } return windows; } -/** Antigravity family buckets: Gem/Cla are 5h counters, "(Weekly)" their weekly pair; per-model rows and other groups are display-only. */ +/** Antigravity family buckets: Gem/Claude GPT are 5h counters, "(Weekly)" their weekly pair; per-model rows and other groups are display-only. */ export function antigravityDecisionWindows(quota: ProviderQuota): DecisionQuotaWindow[] { const windows: DecisionQuotaWindow[] = []; for (const row of Array.isArray(quota.customWindows) ? quota.customWindows : []) { - const match = typeof row?.label === "string" && row.modelId === undefined ? /^(Gem|Cla)( \(Weekly\))?$/.exec(row.label) : null; + const match = typeof row?.label === "string" && row.modelId === undefined ? /^(Gem|Cla|Claude \/ GPT)( \(Weekly\))?$/.exec(row.label) : null; if (!match || !Number.isFinite(row.percent)) continue; windows.push({ window: match[2] ? "weekly" : "5h", family: match[1] === "Gem" ? "gem" : "cla", percent: row.percent, observedAt: quota.updatedAt, ...(row.resetAt !== undefined ? { resetAt: row.resetAt } : {}) }); diff --git a/src/providers/quota/antigravity.ts b/src/providers/quota/antigravity.ts index fb84d282f39..cf511a05381 100644 --- a/src/providers/quota/antigravity.ts +++ b/src/providers/quota/antigravity.ts @@ -1,4 +1,4 @@ -import { antigravityUserAgent } from "../../adapters/client-fingerprint"; +import { antigravityCliUserAgent, antigravityUserAgent } from "../../adapters/client-fingerprint"; import { DestinationDnsResolutionError } from "../../lib/destination-policy"; import { PinnedHttpError, type PinnedHttpErrorCode } from "../../lib/pinned-http"; import { ProviderOutboundPolicyError, providerOutboundPost, providerRedirectError, type ProviderOutboundDependencies } from "../../lib/provider-outbound"; @@ -66,14 +66,14 @@ function antigravitySummaryBucketFamily(bucket: Record): Antigr return undefined; } -function classifyAntigravityFamily(modelId: string, modelInfo: Record, quotaInfo: Record): "Gem" | "Cla" | null { +function classifyAntigravityFamily(modelId: string, modelInfo: Record, quotaInfo: Record): "Gem" | "Claude / GPT" | null { const displayName = typeof modelInfo.displayName === "string" ? modelInfo.displayName : ""; // Argon models keep their exact model rows but never seed the shared Gem family bar. if (isAntigravityArgonIdentity(`${modelId} ${displayName}`)) return null; const tier = typeof quotaInfo.tier === "string" ? quotaInfo.tier : ""; const haystack = `${modelId} ${displayName} ${tier}`.toLowerCase(); if (haystack.includes("gemini")) return "Gem"; - if (haystack.includes("claude") || haystack.includes("opus") || haystack.includes("sonnet") || haystack.includes("gpt-oss") || haystack.includes("gpt_oss")) return "Cla"; + if (haystack.includes("claude") || haystack.includes("opus") || haystack.includes("sonnet") || haystack.includes("gpt-oss") || haystack.includes("gpt_oss")) return "Claude / GPT"; return null; } @@ -88,14 +88,14 @@ function antigravityUsedPercent(quotaInfo: Record): number | un return normalizePercent(100 - remaining); } -/** Gem/Cla windows from a `fetchAvailableModels` body; shared by the provider and account probes. */ +/** Gem/Claude windows from a `fetchAvailableModels` body; shared by the provider and account probes. */ function antigravityWindowsFromModels(body: Record | null): ProviderQuotaWindow[] { const models = asRecord(body?.models); if (!models) return []; const windows = new Map(); const modelWindows: ProviderQuotaWindow[] = []; - const labels = new Set(["Gem", "Cla"]); + const labels = new Set(["Gem", "Claude / GPT"]); for (const [modelId, rawModelInfo] of Object.entries(models)) { const modelInfo = asRecord(rawModelInfo); if (!modelInfo) continue; @@ -131,7 +131,7 @@ function antigravityWindowsFromModels(body: Record | null): Pro } } - const customWindows = ["Gem", "Cla"].flatMap(label => { + const customWindows = ["Gem", "Claude / GPT"].flatMap(label => { const window = windows.get(label); return window ? [window] : []; }); @@ -183,7 +183,7 @@ function parseAntigravityQuotaSummary(body: Record | null): Pro customWindowsMap.set(label, { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) }); } } else if (family === "cla") { - const label = is5h ? "Cla" : isWeekly ? "Cla (Weekly)" : ""; + const label = is5h ? "Claude / GPT" : isWeekly ? "Claude / GPT (Weekly)" : ""; if (label && !customWindowsMap.has(label)) { customWindowsMap.set(label, { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) }); } @@ -197,7 +197,7 @@ function parseAntigravityQuotaSummary(body: Record | null): Pro } } - const PREFERRED_ORDER = ["Gem", "Gem (Weekly)", "Cla", "Cla (Weekly)", "Argon", "Argon (Weekly)"]; + const PREFERRED_ORDER = ["Gem", "Gem (Weekly)", "Claude / GPT", "Claude / GPT (Weekly)", "Argon", "Argon (Weekly)"]; const customWindows = Array.from(customWindowsMap.values()).sort((a, b) => { const ia = PREFERRED_ORDER.indexOf(a.label); const ib = PREFERRED_ORDER.indexOf(b.label); @@ -303,7 +303,7 @@ export async function probeAntigravityUsageQuota(accessToken: string, projectId: // An optional caller signal (account import) cancels every request of this probe, including the // 403 retry and the models fallback, alongside the per-request deadline. const requestSignal = () => signal ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]) : AbortSignal.timeout(REQUEST_TIMEOUT_MS); - const fetchQuota = (url: string, userAgent = antigravityUserAgent()) => providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, { + const fetchQuota = (url: string, userAgent = (url === ANTIGRAVITY_QUOTA_SUMMARY_URL ? antigravityCliUserAgent() : antigravityUserAgent())) => providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, { headers: { Accept: "application/json", "Content-Type": "application/json", "User-Agent": userAgent, Authorization: `Bearer ${accessToken}`, @@ -314,9 +314,13 @@ export async function probeAntigravityUsageQuota(accessToken: string, projectId: try { let response = await fetchQuota(ANTIGRAVITY_QUOTA_SUMMARY_URL); if (response.status === 403) { - // Some valid accounts reject the IDE fingerprint only for quota accounting. + // Some valid accounts reject the CLI fingerprint or IDE fingerprint only for quota accounting. try { await response.body?.cancel(); } catch { /* Best-effort release before retry. */ } - response = await fetchQuota(ANTIGRAVITY_QUOTA_SUMMARY_URL, "antigravity/1.0"); + response = await fetchQuota(ANTIGRAVITY_QUOTA_SUMMARY_URL, antigravityUserAgent()); + if (response.status === 403) { + try { await response.body?.cancel(); } catch { /* Best-effort release before retry. */ } + response = await fetchQuota(ANTIGRAVITY_QUOTA_SUMMARY_URL, "antigravity/1.0"); + } } if (await providerRedirectError(response, ANTIGRAVITY_QUOTA_SUMMARY_URL)) return unavailableAntigravityQuota("redirect_blocked"); if (response.status === 401 || response.status === 403) return unavailableAntigravityQuota("access_denied"); diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index ffeefdbf05d..69b64057928 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -83,6 +83,7 @@ import { quarantineKiroSuspendedAccount, failoverAccountSnapshot, rotateAntigravityAccountOnAuthRefusal, + rotateAntigravityAccountOnModelNotFound, } from "../../oauth/generic-account-failover"; import { attemptOpaqueBlobRecovery, @@ -115,7 +116,7 @@ import { createCodexAuthDispatchGuard, releaseCodexAuthContextProbeLease, unwrap import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { mapCodexAuthContextErrorToResponse } from "./codex-auth-error"; import { chargeWorkflowSends } from "../../lib/workflow-budget"; -import { isAntigravityValidationRefusal } from "./antigravity-validation-refusal"; +import { isAntigravityModelNotFoundRefusal, isAntigravityValidationRefusal } from "./antigravity-validation-refusal"; import { unboundPoolSpendRefusalResponse } from "../workflow-refusal"; import { AdmissionModelDeniedError, admissionModelDeniedResponse } from "../admission-model-scope"; @@ -615,6 +616,7 @@ export async function prepareAdapterExchange( const consoleGoUploadRetryGuard: { attempted: boolean } = { attempted: false }; let oauth401ReplayAttempted = transportState.oauth401ReplayAttempted; let antigravityAuthRotationAttempted = false; + let antigravityModelNotFoundExhausted = false; // At most one reasoning-effort downgrade per request. This sits outside the recovery loop // below for the same reason the two guards above do: a guard declared inside it is reset by // every `continue recovery`, which would let one turn walk the whole ladder down. @@ -880,17 +882,11 @@ export async function prepareAdapterExchange( return rebuildAndRefetch("opaque-blob-rejection"); }; if (!upstreamResponse.ok) { - const rotateAntigravityAuth = async ( + const rotateAntigravityToAccount = async ( failedResponse: Response, recovery: AttemptRecoveryKind, + nextId: string, ): Promise => { - const sent = transportState.replayOAuthCredentialSnapshot ?? transportState.sentOAuthSnapshot; - if (!antigravityPoolActivated || !sent - || transportState.genericFailovers >= transportState.genericFailoverLimit) return null; - const nextId = rotateAntigravityAccountOnAuthRefusal( - antigravityPoolActivated, sent.accountId, sent.generation, route.modelId, - ); - if (!nextId) return null; const adapterOwnsDispatch = transportState.activeAdapter.fetchResponse !== undefined; const hop = reserveCredentialHop("auth-recovery", `${route.providerName}|${route.modelId}|antigravity-auth`, @@ -928,6 +924,33 @@ export async function prepareAdapterExchange( hop.permit?.release(); } }; + + const rotateAntigravityAuth = async ( + failedResponse: Response, + recovery: AttemptRecoveryKind, + ): Promise => { + const sent = transportState.replayOAuthCredentialSnapshot ?? transportState.sentOAuthSnapshot; + if (!antigravityPoolActivated || !sent + || transportState.genericFailovers >= transportState.genericFailoverLimit) return null; + const nextId = rotateAntigravityAccountOnAuthRefusal( + antigravityPoolActivated, sent.accountId, sent.generation, route.modelId, + ); + if (!nextId) return null; + return rotateAntigravityToAccount(failedResponse, recovery, nextId); + }; + + const rotateAntigravityModelNotFound = async ( + failedResponse: Response, + ): Promise => { + const sent = transportState.replayOAuthCredentialSnapshot ?? transportState.sentOAuthSnapshot; + if (!antigravityPoolActivated || !sent + || transportState.genericFailovers >= transportState.genericFailoverLimit) return null; + const nextId = rotateAntigravityAccountOnModelNotFound( + antigravityPoolActivated, sent.accountId, route.modelId, + ); + if (!nextId) return null; + return rotateAntigravityToAccount(failedResponse, "oauth-account-403", nextId); + }; // Keep recovery kinds in sync with the native Responses `passthroughRecovery:` loop above. recovery: for (;;) { if (wasAccountAutoPaused(upstreamResponse) && route.provider.authMode !== "forward") { @@ -1097,6 +1120,17 @@ export async function prepareAdapterExchange( if (rotated) { upstreamResponse = rotated; continue recovery; } } + if (route.providerName === "google-antigravity" && upstreamResponse.status === 404 + && transportState.activeAdapter.name === "google" + && antigravityPoolActivated + && !isNonReplayableResponse(upstreamResponse) + && transportState.genericFailovers < transportState.genericFailoverLimit + && await isAntigravityModelNotFoundRefusal(upstreamResponse, options.abortSignal)) { + const rotated = await rotateAntigravityModelNotFound(upstreamResponse); + if (rotated) { upstreamResponse = rotated; continue recovery; } + antigravityModelNotFoundExhausted = true; + } + // Static API-key pools can recover a credential-scoped 401 without abandoning the // provider: one revoked or mistyped key says nothing about its siblings. OAuth providers // refresh above and never enter here — `hasKeyPoolFailover` rejects oauth/forward modes. @@ -1934,7 +1968,9 @@ export async function prepareAdapterExchange( errorType: normalized.type, code: normalized.code, message: "Structured upstream failure observed before client formatting", }); - const message = normalized.cyberPolicy + const message = antigravityModelNotFoundExhausted + ? `No stored Google Antigravity account can serve the model "${route.modelId ?? parsed.modelId ?? "the requested model"}". ${normalized.safeText}` + : normalized.cyberPolicy ? normalized.message ?? (isCyberPolicyCode(normalized.code) ? CYBER_POLICY_FALLBACK_MESSAGE : normalized.safeText) : enrichOpenCodeZenUpstreamMessage( diff --git a/src/server/responses/antigravity-validation-refusal.ts b/src/server/responses/antigravity-validation-refusal.ts index 7c3512698a4..7615c9e7fae 100644 --- a/src/server/responses/antigravity-validation-refusal.ts +++ b/src/server/responses/antigravity-validation-refusal.ts @@ -32,3 +32,21 @@ async function inspectValidationRefusal(response: Response, signal: AbortSignal return false; } } + +/** Whether an upstream 404 is Google CCA denying access to a model for this account. */ +export async function isAntigravityModelNotFoundRefusal(response: Response, signal?: AbortSignal): Promise { + if (response.status !== 404 || signal?.aborted) return false; + try { + const body = await readBoundedResponseBody(response.clone(), { + maxBytes: 4096, + totalTimeoutMs: 2000, + firstByteTimeoutMs: 1000, + inactivityTimeoutMs: 1000, + signal, + }); + return !signal?.aborted && body.displaySafe && !body.truncated && !body.timedOut && !body.oversized + && /Requested entity was not found/i.test(body.text); + } catch { + return false; + } +} diff --git a/tests/adapters/google/antigravity-account-roster.test.ts b/tests/adapters/google/antigravity-account-roster.test.ts index 199fc8038b0..22fa9989029 100644 --- a/tests/adapters/google/antigravity-account-roster.test.ts +++ b/tests/adapters/google/antigravity-account-roster.test.ts @@ -14,16 +14,20 @@ import { preferKiroModelSupport, preferredInitialAccount, rotateAntigravityAccountOnAuthRefusal, + rotateAntigravityAccountOnModelNotFound, rotateGenericOAuthAccountOn429, routeAntigravityModel, } from "../../../src/oauth/generic-account-failover"; import { ANTIGRAVITY_ACCOUNT_ROSTER_TTL_MS, + ANTIGRAVITY_MODEL_DENIAL_TTL_MS, antigravityAccountRosterModelIds, antigravityAccountSupportsModel, clearAntigravityAccountRosters, recordAntigravityAccountRoster, + recordAntigravityModelDenial, } from "../../../src/providers/antigravity-account-roster"; +import { isAntigravityModelNotFoundRefusal } from "../../../src/server/responses/antigravity-validation-refusal"; import { ANTIGRAVITY_MODELS, ANTIGRAVITY_MODEL_EFFORTS, @@ -189,6 +193,91 @@ describe("account selection prefers an account whose roster lists the model", () const config = { providers: { [PROVIDER]: provider({ oauthAccountFailover: { enabled: true } }) } } as unknown as OcxConfig; expect(preferredInitialAccount(config, PROVIDER, Date.now(), "claude-opus-5-5")).toBe("acct-55"); }); + + test("with proactive failover on, a model request leaves an active lacking account for an unknown sibling (Issue #7500)", () => { + homeWithAccounts(["acct-46", "acct-unknown"], "acct-46"); + // acct-46 has roster omitting claude-opus-5-5, acct-unknown has NO roster yet (undefined) + recordAntigravityAccountRoster(PROVIDER, "acct-46", parseAntigravityAvailableModels(ROSTER_46)!); + expect(antigravityAccountSupportsModel(PROVIDER, "acct-46", "claude-opus-5-5")).toBe(false); + expect(antigravityAccountSupportsModel(PROVIDER, "acct-unknown", "claude-opus-5-5")).toBeUndefined(); + + const config = { providers: { [PROVIDER]: provider({ oauthAccountFailover: { enabled: true } }) } } as unknown as OcxConfig; + // Must fall back to acct-unknown rather than staying on acct-46 which guarantees a 404 + expect(preferredInitialAccount(config, PROVIDER, Date.now(), "claude-opus-5-5")).toBe("acct-unknown"); + }); + + test("with proactive failover on, an unseeded active account is not moved by preference alone", () => { + homeWithAccounts(["acct-unknown", "acct-55"], "acct-unknown"); + // acct-unknown has NO roster yet (undefined), acct-55 has roster listing claude-opus-5-5 (true) + recordAntigravityAccountRoster(PROVIDER, "acct-55", parseAntigravityAvailableModels(ROSTER_55)!); + expect(antigravityAccountSupportsModel(PROVIDER, "acct-unknown", "claude-opus-5-5")).toBeUndefined(); + expect(antigravityAccountSupportsModel(PROVIDER, "acct-55", "claude-opus-5-5")).toBe(true); + + const config = { providers: { [PROVIDER]: provider({ oauthAccountFailover: { enabled: true } }) } } as unknown as OcxConfig; + // Absence of a roster is unknown, never "no": preference alone must not abandon the selected + // account. Only a proven omission, or a reactive 404 denial, may move the request. + expect(preferredInitialAccount(config, PROVIDER, Date.now(), "claude-opus-5-5")).toBeNull(); + }); +}); + +describe("upstream 404 model denial and reactive failover", () => { + test("recordAntigravityModelDenial marks account as lacking model and its wire variants", () => { + expect(antigravityAccountSupportsModel(PROVIDER, "acct-probe", "gemini-4-argon")).toBeUndefined(); + recordAntigravityModelDenial(PROVIDER, "acct-probe", "gemini-4-argon"); + // Wire model and base model are both recognized as unsupported + expect(antigravityAccountSupportsModel(PROVIDER, "acct-probe", "gemini-4-argon")).toBe(false); + expect(antigravityAccountSupportsModel(PROVIDER, "acct-probe", "gemini-4-argon-high")).toBe(false); + // Unrelated models on the account remain unknown + expect(antigravityAccountSupportsModel(PROVIDER, "acct-probe", "gemini-3.8-flash")).toBeUndefined(); + }); + + test("model denial expires after ANTIGRAVITY_MODEL_DENIAL_TTL_MS", () => { + const start = 1_000_000; + recordAntigravityModelDenial(PROVIDER, "acct-exp", "gemini-4-argon", start); + expect(antigravityAccountSupportsModel(PROVIDER, "acct-exp", "gemini-4-argon", start + 1000)).toBe(false); + expect(antigravityAccountSupportsModel(PROVIDER, "acct-exp", "gemini-4-argon", start + ANTIGRAVITY_MODEL_DENIAL_TTL_MS + 1)).toBeUndefined(); + }); + + test("a fresh discovery roster clears prior model denials for that account", () => { + recordAntigravityModelDenial(PROVIDER, "acct-refresh", "claude-opus-5-5"); + expect(antigravityAccountSupportsModel(PROVIDER, "acct-refresh", "claude-opus-5-5")).toBe(false); + recordAntigravityAccountRoster(PROVIDER, "acct-refresh", parseAntigravityAvailableModels(ROSTER_55)!); + expect(antigravityAccountSupportsModel(PROVIDER, "acct-refresh", "claude-opus-5-5")).toBe(true); + }); + + test("rotateAntigravityAccountOnModelNotFound records denial and selects next eligible candidate", () => { + homeWithAccounts(["acct-a", "acct-b", "acct-c"], "acct-a"); + // acct-a receives 404 for gemini-4-argon + const next = rotateAntigravityAccountOnModelNotFound(true, "acct-a", "gemini-4-argon"); + expect(next).toBe("acct-b"); + expect(antigravityAccountSupportsModel(PROVIDER, "acct-a", "gemini-4-argon")).toBe(false); + + // If acct-b also 404s, it advances to acct-c + const next2 = rotateAntigravityAccountOnModelNotFound(true, "acct-b", "gemini-4-argon"); + expect(next2).toBe("acct-c"); + expect(antigravityAccountSupportsModel(PROVIDER, "acct-b", "gemini-4-argon")).toBe(false); + + // If acct-c also 404s, all accounts are denied; ring wraps but nobody is eligible + const next3 = rotateAntigravityAccountOnModelNotFound(true, "acct-c", "gemini-4-argon"); + expect(next3).toBeNull(); + }); + + test("isAntigravityModelNotFoundRefusal correctly identifies Google CCA 404 entity not found", async () => { + const cca404 = new Response(JSON.stringify({ + error: { code: 404, message: "Requested entity was not found.", status: "NOT_FOUND" }, + }), { status: 404 }); + expect(await isAntigravityModelNotFoundRefusal(cca404)).toBe(true); + + const generic404 = new Response(JSON.stringify({ + error: { code: 404, message: "Page not found", status: "NOT_FOUND" }, + }), { status: 404 }); + expect(await isAntigravityModelNotFoundRefusal(generic404)).toBe(false); + + const cca403 = new Response(JSON.stringify({ + error: { code: 403, message: "Permission denied", status: "PERMISSION_DENIED" }, + }), { status: 403 }); + expect(await isAntigravityModelNotFoundRefusal(cca403)).toBe(false); + }); }); describe("model routing fails closed on a proven roster omission (#7500)", () => { diff --git a/tests/oauth/oauth-account-quota-rank.test.ts b/tests/oauth/oauth-account-quota-rank.test.ts index 2d7ed9ab541..10ee87e99a8 100644 --- a/tests/oauth/oauth-account-quota-rank.test.ts +++ b/tests/oauth/oauth-account-quota-rank.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { + accountResetTimestamp, classifyModelFamilyForQuota, hasHeadroomEvidence, isAccountQuotaExhausted, @@ -76,14 +77,15 @@ function config(strategy?: "quota" | "round-robin" | "fill-first"): OcxConfig { } as unknown as OcxConfig; } -function seedWindows(accountId: string, gem: number, cla: number): void { +function seedWindows(accountId: string, gem: number, cla: number, argon?: number): void { setCachedProviderAccountQuotaForTests("google-antigravity", accountId, { updatedAt: Date.now(), customWindows: [ { label: "Gem", percent: gem }, { label: "Gem (Weekly)", percent: gem }, - { label: "Cla", percent: cla }, - { label: "Cla (Weekly)", percent: cla }, + { label: "Claude / GPT", percent: cla }, + { label: "Claude / GPT (Weekly)", percent: cla }, + ...(argon !== undefined ? [{ label: "Argon (Weekly)", percent: argon }] : []), ], }); } @@ -106,6 +108,8 @@ describe("classifyModelFamilyForQuota", () => { expect(classifyModelFamilyForQuota("google-antigravity", "gemini-3.8-flash")).toBe("gem"); expect(classifyModelFamilyForQuota("google-antigravity", "gemini-pro-agent")).toBe("gem"); expect(classifyModelFamilyForQuota("google-antigravity", "gemini-3.1-flash-image")).toBe("gem"); + expect(classifyModelFamilyForQuota("google-antigravity", "gemini-4-argon")).toBe("argon"); + expect(classifyModelFamilyForQuota("google-antigravity", "gemini-4-argon-high")).toBe("argon"); expect(classifyModelFamilyForQuota("google-antigravity", "claude-sonnet-4-5")).toBe("cla"); expect(classifyModelFamilyForQuota("google-antigravity", "claude-opus-4-6-thinking")).toBe("cla"); expect(classifyModelFamilyForQuota("google-antigravity", "claude-3-7-sonnet")).toBe("cla"); @@ -176,6 +180,56 @@ describe("model-family headroom filtering", () => { expect(rankedClaude[0]).toBe("b"); }); + test("Argon request uses dedicated Argon window and ignores Gem / Claude windows", () => { + // acct-a: Gem 20%, Claude 20%, Argon 95% (headroom 5%) + // acct-b: Gem 80%, Claude 80%, Argon 10% (headroom 90%) + seedWindows("a", 20, 20, 95); + seedWindows("b", 80, 80, 10); + + const rankedArgon = rankAccountsByHeadroom( + "google-antigravity", + ["a", "b"], + "gemini-4-argon", + ); + expect(rankedArgon[0]).toBe("b"); + + // Wire model variants also route under argon family + const rankedArgonWire = rankAccountsByHeadroom( + "google-antigravity", + ["a", "b"], + "gemini-4-argon-high", + ); + expect(rankedArgonWire[0]).toBe("b"); + + // Standard Gemini request ignores Argon quota + const rankedGemini = rankAccountsByHeadroom( + "google-antigravity", + ["b", "a"], + "gemini-3.8-flash", + ); + expect(rankedGemini[0]).toBe("a"); + }); + + test("Argon exhaustion identifies Argon spent without exhausting standard Gemini", () => { + seedWindows("a", 10, 10, 100); + expect(isAccountQuotaExhausted("google-antigravity", "a", "gemini-4-argon")).toBe(true); + expect(isAccountQuotaExhausted("google-antigravity", "a", "gemini-4-argon-low")).toBe(true); + expect(isAccountQuotaExhausted("google-antigravity", "a", "gemini-3.8-flash")).toBe(false); + }); + + test("accountResetTimestamp retrieves Argon weekly reset", () => { + const futureReset = Date.now() + 3600_000; + setCachedProviderAccountQuotaForTests("google-antigravity", "a", { + updatedAt: Date.now(), + customWindows: [ + { label: "Gem", percent: 10, resetAt: Date.now() + 1000 }, + { label: "Gem (Weekly)", percent: 10, resetAt: Date.now() + 2000 }, + { label: "Argon (Weekly)", percent: 50, resetAt: futureReset }, + ], + }); + expect(accountResetTimestamp("google-antigravity", "a", "gemini-4-argon")).toBe(futureReset); + }); + test("non-Antigravity provider ignores modelId and uses all windows", () => { setCachedProviderAccountQuotaForTests("xai", "a", { fiveHourPercent: 80, diff --git a/tests/providers/provider-account-quota.test.ts b/tests/providers/provider-account-quota.test.ts index 141e28b527c..91b9bac2281 100644 --- a/tests/providers/provider-account-quota.test.ts +++ b/tests/providers/provider-account-quota.test.ts @@ -739,8 +739,8 @@ describe("google-antigravity per-account quota (#1082)", () => { const [idA, idB] = [idFor("a@example.com"), idFor("b@example.com")]; expect(Object.keys(byId).sort()).toEqual([idA, idB].sort()); const windows = (id: string) => byId[id]!.quota!.customWindows!.filter(w => w.modelId === undefined).map(w => `${w.label}=${w.percent}`); - expect(windows(idA)).toEqual(["Gem=14", "Gem (Weekly)=14", "Cla=62", "Cla (Weekly)=62"]); - expect(windows(idB)).toEqual(["Gem=3", "Gem (Weekly)=3", "Cla=9", "Cla (Weekly)=9"]); + expect(windows(idA)).toEqual(["Gem=14", "Gem (Weekly)=14", "Claude / GPT=62", "Claude / GPT (Weekly)=62"]); + expect(windows(idB)).toEqual(["Gem=3", "Gem (Weekly)=3", "Claude / GPT=9", "Claude / GPT (Weekly)=9"]); expect(byId[idA]!.quota!.customWindows![0]!.resetAt).toBeDefined(); expect(seen.map(s => `${s.auth}|${s.project}`).sort()).toEqual(["Bearer agy-first|proj-first", "Bearer agy-second|proj-second"]); for (const s of seen) { @@ -773,8 +773,8 @@ describe("google-antigravity per-account quota (#1082)", () => { const byId = Object.fromEntries(rows.map(row => [row.accountId, row])); const [idA, idB] = [idFor("a@example.com"), idFor("b@example.com")]; const windows = (id: string) => byId[id]!.quota!.customWindows!.filter(w => w.modelId === undefined).map(w => `${w.label}=${w.percent}`); - expect(windows(idA)).toEqual(["Gem=14", "Cla=62"]); - expect(windows(idB)).toEqual(["Gem=3", "Cla=9"]); + expect(windows(idA)).toEqual(["Gem=14", "Claude / GPT=62"]); + expect(windows(idB)).toEqual(["Gem=3", "Claude / GPT=9"]); expect(byId[idA]!.quota!.customWindows![0]!.resetAt).toBeDefined(); }); @@ -1009,7 +1009,7 @@ describe("google-antigravity per-account quota (#1082)", () => { }, }); expect(await fetchProviderAccountQuotas("google-antigravity")).toEqual([{ accountId: idFor("a@example.com"), quota: null, unavailable: true, quotaFailure: status < 400 ? "redirect_blocked" : "access_denied" }]); - expect(posted).toEqual(fallback ? [summaryUrl, modelsUrl] : status === 403 ? [summaryUrl, summaryUrl] : [summaryUrl]); + expect(posted).toEqual(fallback ? [summaryUrl, modelsUrl] : status === 403 ? [summaryUrl, summaryUrl, summaryUrl] : [summaryUrl]); expect(plainFetchCalls).toBe(0); }); } @@ -1140,7 +1140,7 @@ describe("google-antigravity Fake-IP TUN quota probes without HTTP proxy (#3781) expect(rows[0]!.accountId).toBe(idFor("a@example.com")); expect(rows[0]!.unavailable).toBeUndefined(); expect(rows[0]!.quotaFailure).toBeUndefined(); - expect(rows[0]!.quota?.customWindows?.filter(w => w.modelId === undefined).map(w => `${w.label}=${w.percent}`)).toEqual(fallback ? ["Gem=14", "Cla=62"] : ["Gem=14", "Gem (Weekly)=14", "Cla=62", "Cla (Weekly)=62"]); + expect(rows[0]!.quota?.customWindows?.filter(w => w.modelId === undefined).map(w => `${w.label}=${w.percent}`)).toEqual(fallback ? ["Gem=14", "Claude / GPT=62"] : ["Gem=14", "Gem (Weekly)=14", "Claude / GPT=62", "Claude / GPT (Weekly)=62"]); expect(plainFetchCalls).toBe(0); }); } diff --git a/tests/providers/provider-antigravity-model-quota.test.ts b/tests/providers/provider-antigravity-model-quota.test.ts index be00103dd96..7642a945e3c 100644 --- a/tests/providers/provider-antigravity-model-quota.test.ts +++ b/tests/providers/provider-antigravity-model-quota.test.ts @@ -11,6 +11,7 @@ import { getCachedProviderAccountQuota, setCachedProviderAccountQuotaForTests } import { sweepExpiredProviderAccountQuotaRows } from "../../src/providers/quota/account-cache"; import { ACCOUNT_QUOTA_TTL_MS } from "../../src/providers/quota-wire"; import { providerWindowObservations } from "../../src/quota/window-mapping"; +import { antigravityCliUserAgent } from "../../src/adapters/client-fingerprint"; import { probeAntigravityUsageQuota, setAntigravityAccountQuotaTransportForTests, @@ -67,7 +68,7 @@ describe("Antigravity model quota evidence", () => { expect(result.quota.customWindows?.slice(0, 2)).toEqual([ { label: "Gem", percent: 36, resetAt: Date.parse("2026-11-01T12:00:00Z") }, - { label: "Cla", percent: 79, resetAt: Date.parse("2026-11-02T12:00:00Z") }, + { label: "Claude / GPT", percent: 79, resetAt: Date.parse("2026-11-02T12:00:00Z") }, ]); expect(result.quota.customWindows).toContainEqual({ label: "gemini-3.8-flash-high", modelId: "gemini-3.8-flash-high", percent: 36, @@ -114,7 +115,7 @@ describe("Antigravity model quota evidence", () => { if (result.kind !== "available") return; expect(result.quota.customWindows?.filter(window => window.modelId !== undefined)).toHaveLength(256); expect(result.quota.customWindows?.filter(window => window.modelId === undefined)).toEqual([ - { label: "Gem", percent: 20 }, { label: "Cla", percent: 40 }, + { label: "Gem", percent: 20 }, { label: "Claude / GPT", percent: 40 }, ]); }); @@ -191,6 +192,62 @@ describe("Antigravity model quota evidence", () => { // One pool reset must notify once for its family, not once per diagnostic model row. expect(providerWindowObservations(result.quota).map(observation => observation.window)).toEqual(["custom:Gem"]); }); + + test("summary probe sends CLI User-Agent to surface Gemini 4 Argon quota group", async () => { + const seenUAs: string[] = []; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => ({ + hostname: "daily-cloudcode-pa.googleapis.com", + addresses: [{ address: "142.250.0.1", family: 4 }], + privateNetwork: false, + }), + pinnedPost: async (url, _pinned, _body, _signal, requestOptions) => { + const ua = new Headers(requestOptions?.headers).get("user-agent") ?? ""; + seenUAs.push(ua); + if (url.endsWith("retrieveUserQuotaSummary")) { + return new Response(JSON.stringify({ + groups: [ + { + displayName: "Gemini Models", + buckets: [ + { window: "5h", remainingFraction: 0.8, resetTime: "2026-11-01T05:00:00Z" }, + { window: "weekly", remainingFraction: 0.9, resetTime: "2026-11-07T05:00:00Z" }, + ], + }, + { + displayName: "Claude and GPT models", + buckets: [ + { window: "5h", remainingFraction: 0.7, resetTime: "2026-11-01T06:00:00Z" }, + { window: "weekly", remainingFraction: 0.85, resetTime: "2026-11-07T06:00:00Z" }, + ], + }, + { + displayName: "Gemini 4 Argon", + description: "Argon separate quota", + buckets: [ + { bucketId: "gemini-argon-wtu-weekly", window: "weekly", remainingFraction: 0.4, resetTime: "2026-11-07T07:00:00Z" }, + ], + }, + ], + }), { status: 200, headers: { "content-type": "application/json" } }); + } + return new Response(null, { status: 404 }); + }, + }); + + const result = await probeAntigravityUsageQuota("synthetic-token", "synthetic-project"); + expect(result.kind).toBe("available"); + if (result.kind !== "available") return; + expect(seenUAs[0]).toBe(antigravityCliUserAgent()); + expect(result.source).toBe("google-antigravity:retrieveUserQuotaSummary"); + expect(result.quota.customWindows).toEqual([ + { label: "Gem", percent: 20, resetAt: Date.parse("2026-11-01T05:00:00Z") }, + { label: "Gem (Weekly)", percent: 10, resetAt: Date.parse("2026-11-07T05:00:00Z") }, + { label: "Claude / GPT", percent: 30, resetAt: Date.parse("2026-11-01T06:00:00Z") }, + { label: "Claude / GPT (Weekly)", percent: 15, resetAt: Date.parse("2026-11-07T06:00:00Z") }, + { label: "Argon (Weekly)", modelId: "gemini-4-argon", percent: 60, resetAt: Date.parse("2026-11-07T07:00:00Z") }, + ]); + }); }); describe("Antigravity Argon quota summary (#7272)", () => { @@ -248,7 +305,7 @@ describe("Antigravity Argon quota summary (#7272)", () => { ["Argon group first", [argonGroup, geminiGroup, claudeGroup]], ])("keeps Gem (Weekly) and a separate Argon window: %s", async (_name, groups) => { const windows = await summaryWindows(groups); - expect(label(windows)).toEqual(["Gem=25", "Gem (Weekly)=12", "Cla (Weekly)=5", "Argon (Weekly)=25@gemini-4-argon"]); + expect(label(windows)).toEqual(["Gem=25", "Gem (Weekly)=12", "Claude / GPT (Weekly)=5", "Argon (Weekly)=25@gemini-4-argon"]); expect(windows.at(-1)).toEqual({ label: "Argon (Weekly)", modelId: "gemini-4-argon", percent: 25, resetAt: Date.parse(argonReset) }); // The summary already carries Argon; no model-list request is needed. expect(modelRequests).toBe(0); @@ -282,7 +339,7 @@ describe("Antigravity Argon quota summary (#7272)", () => { { bucketId: "weekly-1", window: "weekly", remainingFraction: 0.3 }, ] }, { displayName: "Claude and GPT models", buckets: [{ bucketId: "gemini-5h", window: "5h", remainingFraction: 0.9 }] }, - ]))).toEqual(["Gem=10", "Gem (Weekly)=50", "Cla (Weekly)=60", "Argon (Weekly)=70@gemini-4-argon"]); + ]))).toEqual(["Gem=10", "Gem (Weekly)=50", "Claude / GPT (Weekly)=60", "Argon (Weekly)=70@gemini-4-argon"]); // Token boundaries: an unknown group with an "argonaut" bucket keeps its own label. expect(label(await summaryWindows([ @@ -329,7 +386,7 @@ describe("Antigravity Argon quota summary (#7272)", () => { .toBe(accountResetTimestamp("google-antigravity", "second", model, Date.parse("2026-10-17T00:00:00Z"))); } expect(providerWindowObservations(quota).map(observation => observation.window)) - .toEqual(["custom:Gem", "custom:Gem (Weekly)", "custom:Cla (Weekly)"]); + .toEqual(["custom:Gem", "custom:Gem (Weekly)", "custom:Claude / GPT (Weekly)"]); // Family-less reset time: with no Gem weekly window, an Argon weekly row must not stand in for it. setCachedProviderAccountQuotaForTests("google-antigravity", "first", { diff --git a/tests/providers/provider-quota.test.ts b/tests/providers/provider-quota.test.ts index 9de61147f58..6a04eea51d1 100644 --- a/tests/providers/provider-quota.test.ts +++ b/tests/providers/provider-quota.test.ts @@ -390,7 +390,7 @@ describe("fetchProviderQuotaReports", () => { ]); expect(byProvider["google-antigravity"]?.quota.customWindows).toEqual([ { label: "Gem", percent: 36, resetAt: Date.parse("2026-07-05T14:00:00Z") }, - { label: "Cla", percent: 79, resetAt: Date.parse("2026-07-05T15:00:00Z") }, + { label: "Claude / GPT", percent: 79, resetAt: Date.parse("2026-07-05T15:00:00Z") }, { label: "gemini-3.6-flash-medium", modelId: "gemini-3.6-flash-medium", percent: 36, resetAt: Date.parse("2026-07-05T14:00:00Z") }, { label: "claude-sonnet-4.6 · sonnet", modelId: "claude-sonnet-4.6", percent: 79, resetAt: Date.parse("2026-07-05T15:00:00Z") }, { label: "autocomplete", modelId: "autocomplete", percent: 99, resetAt: Date.parse("2026-07-05T16:00:00Z") }, @@ -930,7 +930,7 @@ describe("fetchProviderQuotaReports", () => { const report = await fetchProviderQuotaReports(config, true); expect(report.reports[0]?.quota.customWindows).toEqual([ { label: "Gem", percent: 100, resetAt: Date.parse(resetTime) }, - { label: "Cla", percent: 0, resetAt: Date.parse(resetTime) }, + { label: "Claude / GPT", percent: 0, resetAt: Date.parse(resetTime) }, ]); expect(pickComboTarget(config, "quota-scope")?.target).toMatchObject({ provider: "google-antigravity", model: "claude-sonnet-4.6" }); }); @@ -3220,8 +3220,8 @@ describe("fetchProviderQuotaReports", () => { expect(report.quota.customWindows).toEqual([ { label: "Gem", percent: 25, resetAt: Date.parse("2026-09-04T12:25:41Z") }, { label: "Gem (Weekly)", percent: 12, resetAt: Date.parse("2026-09-11T02:25:41Z") }, - { label: "Cla", percent: 0, resetAt: Date.parse("2026-09-04T12:54:45Z") }, - { label: "Cla (Weekly)", percent: 5, resetAt: Date.parse("2026-09-04T10:57:22Z") }, + { label: "Claude / GPT", percent: 0, resetAt: Date.parse("2026-09-04T12:54:45Z") }, + { label: "Claude / GPT (Weekly)", percent: 5, resetAt: Date.parse("2026-09-04T10:57:22Z") }, ]); expect(seen[0]?.url).toBe("https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary"); expect(seen[0]?.auth).toBe("Bearer agy-summary-access"); @@ -3308,8 +3308,8 @@ describe("fetchProviderQuotaReports", () => { expect(report.quota.customWindows).toEqual([ { label: "Gem", percent: 40, resetAt: Date.parse("2026-09-04T08:00:00Z") }, { label: "Gem (Weekly)", percent: 30, resetAt: Date.parse("2026-09-11T00:00:00Z") }, - { label: "Cla", percent: 10, resetAt: Date.parse("2026-09-04T09:00:00Z") }, - { label: "Cla (Weekly)", percent: 15, resetAt: Date.parse("2026-09-11T09:00:00Z") }, + { label: "Claude / GPT", percent: 10, resetAt: Date.parse("2026-09-04T09:00:00Z") }, + { label: "Claude / GPT (Weekly)", percent: 15, resetAt: Date.parse("2026-09-11T09:00:00Z") }, ]); }); @@ -3589,7 +3589,7 @@ describe("fetchProviderQuotaReports", () => { pinnedPost: async url => { posted.push(url); return new Response(null, { status, headers: { location: modelsUrl } }); }, }); expect((await fetchProviderQuotaReports(config(), true)).reports).toEqual([]); - expect(posted).toEqual(status === 403 ? [summaryUrl, summaryUrl] : [summaryUrl]); + expect(posted).toEqual(status === 403 ? [summaryUrl, summaryUrl, summaryUrl] : [summaryUrl]); expect(plainFetchCalls).toEqual([]); }); } diff --git a/tests/routing/jev-quota-antigravity.test.ts b/tests/routing/jev-quota-antigravity.test.ts index a527da61a89..d8ba694a921 100644 --- a/tests/routing/jev-quota-antigravity.test.ts +++ b/tests/routing/jev-quota-antigravity.test.ts @@ -16,7 +16,7 @@ const now = 2_000_000_000_000; const reset = now + 3_600_000; const quota = { updatedAt: now, customWindows: [ { label: "Gem", percent: 36, resetAt: reset }, { label: "Gem (Weekly)", percent: 22, resetAt: reset }, - { label: "Cla", percent: 0, resetAt: reset }, { label: "Cla (Weekly)", percent: 100, resetAt: reset }, + { label: "Claude / GPT", percent: 0, resetAt: reset }, { label: "Claude / GPT (Weekly)", percent: 100, resetAt: reset }, { label: "gemini-3.8-flash · tier", modelId: "gemini-3.8-flash", percent: 99 }, { label: "Argon", modelId: "argon", percent: 95 }, ] }; const summary = { groups: [ diff --git a/tests/server/server-antigravity-model-roster-routing.test.ts b/tests/server/server-antigravity-model-roster-routing.test.ts index e751f53170f..8432d80cf09 100644 --- a/tests/server/server-antigravity-model-roster-routing.test.ts +++ b/tests/server/server-antigravity-model-roster-routing.test.ts @@ -155,3 +155,27 @@ test("a recovery rebind onto a newly selected omitting account is abandoned, not expect(result.status).not.toBe(200); expect(sends).not.toContain(bearer(1)); }); + +test("an upstream 404 model denial rotates to a sibling and continues recovery", async () => { + // No roster is seeded, so the first send reaches the active account and only the 404 itself + // proves that this account cannot serve the model. + let first = true; + onSend = async auth => { + if (!first) return undefined; + first = false; + expect(auth).toBe(bearer(0)); + return Response.json({ error: { code: 404, status: "NOT_FOUND", message: "Requested entity was not found." } }, { status: 404 }); + }; + const result = await turn(config(true)); + expect(result.status).toBe(200); + expect(sends).toEqual([bearer(0), bearer(1)]); +}); + +test("a 404 with no usable sibling names the model no stored account can serve", async () => { + onSend = async () => Response.json( + { error: { code: 404, status: "NOT_FOUND", message: "Requested entity was not found." } }, { status: 404 }); + const result = await turn(config(true)); + expect(result.status).toBe(404); + expect(result.body.error?.message).toContain(`No stored Google Antigravity account can serve the model "${model}"`); + expect(sends).toEqual([bearer(0), bearer(1), bearer(2)]); +});