quota: each allowance says when it was read, a cached one keeps its own time, and magpie quota history tells the windows' readings over time (#802, margbug01) — a subscription's card had no time unless its reading had failed, and a Claude account's allowance, which is what Claude Code last told (kept until it answers again or runs /usage), was shown on every refresh as if just read and written into the history again at each one's time. A reading now carries ReadAt: the time it was made (readNow, for every subscription and account read), and a Claude account's the time Claude Code told it (claudeReadAt). The usage card says "Updated 40 minutes ago" from it, a reading standing in for a failed one still says "As of …" in amber, a failed one says neither. The history (#651) keeps a point at its ReadAt, so a reading shown again is no new point. magpie quota and magpie accounts add "read 40m ago" to a line read more than 2 minutes before, and --json and GET /v1/magpie/quotas carry readAt. magpie quota history [<provider|account>…] [--days N] [--json] tells each account's windows over time (UTC, kept 45 days, a point only where it changed, the runs of a window apart by their reset), the last eight a window in text with ↻ where it started again, every one with --json, as GET /v1/magpie/quotas/history answers. On the card, hovering a window's key under the curve lists its latest readings with their times; no control is added to the card (the curves are kept uncluttered). Strings in zh, ja, de. Request-log correlation between readings isn't attempted. TestClaudeKeptReadingKeepsItsTime (a kept Claude reading shown three times ten minutes apart is one point at its own time; fails on the old code), TestQuotaHistoryCmd, and quota-read-at.test.cjs (WebKit, en and zh; fails on the old code). quota history run against a copy of the owner's real quota-history.json lists their Claude, Codex, Command Code, Copilot, Cursor and Devin windows. quota-curve, quota-pools, quota-families, quota-credits, quota-count-wrap, claude-usage-asked, panel-usage and balance-* pass on WebKit. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass (TestProvidersAnswerWhileListsComeIn, TestUserPath, TestClaudeSignedOut and TestCursorSignInJoinsAWrappedLink failed under load and pass alone).
This commit is contained in:
1 parent
6dd0079b86
commit
6892a28685
12 files changed
+437
-19
No files matched your search
+11
-3
@@ -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 <agent> · switch: magpie accounts switch <agent> <email>"))
|
||||
@@ -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)")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -101,6 +101,8 @@ const usage = `magpie — one place to pick every agent's model
|
||||
magpie quota [<provider>] [--json] what is left of every subscription, plan and key balance
|
||||
magpie quota wait <provider|account> [--timeout <d>] [--quiet]
|
||||
block until that subscription (any of its accounts) or account has allowance again
|
||||
magpie quota history [<provider|account>] [--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 <url>] [--mirror <prefix>]
|
||||
|
||||
+110
-2
@@ -19,6 +19,7 @@ const quotaUsage = `usage: magpie quota [<provider>…] [--json]
|
||||
magpie quota auto-reset [<codex account>] [on|off]
|
||||
magpie quota alert [<percent>|off] [--balance <amount>|off]
|
||||
magpie quota wait <provider|account> [--timeout <duration>] [--quiet]
|
||||
magpie quota history [<provider|account>…] [--days <n>] [--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 [<provider>…] [--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 <provider>/<account> 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 [<provider>…] [--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 [<provider|account>…] [--days <n>] [--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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user