diff --git a/accounts_cli.go b/accounts_cli.go index b634996f..9bae7187 100644 --- a/accounts_cli.go +++ b/accounts_cli.go @@ -193,7 +193,7 @@ func accountsCmd(args []string) error { } else if r.Error != "" { line += " " + muted.Render(r.Error) } - line += quotaReadingCell(r.AsOf, r.Windows) + line += quotaReadingCell(r.AsOf, r.ReadAt, r.Windows) fmt.Println(line) } fmt.Println(faint.Render(" ● signed in · ○ takes over when it runs out · add one: magpie accounts add · switch: magpie accounts switch ")) @@ -211,6 +211,7 @@ type accountRow struct { Error string `json:"error,omitempty"` Lapsed string `json:"lapsed,omitempty"` // its sign-in has to be made again AsOf *time.Time `json:"asOf,omitempty"` // last reading when it couldn't be read now + ReadAt *time.Time `json:"readAt,omitempty"` // when what is shown was read, when known // Resets are a Codex account's rate-limit resets, when it holds any. Resets *provider.ResetCredits `json:"resets,omitempty"` } @@ -252,7 +253,7 @@ func accountRows(ls []provider.Login, now time.Time) []accountRow { if r.Plan == "" { r.Plan = q.Plan } - r.Error, r.Resets, r.AsOf = q.Error, q.Resets, q.AsOf + r.Error, r.Resets, r.AsOf, r.ReadAt = q.Error, q.Resets, q.AsOf, q.ReadAt // a pool's own windows stand in for the models' drawing on it, // as the usage page shows them for _, w := range provider.PooledWindows(q.Windows) { @@ -286,8 +287,15 @@ func quotaCell(w quotaSpan) string { return cell } -func quotaReadingCell(at *time.Time, ws []quotaSpan) string { +// quotaReadingCell says when a line was read: "as of … (cached)" for a +// reading kept in place of one that failed, "read 12m ago" for one a +// cache handed back (a Claude account's waits for Claude Code to tell it +// again), nothing for one just read. +func quotaReadingCell(at, readAt *time.Time, ws []quotaSpan) string { if at == nil { + if readAt != nil && time.Since(*readAt) >= 2*time.Minute { + return " " + muted.Render("read "+ago(*readAt)) + } return "" } cell := " " + muted.Render("as of "+at.Local().Format("Jan 2 15:04")+" (cached)") diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index f8393676..b2b8f014 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -10646,8 +10646,9 @@ function renderQuotas() { if (!brief && sub.daily && !sub.error) card.append(creditDays(sub)); // what is left besides the windows, under them if (sub.balance && sub.windows?.length && !sub.error) card.append(balanceRow(sub, "What is left on the account besides its windows", false)); - // windows standing in for ones that couldn't be read just now say - // when they were read (a balance alone says it in its row) + // when the windows were read: "Updated 3 min ago", or the time of + // ones standing in for a reading that failed just now (#802; a + // balance alone says it in its row) const read = !brief && sub.windows?.length && !sub.error && readWhen(sub); if (read) card.append(read); if (!brief && sub.resets?.count) { @@ -11011,7 +11012,8 @@ function quotaCurve(sub) { sw.style.background = `var(--c${(i % 7) + 1})`; const last = l.points[l.points.length - 1]; k.append(sw, el("span", "", l.label), el("b", "", Math.round(last.left) + "%")); - k.title = t("{name}: {n} left, read {when}", { name: l.label, n: Math.round(last.left) + "%", when: quotaTimeText(last.at, Date.now()) }); + k.title = [t("{name}: {n} left, read {when}", { name: l.label, n: Math.round(last.left) + "%", when: quotaTimeText(last.at, Date.now()) }), + "", t("Latest readings:"), ...quotaReadings(l, Date.now())].join("\n"); legend.append(k); }); box.draw = () => { @@ -11027,6 +11029,14 @@ function quotaCurve(sub) { box.draw(); return box; } +// quotaReadings: a line's last eight readings, newest first, each what was +// left and when it was read (#802), a line each, ↻ where the window +// started again; for its legend key's title, the card keeping no control +// of its own +function quotaReadings(l, now) { + const pts = l.points.slice(-8); + return pts.map((p, i) => (i > 0 && quotaCycleBreak(pts[i - 1], p) ? "↻ " : "") + Math.round(p.left) + "% · " + quotaTimeText(p.at, now)).reverse(); +} function setQuotaRange(id) { if (id === quotaRange) return; const was = quotaRange; diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 6a19b76e..6bed16ac 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -1938,6 +1938,7 @@ const I18N = { "Hide accounts": "账号打码", "Credits used per day": "每天用掉的积分", "Left over time": "剩余额度走势", + "Latest readings:": "最近的读数:", "Solid: what was left. Dashed: an even pace, full at the cycle's start to empty at its reset; a line above its dashes lasts the cycle, one below runs out before the reset.": "实线:剩余额度。虚线:匀速用量,周期开始时满、重置时用完;实线在虚线上方说明能撑到重置,在下方说明会在重置前用完。", "2 days": "2 天", "Cycle": "本周期", @@ -4911,6 +4912,7 @@ const I18N = { "Hide accounts": "アカウントを隠す", "Credits used per day": "1 日あたりのクレジット使用量", "Left over time": "残量の推移", + "Latest readings:": "最近の読み取り値:", "Solid: what was left. Dashed: an even pace, full at the cycle's start to empty at its reset; a line above its dashes lasts the cycle, one below runs out before the reset.": "実線:残量。破線:均等なペースで、サイクル開始時に満タン、リセット時に空になる線です。実線が破線より上ならサイクル終了まで持ち、下ならリセット前に使い切ります。", "2 days": "2 日", "Cycle": "サイクル", @@ -7868,6 +7870,7 @@ const I18N = { "Hide accounts": "Konten verbergen", "Credits used per day": "Verbrauchte Credits pro Tag", "Left over time": "Rest im Zeitverlauf", + "Latest readings:": "Letzte Messwerte:", "Solid: what was left. Dashed: an even pace, full at the cycle's start to empty at its reset; a line above its dashes lasts the cycle, one below runs out before the reset.": "Durchgezogen: der verbleibende Rest. Gestrichelt: ein gleichmäßiges Tempo, von voll zu Beginn des Zyklus bis leer beim Reset; eine Linie über ihrer gestrichelten reicht für den Zyklus, eine darunter ist vor dem Reset aufgebraucht.", "2 days": "2 Tage", "Cycle": "Zyklus", diff --git a/internal/gui/tests/quota-read-at.test.cjs b/internal/gui/tests/quota-read-at.test.cjs new file mode 100644 index 00000000..8c28333b --- /dev/null +++ b/internal/gui/tests/quota-read-at.test.cjs @@ -0,0 +1,98 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// When each allowance was read (#802, margbug01): a subscription's card +// says "Updated 40 minutes ago" from the reading's own time (readAt) — a +// Claude account's is what Claude Code last told, which can be well before +// the page asked — one standing in for a reading that failed says "As of", +// marked stale, and a failed one says neither. Hovering a window's key under +// the curve lists its latest readings, each with when it was read, ↻ where +// the window started again; the card gets no control of its own. +// English and Chinese; no backend, 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 H = 3600e3, M = 60e3; +const iso = (ms) => new Date(ms).toISOString(); + +function fixtures(now) { + const r5 = now + 2 * H; + const quotas = [ + { provider: "claude", name: "Claude Code", icon: "claude-color", plan: "Max", user: "a@x.com", readAt: iso(now - 40 * M), + windows: [{ name: "5 hours", used: 28, resetsAt: iso(r5) }] }, + { provider: "codex", name: "Codex", icon: "openai", plan: "Plus", user: "b@x.com", asOf: iso(now - 3 * H), + windows: [{ name: "5 hours", used: 50, resetsAt: iso(r5) }] }, + { provider: "kimi", name: "Kimi", icon: "kimi-color", error: "HTTP 503", windows: [] }, + ]; + const points = [ + { at: iso(r5 - 6 * H), left: 35, resetsAt: iso(r5 - 5 * H) }, + { at: iso(r5 - 4 * H), left: 100, resetsAt: iso(r5) }, + { at: iso(now - 40 * M), left: 72, resetsAt: iso(r5) }, + ]; + const history = [{ provider: "claude", user: "a@x.com", lines: [{ name: "5 hours", points }] }]; + return { quotas, history }; +} + +function serve(lang, data) { + const settings = { theme: "light", lang, tray: "panel", quotaLeft: false, currency: "usd" }; + return async (route) => { + const url = new URL(route.request().url()); + const json = (body) => route.fulfill({ json: body }); + if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` }); + 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: [], presets: [], excluded: [], gateway: { running: true, window: true } }); + if (url.pathname === "/api/usage/quotas") return json(data.quotas); + if (url.pathname === "/api/usage/quotas/history") return json(data.history); + 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: { updated: "Updated 40 minutes ago", asOf: /^As of /, latest: "Latest readings:" }, + zh: { updated: "40分钟前更新", asOf: /^截至|^As of/, latest: "最近的读数:" }, +}; + +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}: a quota card says when it was read`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + t.after(() => browser.close()); + const errors = []; + const page = await (await browser.newContext({ viewport: { width: 900, height: 700 }, reducedMotion: "reduce" })).newPage(); + page.setDefaultTimeout(5000); + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", serve(lang, fixtures(Date.now()))); + await page.goto("http://magpie.test/?view=usage"); + + const claude = page.locator(".subscription-card", { hasText: "Claude Code" }); + const read = claude.locator(".quota-read"); + await read.waitFor(); + assert.equal(await read.textContent(), w.updated, "the reading's own time, not the page's"); + assert.equal(await read.evaluate((e) => e.classList.contains("stale")), false); + + const codex = page.locator(".subscription-card", { hasText: "Codex" }).locator(".quota-read"); + assert.match(await codex.textContent(), w.asOf); + assert.equal(await codex.evaluate((e) => e.classList.contains("stale")), true, "a kept reading is marked stale"); + + assert.equal(await page.locator(".subscription-card", { hasText: "Kimi" }).locator(".quota-read").count(), 0, "a failed reading says no time"); + + // the latest readings, newest first, on the window's key + const key = claude.locator(".quota-curve .qc-key").first(); + const title = (await key.getAttribute("title")).split("\n"); + assert.equal(title[2], w.latest, title.join(" | ")); + assert.deepEqual(title.slice(3).map((l) => l.replace(/ · .*/, "")), ["72%", "↻ 100%", "35%"]); + assert.equal(await claude.locator(".quota-curve button").count(), 0, "no control on the card"); + assert.deepEqual(errors, []); + }); + } +} diff --git a/internal/provider/login_usage.go b/internal/provider/login_usage.go index 89184aaf..06b9a568 100644 --- a/internal/provider/login_usage.go +++ b/internal/provider/login_usage.go @@ -65,7 +65,7 @@ func LoginUsage(ctx context.Context, agent string) map[string]SubscriptionQuota wg.Add(1) go func(l Login) { defer wg.Done() - q := keepLast(loginQuota(ctx, l), l.User) + q := keepLast(readNow(loginQuota(ctx, l)), l.User) if q.Error != "" && ok && q.Provider != "claude" { q = e.q // a hiccup keeps what was known } @@ -205,6 +205,8 @@ func loginQuota(ctx context.Context, l Login) SubscriptionQuota { q.Windows = ws if err != nil { q.Error = err.Error() + } else { + q.ReadAt = claudeReadAt(l.User) } return q } @@ -219,7 +221,9 @@ func loginQuota(ctx context.Context, l Login) SubscriptionQuota { } if err == nil { if l.Agent == "claude" { - q.Windows, err = claudeWindows(ctx, l.User, true) + if q.Windows, err = claudeWindows(ctx, l.User, true); err == nil { + q.ReadAt = claudeReadAt(l.User) + } } else { var plan string if plan, q.Windows, q.Resets, q.Balance, err = codexWindows(ctx, tok, accountID); plan != "" { diff --git a/internal/provider/quota_history.go b/internal/provider/quota_history.go index ce02b9c6..0f8c4f1d 100644 --- a/internal/provider/quota_history.go +++ b/internal/provider/quota_history.go @@ -143,12 +143,13 @@ func newCycle(a, b QuotaPoint) bool { return moved > b.At.Sub(a.At)+quotaHistSlack || moved < -quotaHistSlack } -// pointOf is what w says at now, if it is something to draw. -func pointOf(w QuotaWindow, now time.Time) (QuotaPoint, bool) { +// pointOf is what w, read at at, says, if it is something to draw; a +// reset told in seconds counts from now, when the windows were made. +func pointOf(w QuotaWindow, at, now time.Time) (QuotaPoint, bool) { if w.Unlimited || w.Aside || w.Family != "" || w.Name == "" || math.IsNaN(w.Used) { return QuotaPoint{}, false } - p := QuotaPoint{At: now.UTC().Truncate(time.Second), Left: math.Round(max(0, min(100, 100-w.Used))*100) / 100} + p := QuotaPoint{At: at.UTC().Truncate(time.Second), Left: math.Round(max(0, min(100, 100-w.Used))*100) / 100} reset := w.ResetsAt if reset == nil && w.ResetSecs > 0 { r := now.Add(time.Duration(w.ResetSecs) * time.Second) @@ -212,7 +213,10 @@ func (h quotaHist) prune(now time.Time) { func quotaHistKey(provider, user string) string { return provider + "|" + strings.ToLower(user) } -// noteQuotaHistory keeps what each window among qs says, read at now. +// noteQuotaHistory keeps what each window among qs says, at when it was +// read (ReadAt), or now: a reading handed back again from a cache (a +// Claude account's, kept until Claude Code tells it again) keeps its time +// and so isn't taken for a new one. func noteQuotaHistory(qs []SubscriptionQuota, now time.Time) { quotaHistMu.Lock() defer quotaHistMu.Unlock() @@ -222,8 +226,12 @@ func noteQuotaHistory(qs []SubscriptionQuota, now time.Time) { if q.Error != "" || q.AsOf != nil || q.Provider == "" { continue // a reading kept from before isn't a new one } + at := now + if q.ReadAt != nil && !q.ReadAt.After(now) { + at = *q.ReadAt + } for _, w := range q.Windows { - p, ok := pointOf(w, now) + p, ok := pointOf(w, at, now) if !ok { continue } diff --git a/internal/provider/quota_read_at_test.go b/internal/provider/quota_read_at_test.go new file mode 100644 index 00000000..de3aca31 --- /dev/null +++ b/internal/provider/quota_read_at_test.go @@ -0,0 +1,59 @@ +package provider + +import ( + "context" + "testing" + "time" + + "github.com/yetone/magpie/internal/testenv" +) + +// A Claude account's allowance is what Claude Code last told: shown again +// on each refresh, it says when it was told (ReadAt), and the history +// keeps it at that time, once, rather than as a new reading each time it +// is shown (#802). +func TestClaudeKeptReadingKeepsItsTime(t *testing.T) { + testenv.SetHome(t, t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + told := time.Now().Add(-40 * time.Minute).Truncate(time.Second) + reset := time.Now().Add(3 * time.Hour).Truncate(time.Second) + claudeUsage.Lock() + was := claudeUsage.m + claudeUsage.m = map[string]claudeUsageEntry{"kept@example.com": {at: told, heard: told, + ws: []QuotaWindow{{Name: "5 hours", Used: 30, ResetsAt: &reset}}}} + claudeUsage.Unlock() + t.Cleanup(func() { claudeUsage.Lock(); claudeUsage.m = was; claudeUsage.Unlock() }) + + saved := Login{Agent: "claude", User: "Kept@example.com"} + var q SubscriptionQuota + for i := range 3 { + q = readNow(loginQuota(context.Background(), saved)) + q.User = saved.User + if q.Error != "" || len(q.Windows) != 1 { + t.Fatalf("saved account's card = %+v", q) + } + if q.ReadAt == nil || !q.ReadAt.Equal(told) { + t.Fatalf("read at %v, want when Claude Code told it, %v", q.ReadAt, told) + } + noteQuotaHistory([]SubscriptionQuota{q}, time.Now().Add(time.Duration(i)*10*time.Minute)) + } + pts := lineOf(t, QuotaHistories(told.Add(-time.Hour), "", ""), "kept@example.com", "5 hours") + if len(pts) != 1 || !pts[0].At.Equal(told.UTC()) || pts[0].Left != 70 { + t.Fatalf("history = %+v, want one point at %v", pts, told) + } + if r := quotaReport([]SubscriptionQuota{q}, nil, nil, time.Now()); len(r) != 1 || r[0].ReadAt == nil || !r[0].ReadAt.Equal(told) { + t.Fatalf("report = %+v", r) + } + + // a reading just made is stamped now; a failed one or one kept from + // before isn't + if q := readNow(SubscriptionQuota{Provider: "codex", Windows: []QuotaWindow{{Name: "5 hours", Used: 1}}}); q.ReadAt == nil || time.Since(*q.ReadAt) > time.Minute { + t.Errorf("a new reading: %+v", q) + } + if q := readNow(SubscriptionQuota{Provider: "codex", Error: "401"}); q.ReadAt != nil { + t.Errorf("a failed reading was stamped: %+v", q) + } + if q := readNow(SubscriptionQuota{Provider: "codex", AsOf: &told, Windows: []QuotaWindow{{Name: "5 hours"}}}); q.ReadAt != nil { + t.Errorf("a kept reading was stamped: %+v", q) + } +} diff --git a/internal/provider/quotas.go b/internal/provider/quotas.go index cc5d4081..0995fb4a 100644 --- a/internal/provider/quotas.go +++ b/internal/provider/quotas.go @@ -81,6 +81,9 @@ type Quota struct { Balance string `json:"balance,omitempty"` Error string `json:"error,omitempty"` AsOf *time.Time `json:"asOf,omitempty"` // the cached reading's time, nil for a new one + // ReadAt is when the windows or balance shown were read, nil when not + // known; a Claude account's can be well before now (claudeReadAt). + ReadAt *time.Time `json:"readAt,omitempty"` // Until is when the plan's paid time ends, renewed then when Renew is // "auto", over when "off", either when "". Until *time.Time `json:"until,omitempty"` @@ -125,7 +128,7 @@ func quotaReport(subs, plans, balances []SubscriptionQuota, now time.Time) []Quo qs []SubscriptionQuota }{{"subscription", subs}, {"plan", plans}, {"balance", balances}} { for _, q := range g.qs { - r := Quota{Provider: q.Provider, Name: q.Name, Kind: g.kind, Plan: q.Plan, User: q.User, AsOf: q.AsOf, + r := Quota{Provider: q.Provider, Name: q.Name, Kind: g.kind, Plan: q.Plan, User: q.User, AsOf: q.AsOf, ReadAt: q.ReadAt, Windows: []QuotaSpan{}, Balance: q.Balance, Error: q.Error, Until: q.Until, Renew: q.Renew, Resets: q.Resets} // a pool's own windows stand in for the models' drawing on it, // as the usage page shows them diff --git a/internal/provider/subscription_usage.go b/internal/provider/subscription_usage.go index c7334756..89ef5616 100644 --- a/internal/provider/subscription_usage.go +++ b/internal/provider/subscription_usage.go @@ -95,8 +95,10 @@ type SubscriptionQuota struct { // AsOf is when an allowance shown in place of one that couldn't be // read was read (see keepLast); nil for a reading just made. AsOf *time.Time `json:"asOf,omitempty"` - // ReadAt is when a key's balance was read, which the minute it is - // kept for (KeyBalances) leaves behind the page's asking. + // ReadAt is when what is shown was read: a key's balance, kept a + // minute (KeyBalances), or an account's allowance, which a cache (a + // minute's, or a Claude account's until Claude Code tells it again) + // can hand back long after; nil when not known. ReadAt *time.Time `json:"readAt,omitempty"` // Resets are the rate-limit resets a Codex account holds, nil when it // holds none (codex_resets.go). @@ -363,7 +365,7 @@ func fetchSubscriptionUsage() []SubscriptionQuota { var wg sync.WaitGroup for i, f := range fetches { wg.Add(1) - go func() { defer wg.Done(); out[i] = keepLast(f(), "") }() + go func() { defer wg.Done(); out[i] = keepLast(readNow(f()), "") }() } wg.Wait() return out @@ -444,10 +446,35 @@ func claudeSubscriptionUsage(ctx context.Context) SubscriptionQuota { q.Windows, err = claudeWindows(ctx, user, true) if err != nil { q.Error = err.Error() + } else { + q.ReadAt = claudeReadAt(user) } return q } +// readNow stamps a reading just made, one that doesn't say when it was +// read itself, with the time. +func readNow(q SubscriptionQuota) SubscriptionQuota { + if q.Error == "" && q.AsOf == nil && q.ReadAt == nil && (len(q.Windows) > 0 || q.Balance != "") { + now := time.Now() + q.ReadAt = &now + } + return q +} + +// claudeReadAt is when what is kept of user's Claude allowance was told: +// by /usage or as Claude Code answered. Nil when nothing is. +func claudeReadAt(user string) *time.Time { + claudeUsage.Lock() + e, ok := claudeUsage.m[strings.ToLower(user)] + claudeUsage.Unlock() + if !ok || e.at.IsZero() { + return nil + } + at := e.at + return &at +} + // claudeUsage keeps each Claude account's allowance as Claude Code last // told it: its /usage, run when the user asks (AskClaudeUsage) and only for // the account it is signed in to, or what it said as it answered diff --git a/main.go b/main.go index 4a643444..769ecc10 100644 --- a/main.go +++ b/main.go @@ -101,6 +101,8 @@ const usage = `magpie — one place to pick every agent's model magpie quota [] [--json] what is left of every subscription, plan and key balance magpie quota wait [--timeout ] [--quiet] block until that subscription (any of its accounts) or account has allowance again + magpie quota history [] [--days N] [--json] + each window's readings over time, kept 45 days magpie sync refresh the model catalog and vendor model lists magpie agents list every supported agent magpie update [check] [--proxy ] [--mirror ] diff --git a/quota_cli.go b/quota_cli.go index 8352e076..0301cf96 100644 --- a/quota_cli.go +++ b/quota_cli.go @@ -19,6 +19,7 @@ const quotaUsage = `usage: magpie quota […] [--json] magpie quota auto-reset [] [on|off] magpie quota alert [|off] [--balance |off] magpie quota wait [--timeout ] [--quiet] + magpie quota history […] [--days ] [--json] what is left of every subscription, plan and key magpie has: each window's use and when it starts again, and each key's balance, asked of the vendors now (or less than a minute ago). --json is for scripts and agents, each entry with lastServedAt, when it @@ -47,7 +48,14 @@ const quotaUsage = `usage: magpie quota […] [--json] says on stderr what it waits for. For a script to go on with work it stopped: until codex exec …; do magpie quota wait codex || break; done. Exit 0 once there is allowance, 1 when --timeout (30m, 6h) passes first, 2 for a name it doesn't - know, 130 on Ctrl+C.` + know, 130 on Ctrl+C. + quota history is what magpie has read of each subscription's and plan's windows + over time, kept 45 days (UTC times, a reading only when it changed, each window's + runs apart by when they reset): the latest readings of each window, or with --json + every one since --days days ago, as GET /v1/magpie/quotas/history?days=&provider=&user= + answers. A provider, an account's email or login, or / narrows it. + A reading is kept at the time it was read: a Claude account's, which Claude Code + tells only as it answers or runs /usage, isn't written again each time it is shown.` // quotaCmd: magpie quota […] [--json] func quotaCmd(args []string) error { @@ -63,6 +71,9 @@ func quotaCmd(args []string) error { if len(args) > 1 && args[1] == "wait" { return quotaWaitCmd(args[2:]) } + if len(args) > 1 && args[1] == "history" { + return quotaHistoryCmd(args[2:]) + } asJSON := false var only []string for _, a := range args[1:] { @@ -119,7 +130,7 @@ func quotaCmd(args []string) error { if q.Error != "" { line += " " + muted.Render(q.Error) } - line += quotaReadingCell(q.AsOf, q.Windows) + line += quotaReadingCell(q.AsOf, q.ReadAt, q.Windows) fmt.Println(line) } fmt.Println(faint.Render(" % is how much of a window is used · ↻ when it starts again · --json for scripts, or GET /v1/magpie/quotas on the gateway")) @@ -367,3 +378,100 @@ func withUntold(qs []provider.Quota, only []string) ([]provider.Quota, error) { } return qs, nil } + +// quotaHistoryCmd: magpie quota history […] [--days ] [--json] +func quotaHistoryCmd(args []string) error { + asJSON, days := false, "" + var only []string + for i := 0; i < len(args); i++ { + switch a := args[i]; { + case a == "--json" || a == "-j": + asJSON = true + case a == "--days" && i+1 < len(args): + i++ + days = args[i] + case strings.HasPrefix(a, "--days="): + days = strings.TrimPrefix(a, "--days=") + case a == "help" || a == "-h" || a == "--help": + fmt.Println(quotaUsage) + return nil + case strings.HasPrefix(a, "-"): + return fmt.Errorf("unknown flag %s\n%s", a, quotaUsage) + default: + only = append(only, strings.ToLower(a)) + } + } + if days != "" { + if n, err := strconv.ParseFloat(days, 64); err != nil || n <= 0 { + return fmt.Errorf("--days takes a number of days, such as 1 or 7, not %q", days) + } + } + now := time.Now() + hs := []provider.QuotaHistory{} + for _, h := range provider.QuotaHistories(provider.QuotaHistorySince(days, now), "", "") { + if len(only) == 0 || slices.ContainsFunc(only, func(o string) bool { + return o == h.Provider || o == h.User || o == h.Provider+"/"+h.User + }) { + hs = append(hs, h) + } + } + if asJSON { + b, _ := json.MarshalIndent(hs, "", " ") + fmt.Println(string(b)) + return nil + } + if len(hs) == 0 { + if len(only) > 0 { + return fmt.Errorf("no readings kept of %s", strings.Join(only, ", ")) + } + fmt.Println(muted.Render("nothing kept yet ·"), "a subscription's or plan's windows are kept here as magpie reads them") + return nil + } + for _, h := range hs { + title := h.Provider + if h.User != "" { + title += " · " + h.User + } + fmt.Println(bold.Render(title)) + for _, l := range h.Lines { + fmt.Println(" " + l.Name + " " + quotaPointsCell(l.Points, 8)) + } + } + fmt.Println(faint.Render(" % is how much was left · ↻ a new run of the window · --json for every reading, or GET /v1/magpie/quotas/history on the gateway")) + return nil +} + +// quotaPointsCell is a window's last n readings, oldest first: "Oct 4 +// 09:12 80% · 10:40 61% · ↻ 14:02 100%", a day only where it changes. +func quotaPointsCell(pts []provider.QuotaPoint, n int) string { + if len(pts) > n { + pts = pts[len(pts)-n:] + } + cells := []string{} + day := "" + for i, p := range pts { + at := p.At.Local() + cell := "" + if i > 0 && quotaNewRun(pts[i-1], p) { + cell = "↻ " + } + if d := at.Format("Jan 2"); d != day { + day = d + cell += muted.Render(d+" ") + at.Format("15:04") + } else { + cell += at.Format("15:04") + } + cells = append(cells, cell+" "+bold.Render(strconv.FormatFloat(p.Left, 'f', -1, 64)+"%")) + } + return strings.Join(cells, muted.Render(" · ")) +} + +// quotaNewRun is whether b is in a run of its window after a's: it resets +// at another time, or has more left with no reset to go by. +func quotaNewRun(a, b provider.QuotaPoint) bool { + if a.ResetsAt != nil && b.ResetsAt != nil { + d := b.ResetsAt.Sub(*a.ResetsAt) + return d > 10*time.Minute || d < -10*time.Minute + } + return b.Left > a.Left+5 +} diff --git a/quota_history_cli_test.go b/quota_history_cli_test.go new file mode 100644 index 00000000..24c01ae8 --- /dev/null +++ b/quota_history_cli_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/yetone/magpie/internal/provider" +) + +// stdoutOf is what f prints. +func stdoutOf(t *testing.T, f func() error) (string, error) { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + old := os.Stdout + os.Stdout = w + done := make(chan []byte) + go func() { b, _ := io.ReadAll(r); done <- b }() + err = f() + w.Close() + os.Stdout = old + return string(<-done), err +} + +// magpie quota history tells each account's windows over time as magpie +// kept them, narrowed to a provider or an account and to --days; --json is +// what GET /v1/magpie/quotas/history answers (#802). +func TestQuotaHistoryCmd(t *testing.T) { + groupsHome(t) + now := time.Now().UTC().Truncate(time.Second) + reset := now.Add(2 * time.Hour) + pt := func(ago time.Duration, left float64, r time.Time) provider.QuotaPoint { + return provider.QuotaPoint{At: now.Add(-ago), Left: left, ResetsAt: &r} + } + h := map[string]map[string][]provider.QuotaPoint{ + "codex|a@x.com": {"5 hours": {pt(30*time.Hour, 40, now.Add(-26*time.Hour)), pt(3*time.Hour, 100, reset), pt(time.Hour, 72.5, reset)}}, + "claude|b@x.com": {"Weekly": {pt(2*time.Hour, 55, now.Add(72*time.Hour))}}, + } + b, _ := json.Marshal(h) + if err := os.WriteFile(filepath.Join(filepath.Dir(provider.Path()), "quota-history.json"), b, 0o600); err != nil { + t.Fatal(err) + } + + out, err := stdoutOf(t, func() error { return quotaCmd([]string{"quota", "history", "codex", "--days", "1", "--json"}) }) + if err != nil { + t.Fatal(err) + } + var hs []provider.QuotaHistory + if err := json.Unmarshal([]byte(out), &hs); err != nil { + t.Fatal(err, out) + } + // the day's two, and the one before so the line reaches its edge + if len(hs) != 1 || hs[0].Provider != "codex" || hs[0].User != "a@x.com" || len(hs[0].Lines) != 1 || len(hs[0].Lines[0].Points) != 3 { + t.Fatalf("codex, a day: %s", out) + } + if p := hs[0].Lines[0].Points[2]; !p.At.Equal(now.Add(-time.Hour)) || p.Left != 72.5 || !p.ResetsAt.Equal(reset) { + t.Errorf("last point = %+v", p) + } + + out, err = stdoutOf(t, func() error { return quotaCmd([]string{"quota", "history", "B@x.com", "--json"}) }) + if err != nil || !strings.Contains(out, `"provider": "claude"`) || strings.Contains(out, "codex") { + t.Fatalf("by account: %v %s", err, out) + } + + out, err = stdoutOf(t, func() error { return quotaCmd([]string{"quota", "history"}) }) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"codex · a@x.com", "claude · b@x.com", "5 hours", "72.5%", "↻ ", "Weekly", "55%"} { + if !strings.Contains(out, want) { + t.Errorf("text lacks %q:\n%s", want, out) + } + } + + if _, err := stdoutOf(t, func() error { return quotaCmd([]string{"quota", "history", "nobody"}) }); err == nil { + t.Error("an account with nothing kept wasn't an error") + } + if _, err := stdoutOf(t, func() error { return quotaCmd([]string{"quota", "history", "--days", "x"}) }); err == nil { + t.Error("--days x wasn't an error") + } +}