usage: an Antigravity account shows each group of models' 5-hour and weekly allowance, one row a group (a user on Discord: 这里显示5h 和一周剩余比较好吧,现在是只显示5h,这3个模型都是一样的,没必要分开…多加个7day 条就好) — fetchAvailableModels gives each model only its 5-hour figure, the same for every model drawing on one quota, so the Usage page, the panel and the account rows showed a figure a family (Gemini, Claude, GPT-OSS) and no week. Antigravity's retrieveUserQuotaSummary reports the quota a group ("Gemini Models"; "Claude and GPT models", Claude and GPT-OSS sharing one), each with a 5h and a weekly bucket; magpie now reads it beside the models' (best-effort: without it the windows are as before) and adds each group's windows, named "5 hours" and "7 days" with their pool (Gemini, Claude & GPT), each model's window naming its pool. The Usage page shows Gemini · 5 hours, Gemini · 7 days, Claude & GPT · 5 hours, Claude & GPT · 7 days, the pool's models in each tooltip; the panel four rings (Gemini 5h/7d, Claude 5h/7d); the account rows each pool with its 5h and 7d meters; the TUI the pools' windows in place of the models'. "Every model" lists the models' own windows. The pools' windows are aside for routing, which reads the models' as before; the tray's text is unchanged. TestAntigravityQuotaPools and quota-pools.test.cjs (Chromium, WebKit; en, zh) fail without the change; quota-families, click-scroll, old-webkit, tray-usages, panel-usage pass. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.

This commit is contained in:
yetone committed 2026-10-02 07:03:17 +08:00
1 parent cb761abdd1
commit 5df609e525
9 files changed
+514 -8

No files matched your search

+3
View File
@@ -1090,6 +1090,8 @@ body.panel .profiles #save { height: 25px; } /* the field's: the line keeps its
.pq-ring.full .pq-dial { background: conic-gradient(var(--amber) calc(var(--p) * 1%), color-mix(in srgb, var(--amber) 18%, transparent) 0); }
.pq-ring.full b { color: var(--fg); }
.pq-rn { max-width: 44px; font-size: 9.5px; line-height: 1.2; color: var(--faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pq-rn2 { display: flex; flex-direction: column; align-items: center; }
.pq-rn2 > span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
/* a Codex account's rate-limit resets, under a hairline across the card */
.pq-resets { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; margin-top: 5px; padding-top: 7px; border-top: 1px solid var(--line-2); font-size: 11px; font-variant-numeric: tabular-nums; }
.resets-words { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -1609,6 +1611,7 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
.acc .aq-w.full b, .acc .aq-w.full .aq-r { color: var(--fg); }
.acc .aq-w.full .aq-track i { background: var(--fg); }
.acc .aq-r { color: var(--muted); }
.acc .aq-k { color: var(--muted); font-variant-numeric: tabular-nums; }
.acc .aq-none { color: var(--faint); }
.acc .skeleton.sk-aq { width: 110px; height: 8px; border-radius: 4px; }
.acc .dot.tick.fixed { cursor: default; }
+92 -6
View File
@@ -6797,6 +6797,7 @@ function accountQuota(data, user) {
// the two rolling windows fit a line; the per-model ones go in its
// tooltip; per-model windows of a family are the family's one
const ws = familyWindows(q.windows);
if (ws.some((w) => w.members)) return poolLine(line, ws, q);
line.title = ws.slice(2).map((w) => w.tiers ? tiersText(w) : t(w.name) + " " + quotaText(w)).join(ws !== q.windows ? "\n" : " · ");
if (q.asOf) line.title = [line.title, asOfText(q)].filter(Boolean).join("\n");
for (const w of ws.slice(0, 2)) {
@@ -6818,6 +6819,36 @@ function accountQuota(data, user) {
return line;
}
// poolLine: an account row's pools, each its name then its 5-hour and
// weekly meters (Gemini 5h ▬ 95% 7d ▬ 75%), two pools on the line and the
// rest in its tooltip.
function poolLine(line, ws, q) {
const pools = [];
for (const w of ws) {
const k = w.members ? w.pool : "\0" + pools.length;
const p = pools.find((x) => x.k === k);
if (p) p.ws.push(w); else pools.push({ k, name: w.members ? w.pool : t(w.name), ws: [w] });
}
line.title = pools.slice(2).map((p) => p.ws.map((w) => t(w.name) + " " + quotaText(w)).join(" · ")).join("\n");
if (q.asOf) line.title = [line.title, asOfText(q)].filter(Boolean).join("\n");
for (const p of pools.slice(0, 2)) {
const m = el("span", "aq-w aq-pool" + (p.ws.some((w) => w.used >= 90) ? " full" : ""));
m.append(el("span", "aq-n", p.name));
for (const w of p.ws) {
const track = el("span", "aq-track");
const fill = el("i");
fill.style.width = quotaFill(w) + "%";
track.append(fill);
m.append(el("span", "aq-k", w.window ? shortWindow(w.window) : ""), track, el("b", "", quotaText(w)));
}
const w = p.ws[0];
m.title = p.ws.map((x) => t(x.name) + " " + quotaText(x) + (x.resetsAt ? " · " + t("Resets {when}", { when: new Date(x.resetsAt).toLocaleString() }) : "")).join("\n")
+ (w.members ? "\n\n" + poolTip(w) : w.tiers ? "\n\n" + tiersText(w) : "");
line.append(m);
}
return line;
}
// A window reads as how much of it is used, or — as the vendors' own apps
// show it — how much is left, the bar filling with that; one choice for
// every meter and the menu bar, kept in the settings (#122). The vendor's
@@ -6843,6 +6874,7 @@ function quotaText(w) {
// account with a window a family already, are as they were.
const FAMILY_FIRST = ["Gemini", "Claude"];
function familyWindows(ws) {
if (ws?.some(isPool)) return poolWindows(ws);
if (!ws?.some((w) => w.family)) return ws;
const fams = new Map();
for (const w of ws) {
@@ -6861,6 +6893,47 @@ function familyWindows(ws) {
const rank = (w) => (w.tiers && FAMILY_FIRST.includes(w.name) ? FAMILY_FIRST.indexOf(w.name) : FAMILY_FIRST.length);
return out.map((w, i) => [w, i]).sort(([a, i], [b, j]) => rank(a) - rank(b) || i - j).map(([w]) => w);
}
// poolWindows: an account's windows one row a pool, where models share one
// allowance (Antigravity's Gemini, Claude & GPT): the pool's 5-hour window
// then its weekly one, each named with its pool, its models along as
// members for its tooltip (a user on Discord: the three models read the
// same, show the 5 hours and the week left a group). The models' windows
// in no pool go on as families.
const isPool = (w) => w.pool && !w.family;
function poolWindows(ws) {
const pools = new Map();
for (const w of ws.filter(isPool)) {
if (!pools.has(w.pool)) pools.set(w.pool, []);
pools.get(w.pool).push(w);
}
const hours = (w) => {
const m = /^(\d+)\s*(hour|day|week)s?$/i.exec(w.name || "");
return m ? m[1] * { hour: 1, day: 24, week: 168 }[m[2].toLowerCase()] : Infinity;
};
const first = (p) => { const i = FAMILY_FIRST.indexOf(p.split(/[ &]/)[0]); return i < 0 ? FAMILY_FIRST.length : i; };
const out = [];
for (const pool of [...pools.keys()].sort((a, b) => first(a) - first(b))) {
const members = ws.filter((w) => w.family && w.pool === pool);
for (const w of pools.get(pool).sort((a, b) => hours(a) - hours(b)))
out.push({ ...w, name: pool + " · " + t(w.name), window: w.name, members });
}
const rest = ws.filter((w) => !isPool(w) && !(w.pool && pools.has(w.pool)));
const fam = familyWindows(rest);
return out.concat(fam);
}
// pooledModels: the models a whole account's card lists under "Every model"
// when its windows are a pool's: the models' own, not the pools' again.
const pooledModels = (ws) => (ws?.some(isPool) ? ws.filter((w) => !isPool(w)) : ws);
// poolTip: what a pool's window counts, and its models, for its tooltip.
function poolTip(w) {
return t("{pool}: one allowance for these models", { pool: w.pool }) + "\n" + tiersText({ tiers: w.members });
}
// ringName: a window's name short enough for a ring: a pool's first word
// and its span (Gemini 5h, Claude 7d).
function ringName(w) {
return w.window ? w.pool.split(/[ &]/)[0] + " " + shortWindow(w.window) : shortWindow(w.name);
}
// tiersText: a family's windows, one a line, for its tooltip.
function tiersText(w) {
return (w.tiers || []).map((x) => t(x.name) + " " + quotaText(x)
@@ -8039,11 +8112,13 @@ function panelQuotaCard(q) {
return card;
}
if (q.asOf) card.title += "\n" + asOfText(q);
const ws = familyWindows(q.windows).slice(0, 3);
// a pool's 5-hour and weekly rings, two pools of them, else three
const fam = familyWindows(q.windows);
const ws = fam.slice(0, fam.some((w) => w.members) ? 4 : 3);
// when the windows begun start again: the first bare, the others by name
const begun = ws.filter((w) => w.resetsAt && w.used > 0);
card.append(el("span", "pq-sub", begun.length
? begun.map((w, i) => (i ? shortWindow(w.name) + " " : "↻ ") + resetClock(new Date(w.resetsAt))).join(" · ")
? begun.map((w, i) => (i || w.members ? ringName(w) + " " : "↻ ") + resetClock(new Date(w.resetsAt))).join(" · ")
: t("Not used yet")));
const rings = el("span", "pq-rings");
for (const w of ws) {
@@ -8052,9 +8127,16 @@ function panelQuotaCard(q) {
const dial = el("span", "pq-dial");
dial.style.setProperty("--p", quotaFill(w));
dial.append(el("b", "", quotaFill(w) + "%"));
r.append(dial, el("span", "pq-rn", shortWindow(w.name)));
// a pool's ring: its first word over its span, too long for one line
const rn = el("span", "pq-rn", w.window ? undefined : ringName(w));
if (w.window) {
rn.classList.add("pq-rn2");
rn.append(el("span", "", w.pool.split(/[ &]/)[0]), el("span", "", shortWindow(w.window)));
}
r.append(dial, rn);
r.title = t(w.name) + " · " + quotaText(w) + (w.resetsAt ? "\n" + t("Resets {when}", { when: new Date(w.resetsAt).toLocaleString() }) + " · " + untilText(new Date(w.resetsAt)) : "")
+ (w.tiers ? "\n\n" + tiersText(w) + "\n" : "")
+ (w.members ? "\n\n" + poolTip(w) + "\n" : "")
+ "\n" + t(quotaLeft ? "Show how much of each window is used" : "Show how much of each window is left");
// used or left turns here too, as on the Usage page (#124)
r.onclick = () => setQuotaLeft(!quotaLeft);
@@ -8298,13 +8380,15 @@ function familyQuota(sub) {
const fam = sub.error ? sub.windows : familyWindows(sub.windows);
if (fam === sub.windows) return [quotaWindows(sub), null];
const key = sub.provider + "|" + (sub.user || "");
const shown = () => quotaWindows(everyModel.has(key) ? sub : { ...sub, windows: fam });
const models = pooledModels(sub.windows);
const pooled = models !== sub.windows;
const shown = () => quotaWindows({ ...sub, windows: everyModel.has(key) ? models : fam });
let box = shown();
const b = el("button", "text quota-every");
const label = () => {
const all = everyModel.has(key);
b.textContent = all ? t("By family") : t("Every model ({n})", { n: sub.windows.length });
b.title = all ? t("One figure a model family, its most used model's") : t("Each model's allowance, level by level");
b.textContent = all ? t(pooled ? "By group" : "By family") : t("Every model ({n})", { n: models.length });
b.title = all ? t(pooled ? "Each group of models' 5-hour and weekly allowance, shared by its models" : "One figure a model family, its most used model's") : t("Each model's allowance, level by level");
b.setAttribute("aria-expanded", String(all));
};
label();
@@ -8354,6 +8438,8 @@ function quotaWindows(sub) {
}
// a model family's figure: its models, level by level, in its tooltip
if (w.tiers) quota.title = t("{family}: the most used of its models", { family: w.name }) + "\n" + tiersText(w);
// a pool's: when it starts again, and the models it counts
if (w.members) quota.title = [quota.title, poolTip(w)].filter(Boolean).join("\n");
windows.append(quota);
}
quotaFit.observe(windows);
+4
View File
@@ -1325,6 +1325,10 @@ const I18N = {
"One figure a model family, its most used model's": "每个模型系列一个数字,取其中用得最多的模型",
"Each model's allowance, level by level": "每个模型各档位的额度",
"{family}: the most used of its models": "{family}:取其中用得最多的模型",
// a group of models sharing one allowance (Antigravity's Gemini, Claude & GPT)
"By group": "按分组",
"Each group of models' 5-hour and weekly allowance, shared by its models": "每组模型共用的 5 小时和每周额度",
"{pool}: one allowance for these models": "{pool}:以下模型共用这份额度",
"tomorrow {time}": "明天 {time}",
"Renews {date}": "{date}续费",
"Expires {date}": "{date}到期",
+13
View File
@@ -1307,4 +1307,17 @@ Z→A; each list keeps its own pick through a reload, and a pick leaves its
control where it was on the screen. It runs in Chromium and WebKit, in
English and Chinese:
node --test internal/gui/tests/list-sort.test.cjs
`quota-pools.test.cjs` checks Antigravity's allowance a row a pool of
models, each with its 5-hour and its weekly window (a user on Discord: the
three models read the same, show the 5 hours and the week left): windows
that name a `pool` show as the pool's 5 hours then its 7 days, Gemini
first, on the Usage page's card (the pool's models in the tooltip), the
menu bar panel's four rings (Gemini 5h, Gemini 7d, Claude 5h, Claude 7d,
each label whole) and the provider's account rows (a pool's name, then its
5h and 7d meters); "Every model" lists the models' own windows, not the
pools' again, and back, without moving the page. Claude Code's windows are
as they were. No left-border accent; English and Chinese, Chromium and
WebKit, with `/api/plugins` faked:
node --test internal/gui/tests/quota-pools.test.cjs
```
+182
View File
@@ -0,0 +1,182 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// Antigravity's allowance a row a pool of models, each with its 5-hour and
// its weekly window (a user on Discord: 这里显示5h 和一周剩余比较好吧,现在
// 是只显示5h,这3个模型都是一样的,没必要分开…多加个7day 条就好). When an
// account's windows name a pool — the models that share one allowance,
// Gemini, Claude & GPT — the Usage page's card shows each pool's 5 hours
// then its 7 days, the pool's models in the tooltip; the menu bar panel
// shows the four as rings (Gemini 5h, Gemini 7d, Claude 5h, Claude 7d) and
// the provider's account rows each pool with both meters. "Every model"
// lists the models' own windows, not the pools' again, and back, the page
// not moving. Claude Code's windows are as they were. No left-border
// accent; English and Chinese, Chromium and WebKit; the API is faked here.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const at = (h) => new Date(Date.now() + h * 36e5).toISOString();
const model = (name, family, pool, used, h) => ({ name, family, pool, used, resetsAt: at(h) });
const pool = (pool, name, used, h) => ({ name, pool, used, resetsAt: at(h) });
// as magpie reports an Antigravity account with retrieveUserQuotaSummary
// read: a window a model, then each pool's weekly and 5-hour windows
const ag = (user, [g5, g7, c5, c7]) => ({
provider: "antigravity", name: "Antigravity", icon: "antigravity-color", user, plan: "Google AI Pro",
windows: [
model("Claude Opus 4.6 (Thinking)", "Claude", "Claude & GPT", c5, 3), model("Claude Sonnet 4.6", "Claude", "Claude & GPT", c5, 3),
model("Gemini 3 Flash", "Gemini", "Gemini", g5, 2), model("Gemini 3.1 Pro (High)", "Gemini", "Gemini", g5, 2),
model("GPT-OSS 120B (Medium)", "GPT-OSS", "Claude & GPT", c5, 3),
pool("Gemini", "7 days", g7, 100), pool("Gemini", "5 hours", g5, 2),
pool("Claude & GPT", "7 days", c7, 90), pool("Claude & GPT", "5 hours", c5, 3),
],
});
const quotas = [
{ provider: "claude", name: "Claude Code", icon: "claude-color", plan: "Max 5x",
windows: [{ name: "5 hours", used: 30, resetsAt: at(2) }, { name: "7 days", used: 12, resetsAt: at(90) }] },
ag("ada@example.com", [5, 25, 30, 60]),
ag("bob@example.com", [0, 10, 100, 100]),
];
// each account's meters: Gemini 5h, 7d, then Claude & GPT 5h, 7d
const figures = { "ada@example.com": [5, 25, 30, 60], "bob@example.com": [0, 10, 100, 100] };
const logins = quotas.slice(1).map((q, i) => ({ user: q.user, plan: q.plan, active: i === 0, on: true }));
const antigravity = {
id: "antigravity", name: "Antigravity", icon: "antigravity-color", chat: "", responses: "", anthropic: "", catalog: "",
models: [{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", on: true }], agents: [], fallback: [], headers: {}, keyList: [],
account: { agent: "antigravity", agentName: "Antigravity", user: logins[0].user, plan: logins[0].plan, logins },
};
function serve(lang, panel) {
const settings = { theme: "light", lang, tray: "panel", quotaLeft: false, currency: "usd" };
return async (route) => {
const url = new URL(route.request().url());
const json = (data) => route.fulfill({ json: data });
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:${!panel}};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings });
if (url.pathname === "/api/settings") return json(settings);
if (url.pathname === "/api/providers") return json({ providers: [antigravity], presets: [], excluded: [], gateway: { running: true, window: true } });
if (url.pathname === "/api/usage/quotas") return json(quotas);
if (url.pathname === "/api/login/usage") return json(Object.fromEntries(quotas.slice(1).map((q) => [q.user, q])));
if (url.pathname === "/api/groups") return json({ groups: [], models: [] });
if (url.pathname === "/api/plugins") return json({ plugins: [] });
if (url.pathname.startsWith("/api/")) return json({});
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); }
};
}
const words = {
en: { five: "5 hours", week: "7 days", every: "Every model (5)", back: "By group", shared: "Gemini: one allowance for these models" },
zh: { five: "5 小时", week: "7 天", every: "全部模型(5)", back: "按分组", shared: "Gemini:以下模型共用这份额度" },
};
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
const w = words[lang];
test(`${engine} ${lang}: Antigravity's allowance a row a pool, 5 hours and 7 days`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
const pages = [];
t.after(async () => {
if (process.env.ARTIFACT_DIR) {
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
for (const [name, p] of pages) await p.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-quota-pools-${name}.png`) });
}
await browser.close();
});
const errors = [];
const open = async (name, url, viewport) => {
const page = await (await browser.newContext({ viewport, reducedMotion: "reduce" })).newPage();
pages.push([name, page]);
page.setDefaultTimeout(5000);
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", serve(lang, url.includes("mode=panel")));
await page.goto(url);
return page;
};
const fill = (loc) => loc.evaluateAll((es) => es.map((e) => Math.round(parseFloat(e.style.width))));
const names = [`Gemini · ${w.five}`, `Gemini · ${w.week}`, `Claude & GPT · ${w.five}`, `Claude & GPT · ${w.week}`];
// the Usage page: each pool's 5 hours then its 7 days, Gemini first
const page = await open("usage", "http://magpie.test/?view=usage", { width: 900, height: 700 });
const card = page.locator(".subscription-card", { hasText: "Antigravity" });
await card.locator(".quota-windows").first().waitFor();
const boxes = card.locator(".quota-windows");
assert.equal(await boxes.count(), 2, "one meter box an account");
for (const [i, user] of ["ada@example.com", "bob@example.com"].entries()) {
const box = boxes.nth(i);
assert.deepEqual(await box.locator(".quota-labels > span:first-child").allTextContents(), names, `${user}: a pool's two windows`);
assert.deepEqual(await fill(box.locator(".quota-track i")), figures[user], `${user}: each window's own figure`);
}
const tip = await boxes.first().locator(".quota").nth(1).getAttribute("title");
assert(tip.includes(w.shared), "the pool says its models share it: " + tip);
for (const m of ["Gemini 3 Flash", "Gemini 3.1 Pro (High)"]) assert(tip.includes(m), `the tooltip names ${m}`);
assert(!tip.includes("Claude"), "only its own pool's models");
const other = await boxes.first().locator(".quota").nth(2).getAttribute("title");
for (const m of ["Claude Opus 4.6 (Thinking)", "Claude Sonnet 4.6", "GPT-OSS 120B (Medium)"]) assert(other.includes(m), `Claude & GPT names ${m}`);
// Claude Code's windows name no pool: as they were, no toggle
const cc = page.locator(".subscription-card", { hasText: "Claude Code" });
assert.deepEqual(await cc.locator(".quota-labels > span:first-child").allTextContents(), [w.five, w.week]);
assert.equal(await cc.locator(".quota-every").count(), 0);
// Every model: the five models' windows, not the pools' again; and back
const toggle = card.locator(".subscription-account", { hasText: "ada@example.com" }).locator("button.quota-every");
assert.equal((await toggle.textContent()).trim(), w.every);
const where = () => toggle.evaluate((e) => [e.getBoundingClientRect().top, document.scrollingElement.scrollTop, ...[...document.querySelectorAll(".view")].map((v) => v.scrollTop)]);
await toggle.scrollIntoViewIfNeeded();
await page.waitForTimeout(150);
const before = await where();
await toggle.click();
await page.waitForTimeout(200);
assert.deepEqual(await boxes.first().locator(".quota-labels > span:first-child").allTextContents(),
["Claude Opus 4.6 (Thinking)", "Claude Sonnet 4.6", "Gemini 3 Flash", "Gemini 3.1 Pro (High)", "GPT-OSS 120B (Medium)"]);
assert.equal((await toggle.textContent()).trim(), w.back);
assert.deepEqual(await where(), before, "the click moved the page");
await toggle.click();
await page.waitForTimeout(200);
assert.equal(await boxes.first().locator(".quota").count(), 4, "by pool again");
assert.deepEqual(await where(), before, "the click moved the page");
const border = await page.evaluate(() => [...document.querySelectorAll(".subscription-card, .subscription-card *, .acc, .acc *, .pq-card, .pq-card *")].map((e) => getComputedStyle(e).borderLeftWidth).filter((b) => parseFloat(b) > 1));
assert.deepEqual(border, [], "no left-border accent");
// the menu bar panel: four rings an account, 5h and 7d a pool
const panel = await open("panel", "http://magpie.test/?mode=panel", { width: 440, height: 640 });
await panel.locator('#ptabs [data-ptab="usage"]').click();
const pcards = panel.locator(".pq-group", { hasText: "Antigravity" }).locator(".pq-card");
await pcards.first().locator(".pq-ring").first().waitFor();
assert.equal(await pcards.count(), 2);
for (let i = 0; i < 2; i++)
assert.deepEqual(await pcards.nth(i).locator(".pq-rn").evaluateAll((es) => es.map((e) => [...e.children].map((c) => c.textContent).join(" "))), ["Gemini 5h", "Gemini 7d", "Claude 5h", "Claude 7d"]);
// each label whole, not cut short
assert(await pcards.first().locator(".pq-rn > span").evaluateAll((es) => es.every((e) => e.scrollWidth <= e.clientWidth)), "the ring labels fit");
assert.deepEqual(await pcards.first().locator(".pq-dial b").allTextContents(), ["5%", "25%", "30%", "60%"]);
const ring = await pcards.first().locator(".pq-ring").nth(3).getAttribute("title");
assert(ring.startsWith(`Claude & GPT · ${w.week}`) && ring.includes("GPT-OSS 120B (Medium)"), "the ring names its pool and models: " + ring);
// the rings fit the card, none cut off
const fits = await pcards.first().evaluate((c) => { const r = c.getBoundingClientRect(); return [...c.querySelectorAll(".pq-ring")].every((x) => x.getBoundingClientRect().right <= r.right + 0.5); });
assert(fits, "the four rings fit the card");
// the provider's accounts: each pool with its 5h and 7d meters
const prov = await open("accounts", "http://magpie.test/?view=providers", { width: 900, height: 700 });
await prov.locator(".row.provider", { hasText: "Antigravity" }).click();
await prov.locator(".accts .acc .aq-w").first().waitFor();
for (const user of ["ada@example.com", "bob@example.com"]) {
const row = prov.locator(".accts .acc", { hasText: user });
assert.deepEqual(await row.locator(".aq-n").allTextContents(), ["Gemini", "Claude & GPT"], user);
assert.deepEqual(await row.locator(".aq-k").allTextContents(), ["5h", "7d", "5h", "7d"], user);
assert.deepEqual(await fill(row.locator(".aq-track i")), figures[user], user);
}
const aq = await prov.locator(".accts .acc", { hasText: "bob@example.com" }).locator(".aq-w").nth(1).getAttribute("title");
assert(aq.includes("Claude Opus 4.6 (Thinking)") && aq.includes(`Claude & GPT · ${w.week}`), "the meter's tooltip: " + aq);
assert(await prov.locator(".accts .acc", { hasText: "bob@example.com" }).locator(".aq-w").nth(1).evaluate((e) => e.classList.contains("full")), "a used-up pool reads full");
const missing = await page.evaluate(() => [
"By group", "Each group of models' 5-hour and weekly allowance, shared by its models", "{pool}: one allowance for these models",
].filter((k) => !I18N.zh[k]));
assert.deepEqual(missing, [], "every string has its Chinese");
assert.deepEqual(errors, []);
});
}
}
+118
View File
@@ -28,6 +28,7 @@ import (
"os"
"path/filepath"
"runtime"
"slices"
"sort"
"strconv"
"strings"
@@ -989,12 +990,129 @@ func (g googleAccount) quota(ctx context.Context, plan string) SubscriptionQuota
}
q.Windows = append(q.Windows, w)
}
if g.app.agent == "antigravity" {
q.Windows = append(q.Windows, g.pools(ctx, q.Windows)...)
}
if p, err := g.project(ctx); err == nil && q.Plan == "" {
q.Plan = p.plan
}
return q
}
// pools are an Antigravity account's shared allowances, a 5-hour and a
// weekly one for each group of models drawing on one quota ("Gemini
// Models", "Claude and GPT models"), as retrieveUserQuotaSummary reports
// them; fetchAvailableModels gives each model only the 5-hour figure, the
// same for every model of a group. Each per-model window in ws is given
// its group's name as its Pool, so the GUI shows one row a group. None
// when the summary can't be read: the per-model windows stand alone. They
// are Aside: routing goes on reading the per-model windows, as before.
func (g googleAccount) pools(ctx context.Context, ws []QuotaWindow) []QuotaWindow {
p, err := g.project(ctx)
if err != nil {
return nil
}
var res struct {
Groups []struct {
DisplayName string `json:"displayName"`
Description string `json:"description"`
Buckets []struct {
BucketID string `json:"bucketId"`
Window string `json:"window"`
RemainingFraction *float64 `json:"remainingFraction"`
ResetTime string `json:"resetTime"`
DisplayName string `json:"displayName"`
Disabled bool `json:"disabled"`
} `json:"buckets"`
} `json:"groups"`
}
if err := g.call(ctx, g.app.base, "retrieveUserQuotaSummary", map[string]any{"project": p.id}, &res, nil); err != nil {
return nil
}
var out []QuotaWindow
for _, gr := range res.Groups {
pool := poolName(gr.DisplayName)
if pool == "" {
continue
}
n := 0
for _, b := range gr.Buckets {
if b.Disabled {
continue
}
w := QuotaWindow{Pool: pool, Used: 100, Aside: true}
switch strings.ToLower(b.Window) {
case "5h":
w.Name, w.Span = "5 hours", 5*time.Hour
case "weekly":
w.Name, w.Span = "7 days", 7*24*time.Hour
default:
for _, s := range []string{b.DisplayName, b.Window, b.BucketID} {
if w.Name == "" {
w.Name = s
}
}
}
if w.Name == "" {
continue
}
if b.RemainingFraction != nil {
w.Used = (1 - *b.RemainingFraction) * 100
} // left out once none is left, as in fetchAvailableModels
if t, err := time.Parse(time.RFC3339, b.ResetTime); err == nil {
w.ResetsAt = &t
w.ResetSecs = int64(max(time.Until(t), 0) / time.Second)
}
out = append(out, w)
n++
}
if n == 0 {
continue
}
// a model is in the group its name or description names its
// family in ("Models within this group: Claude Opus, Claude
// Sonnet, GPT-OSS")
about := strings.ToLower(gr.DisplayName + " " + gr.Description)
for i := range ws {
if ws[i].Pool == "" && ws[i].Family != "" && strings.Contains(about, strings.ToLower(ws[i].Family)) {
ws[i].Pool = pool
}
}
}
return out
}
// PooledWindows are ws as a page of text shows them: a pool's own windows,
// named with their pool ("Gemini · 7 days"), in place of the per-model
// windows drawing on it; ws as it is when it has no pool.
func PooledWindows(ws []QuotaWindow) []QuotaWindow {
if !slices.ContainsFunc(ws, func(w QuotaWindow) bool { return w.Pool != "" && w.Model == "" }) {
return ws
}
var out []QuotaWindow
for _, w := range ws {
switch {
case w.Pool == "":
out = append(out, w)
case w.Model == "":
w.Name = w.Pool + " · " + w.Name
out = append(out, w)
}
}
return out
}
// poolName is a quota group's name without "models" at its end, its "and"
// an ampersand: "Gemini Models" is Gemini, "Claude and GPT models" Claude
// & GPT.
func poolName(s string) string {
s = strings.TrimSpace(s)
if l := strings.ToLower(s); strings.HasSuffix(l, " models") {
s = strings.TrimSpace(s[:len(s)-len(" models")])
}
return strings.ReplaceAll(s, " and ", " & ")
}
// antigravityVendor is the family a model of Antigravity's is in — Gemini,
// Claude, GPT-OSS — as its id begins; one it doesn't know by its name's
// first word. (antigravityFamily is one model's levels, a narrower thing.)
+92
View File
@@ -32,6 +32,7 @@ type fakeGoogle struct {
quota string // retrieveUserQuota's reply
flags string // listExperiments' reply
models string // fetchAvailableModels' reply
summary string // retrieveUserQuotaSummary's reply
exps []map[string]any
heads map[string]http.Header
}
@@ -63,6 +64,8 @@ func (f *fakeGoogle) ServeHTTP(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, f.onboard)
case strings.HasSuffix(r.URL.Path, ":retrieveUserQuota") && f.quota != "":
io.WriteString(w, f.quota)
case strings.HasSuffix(r.URL.Path, ":retrieveUserQuotaSummary") && f.summary != "":
io.WriteString(w, f.summary)
case strings.HasSuffix(r.URL.Path, ":fetchAvailableModels") && f.models != "":
io.WriteString(w, f.models)
case strings.HasSuffix(r.URL.Path, ":listExperiments") && f.flags != "":
@@ -440,3 +443,92 @@ func TestAntigravityQuotaFamilies(t *testing.T) {
}
}
}
// An Antigravity account's models draw on a quota a group, each with a
// 5-hour and a weekly window (a user on Discord: 这3个模型都是一样的,没必
// 要分开…多加个7day 条就好); fetchAvailableModels tells only the 5-hour one,
// model by model. The groups' windows, from retrieveUserQuotaSummary, come
// after the models', each model naming its group, and a page of text shows
// the groups' windows in place of the models'.
func TestAntigravityQuotaPools(t *testing.T) {
f := &fakeGoogle{
load: `{"allowedTiers":[{"id":"free-tier","name":"Antigravity","isDefault":true}]}`,
onboard: `{"done":true,"response":{"cloudaicompanionProject":"ag-proj"}}`,
models: `{"models":{
"gemini-3.1-pro-high":{"displayName":"Gemini 3.1 Pro (High)","quotaInfo":{"remainingFraction":0.9545545,"resetTime":"2099-01-01T05:00:00Z"}},
"gemini-3-flash":{"displayName":"Gemini 3 Flash","quotaInfo":{"remainingFraction":0.9545545,"resetTime":"2099-01-01T05:00:00Z"}},
"claude-opus-4-6-thinking":{"displayName":"Claude Opus 4.6 (Thinking)","quotaInfo":{"remainingFraction":0.7}},
"gpt-oss-120b-medium":{"displayName":"GPT-OSS 120B (Medium)","quotaInfo":{"remainingFraction":0.7}}}}`,
// as Antigravity answers (Antigravity-Manager#3185)
summary: `{"groups":[
{"displayName":"Gemini Models","description":"Models within this group: Gemini Flash, Gemini Pro","buckets":[
{"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.75,"resetTime":"2099-01-07T00:00:00Z","displayName":"Weekly Limit"},
{"bucketId":"gemini-5h","window":"5h","remainingFraction":0.9545545,"resetTime":"2099-01-01T05:00:00Z","displayName":"Five Hour Limit"}]},
{"displayName":"Claude and GPT models","description":"Models within this group: Claude Opus, Claude Sonnet, GPT-OSS","buckets":[
{"bucketId":"3p-weekly","window":"weekly","resetTime":"2099-01-06T00:00:00Z"},
{"bucketId":"3p-5h","window":"5h","remainingFraction":0.7,"resetTime":"2099-01-01T03:00:00Z"},
{"bucketId":"3p-x","window":"5h","remainingFraction":1,"disabled":true}]}]}`,
}
googleSandbox(t, f)
auth := googleAuth{AccessToken: "tok", RefreshToken: "rt-ag", Expiry: time.Now().Add(time.Hour).UnixMilli()}
if err := addGoogleLogin("antigravity", "ag@example.com", "", auth); err != nil {
t.Fatal(err)
}
q := googleLogins("antigravity")[0].acct.quota(context.Background(), "")
line := func(w QuotaWindow) string {
at := ""
if w.ResetsAt != nil {
at = w.ResetsAt.UTC().Format("01-02T15")
}
return fmt.Sprintf("%s|%s|%s|%s|%.0f|%s|%v", w.Model, w.Name, w.Family, w.Pool, w.Used, at, w.Aside)
}
var got []string
for _, w := range q.Windows {
got = append(got, line(w))
}
want := []string{
"claude-opus-4-6-thinking|Claude Opus 4.6 (Thinking)|Claude|Claude & GPT|30||false",
"gemini-3-flash|Gemini 3 Flash|Gemini|Gemini|5|01-01T05|false",
"gemini-3.1-pro-high|Gemini 3.1 Pro (High)|Gemini|Gemini|5|01-01T05|false",
"gpt-oss-120b-medium|GPT-OSS 120B (Medium)|GPT-OSS|Claude & GPT|30||false",
"|7 days||Gemini|25|01-07T00|true",
"|5 hours||Gemini|5|01-01T05|true",
"|7 days||Claude & GPT|100|01-06T00|true", // none left: no fraction
"|5 hours||Claude & GPT|30|01-01T03|true",
}
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("windows\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
}
b, _ := json.Marshal(q.Windows[4])
if !strings.Contains(string(b), `"pool":"Gemini"`) {
t.Errorf("json %s", b)
}
// routing reads the models' windows as it did; the pools' are aside
if a := allowanceOf(q.Windows, time.Now()); len(a) != 4 {
t.Errorf("allowance has %d limits, want the 4 models'", len(a))
}
// a model not enabled leaves its pool's windows as they were
kept := chosenWindows(q.Windows, map[string]bool{"gemini-3.1-pro-high": true}, nil)
if len(kept) != 5 {
t.Errorf("chosen windows: %d, want 1 model's and 4 pools'", len(kept))
}
if len(chosenWindows(q.Windows, map[string]bool{}, nil)) != len(q.Windows) {
t.Error("no model enabled: every window is kept")
}
got = nil
for _, w := range PooledWindows(q.Windows) {
got = append(got, w.Name)
}
if strings.Join(got, ",") != "Gemini · 7 days,Gemini · 5 hours,Claude & GPT · 7 days,Claude & GPT · 5 hours" {
t.Errorf("pooled = %v", got)
}
// no summary to be had: the models' windows alone, as before
f.mu.Lock()
f.summary = ""
f.mu.Unlock()
q = googleLogins("antigravity")[0].acct.quota(context.Background(), "")
if len(q.Windows) != 4 || q.Windows[0].Pool != "" || len(PooledWindows(q.Windows)) != 4 {
t.Errorf("without a summary: %+v", q.Windows)
}
}
+9 -1
View File
@@ -32,6 +32,12 @@ type QuotaWindow struct {
// family, the tightest; each window is still here, and routing reads
// them one by one.
Family string `json:"family,omitempty"`
// Pool is the group of models that share one allowance (Antigravity's
// "Gemini", "Claude & GPT"): on the group's own windows, a 5-hour and
// a weekly, which name no Model, and on each per-model window drawing
// on it, for the GUI to show the group's windows in place of its
// models'.
Pool string `json:"pool,omitempty"`
// For routing (see Allowances): how long the window runs, zero when
// not known; the only models it counts, by a word in their ids
@@ -190,6 +196,7 @@ func exposedIDs() map[string]map[string]bool {
// base, when not nil, is the model magpie offers for an id a quota names.
func chosenWindows(ws []QuotaWindow, chosen map[string]bool, base func(string) string) []QuotaWindow {
var out []QuotaWindow
some := false // a model's window kept, not only a pool's
for _, w := range ws {
m := w.Model
if base != nil && m != "" {
@@ -197,9 +204,10 @@ func chosenWindows(ws []QuotaWindow, chosen map[string]bool, base func(string) s
}
if w.Model == "" || chosen[m] {
out = append(out, w)
some = some || w.Model != ""
}
}
if len(out) == 0 {
if !some {
return ws
}
return out
+1 -1
View File
@@ -683,7 +683,7 @@ func quotaLines(qs []provider.SubscriptionQuota, asked, left bool, width int, no
// the windows follow the name, those that don't fit on lines below
// it, and a Codex account's resets after them
var cells []string
for _, w := range q.Windows {
for _, w := range provider.PooledWindows(q.Windows) {
cells = append(cells, quotaCell(w, left, now))
}
if r := q.Resets; r != nil {