diff --git a/changelog.d/fixes/antigravity-unreported-quota-fraction-unknown.md b/changelog.d/fixes/antigravity-unreported-quota-fraction-unknown.md new file mode 100644 index 0000000000..04cfb551a7 --- /dev/null +++ b/changelog.d/fixes/antigravity-unreported-quota-fraction-unknown.md @@ -0,0 +1 @@ +- Fix Antigravity quota parsing treating an unreported `remainingFraction` as 0% remaining instead of unknown, which made a genuinely exhausted quota indistinguishable from one the upstream simply didn't report. diff --git a/open-sse/services/usage/antigravity.ts b/open-sse/services/usage/antigravity.ts index f251e6d98a..8b7c0aa122 100644 --- a/open-sse/services/usage/antigravity.ts +++ b/open-sse/services/usage/antigravity.ts @@ -654,12 +654,12 @@ export async function getAntigravityUsage( const liveQuota = userQuotaEntries.get(modelKey); const quotaSource = liveQuota || quotaInfo; - const rawFraction = toNumber(quotaSource.remainingFraction, -1); + const rawFraction = toNumber(quotaSource.remainingFraction, Number.NaN); const resetAt = parseResetTime(quotaSource.resetTime); // Distinguish "upstream did not report remainingFraction" from "remaining is 0%". // fetchAvailableModels is a catalog view and can be stale/full; retrieveUserQuota is // the source of truth for actual Gemini consumption when it includes the model. - const fractionReported = rawFraction >= 0; + const fractionReported = Number.isFinite(rawFraction); if (!fractionReported) { console.warn( `[Antigravity] model ${modelKey} returned no remainingFraction — quota unknown` @@ -669,18 +669,22 @@ export async function getAntigravityUsage( // Models with no resetTime AND a reported full fraction are unlimited // (e.g. tab-completion models). Unreported fraction is NEVER unlimited. const isUnlimited = fractionReported && !resetAt && remainingFraction >= 1; - const remainingPercentage = remainingFraction * 100; const QUOTA_NORMALIZED_BASE = 1000; - const total = QUOTA_NORMALIZED_BASE; + const total = fractionReported ? QUOTA_NORMALIZED_BASE : 0; const remaining = Math.round(total * remainingFraction); const used = isUnlimited ? 0 : Math.max(0, total - remaining); quotas[modelKey] = applyLocalUsageFallback( { + // An omitted fraction is unknown, not a 0% sentinel. Keep the reset + // timestamp for display, but omit numeric quota fields so cache and + // preflight fail open rather than turning uncertainty into exhaustion. used, total: isUnlimited ? 0 : total, resetAt, - remainingPercentage: isUnlimited ? 100 : remainingPercentage, + ...(fractionReported && { + remainingPercentage: isUnlimited ? 100 : remainingFraction * 100, + }), unlimited: isUnlimited, fractionReported, quotaSource: liveQuota ? "retrieveUserQuota" : "fetchAvailableModels", diff --git a/tests/unit/antigravity-usage-service.test.ts b/tests/unit/antigravity-usage-service.test.ts index 6e885c7710..03d3b3d31c 100644 --- a/tests/unit/antigravity-usage-service.test.ts +++ b/tests/unit/antigravity-usage-service.test.ts @@ -2,7 +2,7 @@ * Tests for open-sse/services/usage.ts — Antigravity quota parsing. * * Verifies that remainingFraction is correctly parsed: - * - undefined → 0% remaining (exhausted quota) + * - undefined → unknown quota (not exhausted) * - 0 → 0% remaining (exhausted quota, explicit) * - 1.0 → 100% remaining (full quota) * - 1.0 without resetTime → unlimited (e.g. tab-completion) @@ -28,7 +28,7 @@ describe("getUsageForProvider (antigravity in usage.ts)", () => { projectId: undefined, }; - it("defaults to 0% remaining when remainingFraction is undefined", async () => { + it("treats a missing remainingFraction as unknown rather than exhausted", async () => { const originalFetch = globalThis.fetch; globalThis.fetch = async () => ({ @@ -53,9 +53,10 @@ describe("getUsageForProvider (antigravity in usage.ts)", () => { if ("quotas" in result) { const quota = result.quotas["gemini-3.7-flash-high"]; assert.ok(quota, "should have quota for gemini-3.7-flash-high"); - assert.equal(quota.remainingPercentage, 0, "remaining should be 0%"); + assert.equal(quota.remainingPercentage, undefined, "unknown quota must not become 0%"); + assert.equal(quota.fractionReported, false, "missing fraction should be marked unknown"); assert.equal(quota.unlimited, false, "should not be unlimited"); - assert.equal(quota.used > 0, true, "used should be > 0 when quota is exhausted"); + assert.equal(quota.used, 0, "unknown quota must not report usage"); } } finally { globalThis.fetch = originalFetch; diff --git a/tests/unit/generic-quota-fetcher.test.ts b/tests/unit/generic-quota-fetcher.test.ts index 6edce4894f..54759557b2 100644 --- a/tests/unit/generic-quota-fetcher.test.ts +++ b/tests/unit/generic-quota-fetcher.test.ts @@ -142,6 +142,15 @@ test("registerGenericQuotaFetchers registers Claude, GLM, and OpenCode Go via th // semantics are exercised by the source code review. }); +test("convertUsageToQuotaInfo skips Antigravity quota entries with an unknown fraction", () => { + const result = convertUsageToQuotaInfo({ + quotas: { + gemini: { fractionReported: false, resetAt: "2026-05-14T20:00:00Z" }, + }, + }); + assert.equal(result, null); +}); + test.afterEach(() => { __setGenericUsageFetcherForTests(null); __resetGenericQuotaFetcherForTests(); @@ -329,7 +338,11 @@ test("in-flight fetch must not drop a concurrent 429 force-refresh", async () => assert.equal(first?.percentUsed, 0.2); const second = await fetchGenericQuota(connectionId, connection); - assert.equal(calls.length, 2, "concurrent 429 must not let the in-flight recache wipe force-refresh"); + assert.equal( + calls.length, + 2, + "concurrent 429 must not let the in-flight recache wipe force-refresh" + ); assert.equal(calls[1]?.forceRefresh, true); assert.equal(second?.percentUsed, 0.9); invalidateGenericQuotaCache("agy", connectionId);