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:
9 files changed
+514
-8
No files matched your search
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}到期",
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user