diff --git a/open-sse/services/usage/antigravity-weekly.js b/open-sse/services/usage/antigravity-weekly.js index db92a224..c0b78884 100644 --- a/open-sse/services/usage/antigravity-weekly.js +++ b/open-sse/services/usage/antigravity-weekly.js @@ -25,10 +25,18 @@ export function _clearWeeklyCache() { weeklyCache.clear(); } -// — Group-name to stable key mapping —————————————————————— -const GROUP_MATCHERS = [ - { pattern: /gemini/i, key: "gemini_weekly", displayName: "Gemini (Weekly)" }, - { pattern: /claude|gpt/i, key: "claude_gpt_weekly", displayName: "Claude & GPT (Weekly)" }, +// — Group-name and window to stable key mapping —————————————————————— +const GROUP_CONFIGS = [ + { + pattern: /gemini/i, + weekly: { key: "gemini_weekly", displayName: "Gemini (Weekly)" }, + session: { key: "gemini_session", displayName: "Gemini (5h)" }, + }, + { + pattern: /claude|gpt/i, + weekly: { key: "claude_gpt_weekly", displayName: "Claude & GPT (Weekly)" }, + session: { key: "claude_gpt_session", displayName: "Claude & GPT (5h)" }, + }, ]; /** @@ -60,32 +68,40 @@ export function parseWeeklyQuotaSummary(data) { for (const bucket of buckets) { if (!bucket || typeof bucket !== "object") continue; - // Identify weekly buckets by checking bucketId + displayName for "weekly" + const windowType = String(bucket.window || "").toLowerCase(); const bucketText = `${bucket.bucketId || ""} ${bucket.displayName || ""}`.toLowerCase(); - if (!bucketText.includes("weekly")) continue; + const isWeekly = windowType === "weekly" || bucketText.includes("weekly"); + const isSession = windowType === "5h" || bucketText.includes("five hour") || bucketText.includes("5h") || bucketText.includes("daily") || windowType === "daily"; - // Skip disabled buckets - if (bucket.disabled === true) continue; + if (!isWeekly && !isSession) continue; - const remainingFraction = Number(bucket.remainingFraction); + // If a session (5h) bucket is marked disabled by upstream (because weekly was hit), + // keep it so the UI shows the 5h row, but with remainingFraction: 0. + // Disabled weekly buckets are truly disabled and skipped. + if (bucket.disabled === true && isWeekly) continue; + + const remainingFraction = bucket.disabled === true ? 0 : Number(bucket.remainingFraction); if (!Number.isFinite(remainingFraction)) continue; // Match group to a known family - for (const matcher of GROUP_MATCHERS) { - if (matcher.pattern.test(displayName)) { + for (const config of GROUP_CONFIGS) { + if (config.pattern.test(displayName)) { + const target = isWeekly ? config.weekly : config.session; + if (result[target.key]) break; // first matching bucket per type wins + const total = 1000; const remaining = Math.round(total * remainingFraction); const used = Math.max(0, total - remaining); - result[matcher.key] = { + result[target.key] = { used, total, resetAt: parseResetTime(bucket.resetTime), remainingPercentage: remainingFraction * 100, unlimited: false, - displayName: matcher.displayName, + displayName: target.displayName, }; - break; // first matching bucket per family wins + break; } } } diff --git a/open-sse/services/usage/google.js b/open-sse/services/usage/google.js index 736722a7..197f4364 100644 --- a/open-sse/services/usage/google.js +++ b/open-sse/services/usage/google.js @@ -228,39 +228,37 @@ export async function getAntigravityUsage(accessToken, providerSpecificData, pro proxyOptions ); - // Reconcile weekly quota against model family status: + // Reconcile short-window session quota if models are exhausted: // If every model in a family is locked/exhausted (remainingPercentage === 0) - // until a future reset time, the weekly limit cannot be 100% available. - // On Google's Free Starter tier, retrieveUserQuotaSummary buggily reports - // remainingFraction: 1 even after the starter quota is depleted and all models 429. + // until a future reset time, update the 5h session row (not the weekly row). const entries = Object.entries(quotas); const geminiModels = entries.filter(([k]) => k.startsWith("gemini-") && !k.includes("image")); const claudeModels = entries.filter(([k]) => k.startsWith("claude-")); - if (weeklyQuotas.gemini_weekly && geminiModels.length > 0) { + if (weeklyQuotas.gemini_session && geminiModels.length > 0) { const allGeminiExhausted = geminiModels.every(([, q]) => (q.remainingPercentage ?? 0) === 0); - if (allGeminiExhausted && weeklyQuotas.gemini_weekly.remainingPercentage > 0) { + if (allGeminiExhausted && weeklyQuotas.gemini_session.remainingPercentage > 0) { const maxResetAt = geminiModels.reduce((max, [, q]) => !max || (q.resetAt && new Date(q.resetAt) > new Date(max)) ? q.resetAt : max, null ); - weeklyQuotas.gemini_weekly.used = weeklyQuotas.gemini_weekly.total; - weeklyQuotas.gemini_weekly.remainingPercentage = 0; + weeklyQuotas.gemini_session.used = weeklyQuotas.gemini_session.total; + weeklyQuotas.gemini_session.remainingPercentage = 0; if (maxResetAt) { - weeklyQuotas.gemini_weekly.resetAt = maxResetAt; + weeklyQuotas.gemini_session.resetAt = maxResetAt; } } } - if (weeklyQuotas.claude_gpt_weekly && claudeModels.length > 0) { + if (weeklyQuotas.claude_gpt_session && claudeModels.length > 0) { const allClaudeExhausted = claudeModels.every(([, q]) => (q.remainingPercentage ?? 0) === 0); - if (allClaudeExhausted && weeklyQuotas.claude_gpt_weekly.remainingPercentage > 0) { + if (allClaudeExhausted && weeklyQuotas.claude_gpt_session.remainingPercentage > 0) { const maxResetAt = claudeModels.reduce((max, [, q]) => !max || (q.resetAt && new Date(q.resetAt) > new Date(max)) ? q.resetAt : max, null ); - weeklyQuotas.claude_gpt_weekly.used = weeklyQuotas.claude_gpt_weekly.total; - weeklyQuotas.claude_gpt_weekly.remainingPercentage = 0; + weeklyQuotas.claude_gpt_session.used = weeklyQuotas.claude_gpt_session.total; + weeklyQuotas.claude_gpt_session.remainingPercentage = 0; if (maxResetAt) { - weeklyQuotas.claude_gpt_weekly.resetAt = maxResetAt; + weeklyQuotas.claude_gpt_session.resetAt = maxResetAt; } } } diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js index 784e6e47..e02dc787 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js @@ -377,51 +377,112 @@ export function parseQuotaData(provider, data) { if (data.quotas) { const entries = Object.entries(data.quotas); const weeklyKeys = new Set(["gemini_weekly", "claude_gpt_weekly"]); + const sessionKeys = new Set(["gemini_session", "claude_gpt_session"]); + const summaryKeys = new Set([...weeklyKeys, ...sessionKeys]); const geminiModels = entries.filter(([k]) => k.startsWith("gemini-") && !k.includes("image")); const claudeModels = entries.filter(([k]) => k.startsWith("claude-")); const imageModels = entries.filter(([k]) => k.includes("image")); - const weeklyModels = entries.filter(([k]) => weeklyKeys.has(k)); - const otherModels = entries.filter(([k]) => !k.startsWith("gemini-") && !k.startsWith("claude-") && !k.includes("image") && !weeklyKeys.has(k)); + const summaryModels = entries.filter(([k]) => summaryKeys.has(k)); + const otherModels = entries.filter(([k]) => !k.startsWith("gemini-") && !k.startsWith("claude-") && !k.includes("image") && !summaryKeys.has(k)); - if (geminiModels.length > 0) { + // Summary keys from retrieveUserQuotaSummary + const hasGeminiWeekly = Boolean(data.quotas.gemini_weekly); + const hasGeminiSession = Boolean(data.quotas.gemini_session); + const hasClaudeWeekly = Boolean(data.quotas.claude_gpt_weekly); + const hasClaudeSession = Boolean(data.quotas.claude_gpt_session); + + // 1. Gemini Family: + if (hasGeminiSession) { + summaryModels.filter(([k]) => k === "gemini_session").forEach(([modelKey, quota]) => { + normalizedQuotas.push({ + name: quota.displayName || modelKey, + modelKey, + used: quota.used || 0, + total: quota.total || 0, + resetAt: quota.resetAt || null, + remainingPercentage: quota.remainingPercentage, + }); + }); + } else if (geminiModels.length > 0) { const rep = geminiModels.reduce((min, cur) => (cur[1].remainingPercentage ?? 100) < (min[1].remainingPercentage ?? 100) ? cur : min )[1]; - normalizedQuotas.push({ - name: "Gemini (Flash / Pro)", - modelKey: "gemini", - used: rep.used || 0, - total: rep.total || 0, - resetAt: rep.resetAt || null, - remainingPercentage: rep.remainingPercentage, + // Only show synthesized Gemini row if its resetAt differs from weekly (i.e. it represents a separate 5h window) + const weeklyResetAt = data.quotas.gemini_weekly?.resetAt; + const isDuplicateOfWeekly = hasGeminiWeekly && rep.resetAt === weeklyResetAt && (rep.remainingPercentage ?? 0) === 0; + + if (!isDuplicateOfWeekly) { + normalizedQuotas.push({ + name: "Gemini (Flash / Pro)", + modelKey: "gemini", + used: rep.used || 0, + total: rep.total || 0, + resetAt: rep.resetAt || null, + remainingPercentage: rep.remainingPercentage, + }); + } + } + + // Show Gemini weekly row if present + if (hasGeminiWeekly) { + summaryModels.filter(([k]) => k === "gemini_weekly").forEach(([modelKey, quota]) => { + normalizedQuotas.push({ + name: quota.displayName || modelKey, + modelKey, + used: quota.used || 0, + total: quota.total || 0, + resetAt: quota.resetAt || null, + remainingPercentage: quota.remainingPercentage, + }); }); } - if (claudeModels.length > 0) { + // 2. Claude & GPT Family: + if (hasClaudeSession) { + summaryModels.filter(([k]) => k === "claude_gpt_session").forEach(([modelKey, quota]) => { + normalizedQuotas.push({ + name: quota.displayName || modelKey, + modelKey, + used: quota.used || 0, + total: quota.total || 0, + resetAt: quota.resetAt || null, + remainingPercentage: quota.remainingPercentage, + }); + }); + } else if (claudeModels.length > 0) { const rep = claudeModels.reduce((min, cur) => (cur[1].remainingPercentage ?? 100) < (min[1].remainingPercentage ?? 100) ? cur : min )[1]; - normalizedQuotas.push({ - name: "Claude (Sonnet / Opus)", - modelKey: "claude", - used: rep.used || 0, - total: rep.total || 0, - resetAt: rep.resetAt || null, - remainingPercentage: rep.remainingPercentage, + const weeklyResetAt = data.quotas.claude_gpt_weekly?.resetAt; + const isDuplicateOfWeekly = hasClaudeWeekly && rep.resetAt === weeklyResetAt && (rep.remainingPercentage ?? 0) === 0; + + if (!isDuplicateOfWeekly) { + normalizedQuotas.push({ + name: "Claude (Sonnet / Opus)", + modelKey: "claude", + used: rep.used || 0, + total: rep.total || 0, + resetAt: rep.resetAt || null, + remainingPercentage: rep.remainingPercentage, + }); + } + } + + // Show Claude & GPT weekly row if present + if (hasClaudeWeekly) { + summaryModels.filter(([k]) => k === "claude_gpt_weekly").forEach(([modelKey, quota]) => { + normalizedQuotas.push({ + name: quota.displayName || modelKey, + modelKey, + used: quota.used || 0, + total: quota.total || 0, + resetAt: quota.resetAt || null, + remainingPercentage: quota.remainingPercentage, + }); }); } - weeklyModels.forEach(([modelKey, quota]) => { - normalizedQuotas.push({ - name: quota.displayName || modelKey, - modelKey, - used: quota.used || 0, - total: quota.total || 0, - resetAt: quota.resetAt || null, - remainingPercentage: quota.remainingPercentage, - }); - }); - + // 3. Standalone Image Generation Models (unique usage) imageModels.forEach(([modelKey, quota]) => { normalizedQuotas.push({ name: quota.displayName || modelKey, @@ -433,16 +494,23 @@ export function parseQuotaData(provider, data) { }); }); - otherModels.forEach(([modelKey, quota]) => { - normalizedQuotas.push({ - name: quota.displayName || modelKey, - modelKey, - used: quota.used || 0, - total: quota.total || 0, - resetAt: quota.resetAt || null, - remainingPercentage: quota.remainingPercentage, + // 4. Other models: + // In Antigravity, GPT-OSS is explicitly documented by Google as part of the "Claude and GPT models" group: + // ("Models within this group: Claude Opus, Claude Sonnet, GPT-OSS"). + // When summary quotas (claude_gpt_session / claude_gpt_weekly) are present, GPT-OSS is already represented + // by the "Claude & GPT" family rows. We only include otherModels if no summary exists for that pool. + if (!hasClaudeWeekly && !hasClaudeSession) { + otherModels.forEach(([modelKey, quota]) => { + normalizedQuotas.push({ + name: quota.displayName || modelKey, + modelKey, + used: quota.used || 0, + total: quota.total || 0, + resetAt: quota.resetAt || null, + remainingPercentage: quota.remainingPercentage, + }); }); - }); + } } break; @@ -702,10 +770,10 @@ export function parseQuotaData(provider, data) { // Use modelKey for antigravity (mapped to family anchor), otherwise use name let keyA = a.modelKey || a.name; let keyB = b.modelKey || b.name; - if (keyA === "gemini") keyA = "gemini-3.8-flash-high"; - if (keyA === "claude") keyA = "claude-sonnet-4-6"; - if (keyB === "gemini") keyB = "gemini-3.8-flash-high"; - if (keyB === "claude") keyB = "claude-sonnet-4-6"; + if (keyA === "gemini" || keyA === "gemini_session") keyA = "gemini-3.8-flash-high"; + if (keyA === "claude" || keyA === "claude_gpt_session") keyA = "claude-sonnet-4-6"; + if (keyB === "gemini" || keyB === "gemini_session") keyB = "gemini-3.8-flash-high"; + if (keyB === "claude" || keyB === "claude_gpt_session") keyB = "claude-sonnet-4-6"; const orderA = orderMap.get(keyA) ?? 999; const orderB = orderMap.get(keyB) ?? 999; return orderA - orderB; diff --git a/tests/unit/antigravity-weekly-dashboard.test.js b/tests/unit/antigravity-weekly-dashboard.test.js index 1c4a2784..47b618b6 100644 --- a/tests/unit/antigravity-weekly-dashboard.test.js +++ b/tests/unit/antigravity-weekly-dashboard.test.js @@ -82,17 +82,97 @@ describe("Antigravity dashboard normalization with weekly quotas", () => { expect(weeklyRows[1].name).toMatch(/Weekly/); }); - it("order: gemini family, claude family, weekly, then other", () => { - const quotas = parseQuotaData("antigravity", data); + it("order: session, weekly, then other models", () => { + const dataWithBoth = { + quotas: { + gemini_session: { + displayName: "Gemini (5h)", + used: 100, + total: 1000, + resetAt: "2026-09-08T05:00:00Z", + remainingPercentage: 90, + }, + gemini_weekly: { + displayName: "Gemini (Weekly)", + used: 250, + total: 1000, + resetAt: "2026-09-15T00:00:00Z", + remainingPercentage: 75, + }, + claude_gpt_session: { + displayName: "Claude & GPT (5h)", + used: 50, + total: 1000, + resetAt: "2026-09-08T05:00:00Z", + remainingPercentage: 95, + }, + claude_gpt_weekly: { + displayName: "Claude & GPT (Weekly)", + used: 500, + total: 1000, + resetAt: "2026-09-14T00:00:00Z", + remainingPercentage: 50, + }, + }, + }; + const quotas = parseQuotaData("antigravity", dataWithBoth); const keys = quotas.map((q) => q.modelKey); - const geminiIdx = keys.indexOf("gemini"); - const claudeIdx = keys.indexOf("claude"); + const geminiSessionIdx = keys.indexOf("gemini_session"); const geminiWeeklyIdx = keys.indexOf("gemini_weekly"); + const claudeSessionIdx = keys.indexOf("claude_gpt_session"); const claudeWeeklyIdx = keys.indexOf("claude_gpt_weekly"); - expect(geminiIdx).toBeLessThan(geminiWeeklyIdx); - expect(claudeIdx).toBeLessThan(claudeWeeklyIdx); + expect(geminiSessionIdx).toBeLessThan(geminiWeeklyIdx); + expect(claudeSessionIdx).toBeLessThan(claudeWeeklyIdx); + }); + + it("excludes redundant duplicates when individual models mirror weekly reset and summary is present", () => { + // Exact scenario from Christian's account: + const liveLikeData = { + quotas: { + "gemini-3.8-flash-high": { used: 1000, total: 1000, remainingPercentage: 0, resetAt: "2026-09-23T06:00:17Z", displayName: "Gemini 3.8 Flash (High)" }, + "claude-sonnet-4-6": { used: 1000, total: 1000, remainingPercentage: 0, resetAt: "2026-09-20T19:00:21Z", displayName: "Claude 3.7 Sonnet" }, + "gpt-oss-120b-medium": { used: 1000, total: 1000, remainingPercentage: 0, resetAt: "2026-09-20T19:00:21Z", displayName: "GPT-OSS 120B (Medium)" }, + "gemini-3.1-flash-image": { used: 1000, total: 1000, remainingPercentage: 0, resetAt: "2026-09-23T06:00:17Z", displayName: "Gemini 3.1 Flash Image" }, + gemini_weekly: { + displayName: "Gemini (Weekly)", + used: 1000, + total: 1000, + resetAt: "2026-09-23T06:00:17Z", + remainingPercentage: 0, + }, + claude_gpt_weekly: { + displayName: "Claude & GPT (Weekly)", + used: 807, + total: 1000, + resetAt: "2026-09-24T18:09:46Z", + remainingPercentage: 19.3, + }, + claude_gpt_session: { + displayName: "Claude & GPT (5h)", + used: 1000, + total: 1000, + resetAt: "2026-09-20T19:00:21Z", + remainingPercentage: 0, + }, + }, + }; + + const quotas = parseQuotaData("antigravity", liveLikeData); + const names = quotas.map((q) => q.name); + + // Should contain unique usages: + expect(names).toContain("Claude & GPT (5h)"); + expect(names).toContain("Claude & GPT (Weekly)"); + expect(names).toContain("Gemini (Weekly)"); + expect(names).toContain("Gemini 3.1 Flash Image"); + + // Should NOT contain redundant duplicate entries: + expect(names).not.toContain("Gemini (Flash / Pro)"); // duplicate of Gemini (Weekly) + expect(names).not.toContain("Claude (Sonnet / Opus)"); // duplicate of Claude & GPT (5h) + expect(names).not.toContain("GPT-OSS 120B (Medium)"); // covered by Claude & GPT family + expect(quotas).toHaveLength(4); }); it("works with no weekly keys present (backward compat)", () => { diff --git a/tests/unit/antigravity-weekly-quota.test.js b/tests/unit/antigravity-weekly-quota.test.js index 55cb3a81..a565a2c1 100644 --- a/tests/unit/antigravity-weekly-quota.test.js +++ b/tests/unit/antigravity-weekly-quota.test.js @@ -53,7 +53,7 @@ const NESTED_RESPONSE = { // — parseWeeklyQuotaSummary ——————————————————————————————— describe("parseWeeklyQuotaSummary", () => { - it("extracts Gemini weekly quota from top-level groups", () => { + it("extracts Gemini weekly and session quotas from top-level groups", () => { const result = parseWeeklyQuotaSummary(FULL_RESPONSE); expect(result.gemini_weekly).toMatchObject({ used: 250, @@ -63,6 +63,14 @@ describe("parseWeeklyQuotaSummary", () => { unlimited: false, }); expect(result.gemini_weekly.resetAt).toBe("2026-09-15T00:00:00.000Z"); + expect(result.gemini_session).toMatchObject({ + used: 100, + total: 1000, + remainingPercentage: 90, + displayName: "Gemini (5h)", + unlimited: false, + }); + expect(result.gemini_session.resetAt).toBe("2026-09-09T00:00:00.000Z"); }); it("extracts Claude & GPT weekly quota", () => { @@ -85,14 +93,14 @@ describe("parseWeeklyQuotaSummary", () => { expect(result.claude_gpt_weekly.remainingPercentage).toBe(50); }); - it("skips non-weekly buckets", () => { + it("skips unrecognized non-weekly non-session buckets", () => { const data = { groups: [{ displayName: "Gemini Models", buckets: [ { - bucketId: "gemini-daily-bucket", - displayName: "Daily Limit", + bucketId: "gemini-monthly-bucket", + displayName: "Monthly Limit", remainingFraction: 0.9, resetTime: "2026-09-09T00:00:00Z", }, @@ -404,7 +412,7 @@ describe("weekly quota isolation from existing quota", () => { }); }); - it("reconciles weekly quota to 0% when all paid-tier family models are exhausted", async () => { + it("reconciles 5h session quota to 0% when all paid-tier family models are exhausted without clobbering weekly quota", async () => { proxyAwareFetch.mockImplementation(async (url) => { if (url.includes(":loadCodeAssist")) { return { @@ -421,7 +429,7 @@ describe("weekly quota isolation from existing quota", () => { models: { "gemini-3.8-flash-high": { displayName: "Gemini 3.8 Flash (High)", - // Exhausted model: no remainingFraction, future resetTime + // Exhausted model: no remainingFraction, future resetTime (5h window reset) quotaInfo: { resetTime: "2026-09-13T12:00:00Z" }, }, }, @@ -435,30 +443,49 @@ describe("weekly quota isolation from existing quota", () => { json: async () => ({ groups: [{ displayName: "Gemini Models", - buckets: [{ - bucketId: "gemini-weekly", - displayName: "Weekly Limit Remaining", - remainingFraction: 1, - resetTime: "2026-09-15T00:00:00Z", - }], + buckets: [ + { + bucketId: "gemini-weekly", + displayName: "Weekly Limit Remaining", + window: "weekly", + remainingFraction: 0.75, + resetTime: "2026-09-15T00:00:00Z", + }, + { + bucketId: "gemini-5h", + displayName: "Five Hour Limit Remaining", + window: "5h", + remainingFraction: 1, + resetTime: "2026-09-13T11:00:00Z", + }, + ], }], }), }; } - return { ok: false, status: 404 }; + return { ok: true, status: 200, json: async () => ({}) }; }); const { getAntigravityUsage } = await import("../../open-sse/services/usage/google.js"); - const result = await getAntigravityUsage("token", {}); + const result = await getAntigravityUsage("token-exhausted", null); // Per-model quota should show exhausted expect(result.quotas["gemini-3.8-flash-high"].remainingPercentage).toBe(0); - // Weekly quota should be reconciled to 0% with the family reset time - expect(result.quotas.gemini_weekly).toMatchObject({ + + // 5h session quota should be reconciled to 0% with the family reset time + expect(result.quotas.gemini_session).toMatchObject({ used: 1000, total: 1000, remainingPercentage: 0, resetAt: "2026-09-13T12:00:00.000Z", }); + + // Weekly quota should remain intact and NOT be clobbered to 0% or steal the 5h resetAt + expect(result.quotas.gemini_weekly).toMatchObject({ + used: 250, + total: 1000, + remainingPercentage: 75, + resetAt: "2026-09-15T00:00:00.000Z", + }); }); });