From 5df609e525c10d0c02cc8acaba475fe49a0220ca Mon Sep 17 00:00:00 2001 From: yetone Date: Fri, 2 Oct 2026 07:02:36 +0800 Subject: [PATCH] =?UTF-8?q?usage:=20an=20Antigravity=20account=20shows=20e?= =?UTF-8?q?ach=20group=20of=20models'=205-hour=20and=20weekly=20allowance,?= =?UTF-8?q?=20one=20row=20a=20group=20(a=20user=20on=20Discord:=20?= =?UTF-8?q?=E8=BF=99=E9=87=8C=E6=98=BE=E7=A4=BA5h=20=E5=92=8C=E4=B8=80?= =?UTF-8?q?=E5=91=A8=E5=89=A9=E4=BD=99=E6=AF=94=E8=BE=83=E5=A5=BD=E5=90=A7?= =?UTF-8?q?=EF=BC=8C=E7=8E=B0=E5=9C=A8=E6=98=AF=E5=8F=AA=E6=98=BE=E7=A4=BA?= =?UTF-8?q?5h=EF=BC=8C=E8=BF=993=E4=B8=AA=E6=A8=A1=E5=9E=8B=E9=83=BD?= =?UTF-8?q?=E6=98=AF=E4=B8=80=E6=A0=B7=E7=9A=84=EF=BC=8C=E6=B2=A1=E5=BF=85?= =?UTF-8?q?=E8=A6=81=E5=88=86=E5=BC=80=E2=80=A6=E5=A4=9A=E5=8A=A0=E4=B8=AA?= =?UTF-8?q?7day=20=E6=9D=A1=E5=B0=B1=E5=A5=BD)=20=E2=80=94=20fetchAvailabl?= =?UTF-8?q?eModels=20gives=20each=20model=20only=20its=205-hour=20figure,?= =?UTF-8?q?=20the=20same=20for=20every=20model=20drawing=20on=20one=20quot?= =?UTF-8?q?a,=20so=20the=20Usage=20page,=20the=20panel=20and=20the=20accou?= =?UTF-8?q?nt=20rows=20showed=20a=20figure=20a=20family=20(Gemini,=20Claud?= =?UTF-8?q?e,=20GPT-OSS)=20and=20no=20week.=20Antigravity's=20retrieveUser?= =?UTF-8?q?QuotaSummary=20reports=20the=20quota=20a=20group=20("Gemini=20M?= =?UTF-8?q?odels";=20"Claude=20and=20GPT=20models",=20Claude=20and=20GPT-O?= =?UTF-8?q?SS=20sharing=20one),=20each=20with=20a=205h=20and=20a=20weekly?= =?UTF-8?q?=20bucket;=20magpie=20now=20reads=20it=20beside=20the=20models'?= =?UTF-8?q?=20(best-effort:=20without=20it=20the=20windows=20are=20as=20be?= =?UTF-8?q?fore)=20and=20adds=20each=20group's=20windows,=20named=20"5=20h?= =?UTF-8?q?ours"=20and=20"7=20days"=20with=20their=20pool=20(Gemini,=20Cla?= =?UTF-8?q?ude=20&=20GPT),=20each=20model's=20window=20naming=20its=20pool?= =?UTF-8?q?.=20The=20Usage=20page=20shows=20Gemini=20=C2=B7=205=20hours,?= =?UTF-8?q?=20Gemini=20=C2=B7=207=20days,=20Claude=20&=20GPT=20=C2=B7=205?= =?UTF-8?q?=20hours,=20Claude=20&=20GPT=20=C2=B7=207=20days,=20the=20pool'?= =?UTF-8?q?s=20models=20in=20each=20tooltip;=20the=20panel=20four=20rings?= =?UTF-8?q?=20(Gemini=205h/7d,=20Claude=205h/7d);=20the=20account=20rows?= =?UTF-8?q?=20each=20pool=20with=20its=205h=20and=207d=20meters;=20the=20T?= =?UTF-8?q?UI=20the=20pools'=20windows=20in=20place=20of=20the=20models'.?= =?UTF-8?q?=20"Every=20model"=20lists=20the=20models'=20own=20windows.=20T?= =?UTF-8?q?he=20pools'=20windows=20are=20aside=20for=20routing,=20which=20?= =?UTF-8?q?reads=20the=20models'=20as=20before;=20the=20tray's=20text=20is?= =?UTF-8?q?=20unchanged.=20TestAntigravityQuotaPools=20and=20quota-pools.t?= =?UTF-8?q?est.cjs=20(Chromium,=20WebKit;=20en,=20zh)=20fail=20without=20t?= =?UTF-8?q?he=20change;=20quota-families,=20click-scroll,=20old-webkit,=20?= =?UTF-8?q?tray-usages,=20panel-usage=20pass.=20go=20vet,=20go=20build=20-?= =?UTF-8?q?tags=20nogui=20(darwin,=20GOOS=3Dlinux),=20GOOS=3Dwindows=20go?= =?UTF-8?q?=20build,=20go=20test=20-tags=20nogui=20./...=20pass.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gui/assets/app.css | 3 + internal/gui/assets/app.js | 98 ++++++++++++- internal/gui/assets/i18n.js | 4 + internal/gui/tests/README.md | 13 ++ internal/gui/tests/quota-pools.test.cjs | 182 ++++++++++++++++++++++++ internal/provider/google.go | 118 +++++++++++++++ internal/provider/google_test.go | 92 ++++++++++++ internal/provider/subscription_usage.go | 10 +- internal/tui/pages.go | 2 +- 9 files changed, 514 insertions(+), 8 deletions(-) create mode 100644 internal/gui/tests/quota-pools.test.cjs diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 00de3992..dfbef4ed 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -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; } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index aa3f5446..8a8754f3 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -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); diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index e140d8f0..87a1b68d 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -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}到期", diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index 2404ee5d..4a162959 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -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 ``` diff --git a/internal/gui/tests/quota-pools.test.cjs b/internal/gui/tests/quota-pools.test.cjs new file mode 100644 index 00000000..2986e812 --- /dev/null +++ b/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, []); + }); + } +} diff --git a/internal/provider/google.go b/internal/provider/google.go index 6d7d675d..90b30879 100644 --- a/internal/provider/google.go +++ b/internal/provider/google.go @@ -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.) diff --git a/internal/provider/google_test.go b/internal/provider/google_test.go index f80ccea0..62dda84c 100644 --- a/internal/provider/google_test.go +++ b/internal/provider/google_test.go @@ -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) + } +} diff --git a/internal/provider/subscription_usage.go b/internal/provider/subscription_usage.go index 30cd87bd..67b7c379 100644 --- a/internal/provider/subscription_usage.go +++ b/internal/provider/subscription_usage.go @@ -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 diff --git a/internal/tui/pages.go b/internal/tui/pages.go index 6bf5fed4..92bb731f 100644 --- a/internal/tui/pages.go +++ b/internal/tui/pages.go @@ -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 {