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:
@@ -25,10 +25,18 @@ export function _clearWeeklyCache() {
|
|||||||
weeklyCache.clear();
|
weeklyCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// — Group-name to stable key mapping ——————————————————————
|
// — Group-name and window to stable key mapping ——————————————————————
|
||||||
const GROUP_MATCHERS = [
|
const GROUP_CONFIGS = [
|
||||||
{ pattern: /gemini/i, key: "gemini_weekly", displayName: "Gemini (Weekly)" },
|
{
|
||||||
{ pattern: /claude|gpt/i, key: "claude_gpt_weekly", displayName: "Claude & GPT (Weekly)" },
|
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) {
|
for (const bucket of buckets) {
|
||||||
if (!bucket || typeof bucket !== "object") continue;
|
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();
|
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 (!isWeekly && !isSession) continue;
|
||||||
if (bucket.disabled === true) 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;
|
if (!Number.isFinite(remainingFraction)) continue;
|
||||||
|
|
||||||
// Match group to a known family
|
// Match group to a known family
|
||||||
for (const matcher of GROUP_MATCHERS) {
|
for (const config of GROUP_CONFIGS) {
|
||||||
if (matcher.pattern.test(displayName)) {
|
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 total = 1000;
|
||||||
const remaining = Math.round(total * remainingFraction);
|
const remaining = Math.round(total * remainingFraction);
|
||||||
const used = Math.max(0, total - remaining);
|
const used = Math.max(0, total - remaining);
|
||||||
|
|
||||||
result[matcher.key] = {
|
result[target.key] = {
|
||||||
used,
|
used,
|
||||||
total,
|
total,
|
||||||
resetAt: parseResetTime(bucket.resetTime),
|
resetAt: parseResetTime(bucket.resetTime),
|
||||||
remainingPercentage: remainingFraction * 100,
|
remainingPercentage: remainingFraction * 100,
|
||||||
unlimited: false,
|
unlimited: false,
|
||||||
displayName: matcher.displayName,
|
displayName: target.displayName,
|
||||||
};
|
};
|
||||||
break; // first matching bucket per family wins
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,39 +228,37 @@ export async function getAntigravityUsage(accessToken, providerSpecificData, pro
|
|||||||
proxyOptions
|
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)
|
// If every model in a family is locked/exhausted (remainingPercentage === 0)
|
||||||
// until a future reset time, the weekly limit cannot be 100% available.
|
// until a future reset time, update the 5h session row (not the weekly row).
|
||||||
// On Google's Free Starter tier, retrieveUserQuotaSummary buggily reports
|
|
||||||
// remainingFraction: 1 even after the starter quota is depleted and all models 429.
|
|
||||||
const entries = Object.entries(quotas);
|
const entries = Object.entries(quotas);
|
||||||
const geminiModels = entries.filter(([k]) => k.startsWith("gemini-") && !k.includes("image"));
|
const geminiModels = entries.filter(([k]) => k.startsWith("gemini-") && !k.includes("image"));
|
||||||
const claudeModels = entries.filter(([k]) => k.startsWith("claude-"));
|
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);
|
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]) =>
|
const maxResetAt = geminiModels.reduce((max, [, q]) =>
|
||||||
!max || (q.resetAt && new Date(q.resetAt) > new Date(max)) ? q.resetAt : max, null
|
!max || (q.resetAt && new Date(q.resetAt) > new Date(max)) ? q.resetAt : max, null
|
||||||
);
|
);
|
||||||
weeklyQuotas.gemini_weekly.used = weeklyQuotas.gemini_weekly.total;
|
weeklyQuotas.gemini_session.used = weeklyQuotas.gemini_session.total;
|
||||||
weeklyQuotas.gemini_weekly.remainingPercentage = 0;
|
weeklyQuotas.gemini_session.remainingPercentage = 0;
|
||||||
if (maxResetAt) {
|
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);
|
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]) =>
|
const maxResetAt = claudeModels.reduce((max, [, q]) =>
|
||||||
!max || (q.resetAt && new Date(q.resetAt) > new Date(max)) ? q.resetAt : max, null
|
!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_session.used = weeklyQuotas.claude_gpt_session.total;
|
||||||
weeklyQuotas.claude_gpt_weekly.remainingPercentage = 0;
|
weeklyQuotas.claude_gpt_session.remainingPercentage = 0;
|
||||||
if (maxResetAt) {
|
if (maxResetAt) {
|
||||||
weeklyQuotas.claude_gpt_weekly.resetAt = maxResetAt;
|
weeklyQuotas.claude_gpt_session.resetAt = maxResetAt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -377,51 +377,112 @@ export function parseQuotaData(provider, data) {
|
|||||||
if (data.quotas) {
|
if (data.quotas) {
|
||||||
const entries = Object.entries(data.quotas);
|
const entries = Object.entries(data.quotas);
|
||||||
const weeklyKeys = new Set(["gemini_weekly", "claude_gpt_weekly"]);
|
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 geminiModels = entries.filter(([k]) => k.startsWith("gemini-") && !k.includes("image"));
|
||||||
const claudeModels = entries.filter(([k]) => k.startsWith("claude-"));
|
const claudeModels = entries.filter(([k]) => k.startsWith("claude-"));
|
||||||
const imageModels = entries.filter(([k]) => k.includes("image"));
|
const imageModels = entries.filter(([k]) => k.includes("image"));
|
||||||
const weeklyModels = entries.filter(([k]) => 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") && !weeklyKeys.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) =>
|
const rep = geminiModels.reduce((min, cur) =>
|
||||||
(cur[1].remainingPercentage ?? 100) < (min[1].remainingPercentage ?? 100) ? cur : min
|
(cur[1].remainingPercentage ?? 100) < (min[1].remainingPercentage ?? 100) ? cur : min
|
||||||
)[1];
|
)[1];
|
||||||
normalizedQuotas.push({
|
// Only show synthesized Gemini row if its resetAt differs from weekly (i.e. it represents a separate 5h window)
|
||||||
name: "Gemini (Flash / Pro)",
|
const weeklyResetAt = data.quotas.gemini_weekly?.resetAt;
|
||||||
modelKey: "gemini",
|
const isDuplicateOfWeekly = hasGeminiWeekly && rep.resetAt === weeklyResetAt && (rep.remainingPercentage ?? 0) === 0;
|
||||||
used: rep.used || 0,
|
|
||||||
total: rep.total || 0,
|
if (!isDuplicateOfWeekly) {
|
||||||
resetAt: rep.resetAt || null,
|
normalizedQuotas.push({
|
||||||
remainingPercentage: rep.remainingPercentage,
|
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) =>
|
const rep = claudeModels.reduce((min, cur) =>
|
||||||
(cur[1].remainingPercentage ?? 100) < (min[1].remainingPercentage ?? 100) ? cur : min
|
(cur[1].remainingPercentage ?? 100) < (min[1].remainingPercentage ?? 100) ? cur : min
|
||||||
)[1];
|
)[1];
|
||||||
normalizedQuotas.push({
|
const weeklyResetAt = data.quotas.claude_gpt_weekly?.resetAt;
|
||||||
name: "Claude (Sonnet / Opus)",
|
const isDuplicateOfWeekly = hasClaudeWeekly && rep.resetAt === weeklyResetAt && (rep.remainingPercentage ?? 0) === 0;
|
||||||
modelKey: "claude",
|
|
||||||
used: rep.used || 0,
|
if (!isDuplicateOfWeekly) {
|
||||||
total: rep.total || 0,
|
normalizedQuotas.push({
|
||||||
resetAt: rep.resetAt || null,
|
name: "Claude (Sonnet / Opus)",
|
||||||
remainingPercentage: rep.remainingPercentage,
|
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]) => {
|
// 3. Standalone Image Generation Models (unique usage)
|
||||||
normalizedQuotas.push({
|
|
||||||
name: quota.displayName || modelKey,
|
|
||||||
modelKey,
|
|
||||||
used: quota.used || 0,
|
|
||||||
total: quota.total || 0,
|
|
||||||
resetAt: quota.resetAt || null,
|
|
||||||
remainingPercentage: quota.remainingPercentage,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
imageModels.forEach(([modelKey, quota]) => {
|
imageModels.forEach(([modelKey, quota]) => {
|
||||||
normalizedQuotas.push({
|
normalizedQuotas.push({
|
||||||
name: quota.displayName || modelKey,
|
name: quota.displayName || modelKey,
|
||||||
@@ -433,16 +494,23 @@ export function parseQuotaData(provider, data) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
otherModels.forEach(([modelKey, quota]) => {
|
// 4. Other models:
|
||||||
normalizedQuotas.push({
|
// In Antigravity, GPT-OSS is explicitly documented by Google as part of the "Claude and GPT models" group:
|
||||||
name: quota.displayName || modelKey,
|
// ("Models within this group: Claude Opus, Claude Sonnet, GPT-OSS").
|
||||||
modelKey,
|
// When summary quotas (claude_gpt_session / claude_gpt_weekly) are present, GPT-OSS is already represented
|
||||||
used: quota.used || 0,
|
// by the "Claude & GPT" family rows. We only include otherModels if no summary exists for that pool.
|
||||||
total: quota.total || 0,
|
if (!hasClaudeWeekly && !hasClaudeSession) {
|
||||||
resetAt: quota.resetAt || null,
|
otherModels.forEach(([modelKey, quota]) => {
|
||||||
remainingPercentage: quota.remainingPercentage,
|
normalizedQuotas.push({
|
||||||
|
name: quota.displayName || modelKey,
|
||||||
|
modelKey,
|
||||||
|
used: quota.used || 0,
|
||||||
|
total: quota.total || 0,
|
||||||
|
resetAt: quota.resetAt || null,
|
||||||
|
remainingPercentage: quota.remainingPercentage,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -702,10 +770,10 @@ export function parseQuotaData(provider, data) {
|
|||||||
// Use modelKey for antigravity (mapped to family anchor), otherwise use name
|
// Use modelKey for antigravity (mapped to family anchor), otherwise use name
|
||||||
let keyA = a.modelKey || a.name;
|
let keyA = a.modelKey || a.name;
|
||||||
let keyB = b.modelKey || b.name;
|
let keyB = b.modelKey || b.name;
|
||||||
if (keyA === "gemini") keyA = "gemini-3.8-flash-high";
|
if (keyA === "gemini" || keyA === "gemini_session") keyA = "gemini-3.8-flash-high";
|
||||||
if (keyA === "claude") keyA = "claude-sonnet-4-6";
|
if (keyA === "claude" || keyA === "claude_gpt_session") keyA = "claude-sonnet-4-6";
|
||||||
if (keyB === "gemini") keyB = "gemini-3.8-flash-high";
|
if (keyB === "gemini" || keyB === "gemini_session") keyB = "gemini-3.8-flash-high";
|
||||||
if (keyB === "claude") keyB = "claude-sonnet-4-6";
|
if (keyB === "claude" || keyB === "claude_gpt_session") keyB = "claude-sonnet-4-6";
|
||||||
const orderA = orderMap.get(keyA) ?? 999;
|
const orderA = orderMap.get(keyA) ?? 999;
|
||||||
const orderB = orderMap.get(keyB) ?? 999;
|
const orderB = orderMap.get(keyB) ?? 999;
|
||||||
return orderA - orderB;
|
return orderA - orderB;
|
||||||
|
|||||||
@@ -82,17 +82,97 @@ describe("Antigravity dashboard normalization with weekly quotas", () => {
|
|||||||
expect(weeklyRows[1].name).toMatch(/Weekly/);
|
expect(weeklyRows[1].name).toMatch(/Weekly/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("order: gemini family, claude family, weekly, then other", () => {
|
it("order: session, weekly, then other models", () => {
|
||||||
const quotas = parseQuotaData("antigravity", data);
|
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 keys = quotas.map((q) => q.modelKey);
|
||||||
|
|
||||||
const geminiIdx = keys.indexOf("gemini");
|
const geminiSessionIdx = keys.indexOf("gemini_session");
|
||||||
const claudeIdx = keys.indexOf("claude");
|
|
||||||
const geminiWeeklyIdx = keys.indexOf("gemini_weekly");
|
const geminiWeeklyIdx = keys.indexOf("gemini_weekly");
|
||||||
|
const claudeSessionIdx = keys.indexOf("claude_gpt_session");
|
||||||
const claudeWeeklyIdx = keys.indexOf("claude_gpt_weekly");
|
const claudeWeeklyIdx = keys.indexOf("claude_gpt_weekly");
|
||||||
|
|
||||||
expect(geminiIdx).toBeLessThan(geminiWeeklyIdx);
|
expect(geminiSessionIdx).toBeLessThan(geminiWeeklyIdx);
|
||||||
expect(claudeIdx).toBeLessThan(claudeWeeklyIdx);
|
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)", () => {
|
it("works with no weekly keys present (backward compat)", () => {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const NESTED_RESPONSE = {
|
|||||||
|
|
||||||
// — parseWeeklyQuotaSummary ———————————————————————————————
|
// — parseWeeklyQuotaSummary ———————————————————————————————
|
||||||
describe("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);
|
const result = parseWeeklyQuotaSummary(FULL_RESPONSE);
|
||||||
expect(result.gemini_weekly).toMatchObject({
|
expect(result.gemini_weekly).toMatchObject({
|
||||||
used: 250,
|
used: 250,
|
||||||
@@ -63,6 +63,14 @@ describe("parseWeeklyQuotaSummary", () => {
|
|||||||
unlimited: false,
|
unlimited: false,
|
||||||
});
|
});
|
||||||
expect(result.gemini_weekly.resetAt).toBe("2026-09-15T00:00:00.000Z");
|
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", () => {
|
it("extracts Claude & GPT weekly quota", () => {
|
||||||
@@ -85,14 +93,14 @@ describe("parseWeeklyQuotaSummary", () => {
|
|||||||
expect(result.claude_gpt_weekly.remainingPercentage).toBe(50);
|
expect(result.claude_gpt_weekly.remainingPercentage).toBe(50);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips non-weekly buckets", () => {
|
it("skips unrecognized non-weekly non-session buckets", () => {
|
||||||
const data = {
|
const data = {
|
||||||
groups: [{
|
groups: [{
|
||||||
displayName: "Gemini Models",
|
displayName: "Gemini Models",
|
||||||
buckets: [
|
buckets: [
|
||||||
{
|
{
|
||||||
bucketId: "gemini-daily-bucket",
|
bucketId: "gemini-monthly-bucket",
|
||||||
displayName: "Daily Limit",
|
displayName: "Monthly Limit",
|
||||||
remainingFraction: 0.9,
|
remainingFraction: 0.9,
|
||||||
resetTime: "2026-09-09T00:00:00Z",
|
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) => {
|
proxyAwareFetch.mockImplementation(async (url) => {
|
||||||
if (url.includes(":loadCodeAssist")) {
|
if (url.includes(":loadCodeAssist")) {
|
||||||
return {
|
return {
|
||||||
@@ -421,7 +429,7 @@ describe("weekly quota isolation from existing quota", () => {
|
|||||||
models: {
|
models: {
|
||||||
"gemini-3.8-flash-high": {
|
"gemini-3.8-flash-high": {
|
||||||
displayName: "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" },
|
quotaInfo: { resetTime: "2026-09-13T12:00:00Z" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -435,30 +443,49 @@ describe("weekly quota isolation from existing quota", () => {
|
|||||||
json: async () => ({
|
json: async () => ({
|
||||||
groups: [{
|
groups: [{
|
||||||
displayName: "Gemini Models",
|
displayName: "Gemini Models",
|
||||||
buckets: [{
|
buckets: [
|
||||||
bucketId: "gemini-weekly",
|
{
|
||||||
displayName: "Weekly Limit Remaining",
|
bucketId: "gemini-weekly",
|
||||||
remainingFraction: 1,
|
displayName: "Weekly Limit Remaining",
|
||||||
resetTime: "2026-09-15T00:00:00Z",
|
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 { 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
|
// Per-model quota should show exhausted
|
||||||
expect(result.quotas["gemini-3.8-flash-high"].remainingPercentage).toBe(0);
|
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,
|
used: 1000,
|
||||||
total: 1000,
|
total: 1000,
|
||||||
remainingPercentage: 0,
|
remainingPercentage: 0,
|
||||||
resetAt: "2026-09-13T12:00:00.000Z",
|
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",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user