fix(antigravity): separate weekly and short-window quotas and clean up redundant rows

- Track both weekly and 5-hour session buckets in parseWeeklyQuotaSummary,
  distinguishing sliding-window limits from multi-day weekly limits
- Preserve disabled session buckets at 0% rather than dropping them when weekly limits are reached
- Target 5-hour session rows (not weekly rows) during family exhaustion reconciliation in getAntigravityUsage
- Suppress synthesized per-model duplicate rows in dashboard normalization when family summaries are present
- Add unit test coverage for multi-bucket extraction, reconciliation isolation, and dashboard deduplication
This commit is contained in:
Christian Gennari
2026-09-21 20:15:11 +07:00
parent 2daf25ffbe
commit be3bc764b1
5 changed files with 281 additions and 92 deletions

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;

View File

@@ -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)", () => {

View File

@@ -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",
});
});
});