Files
magpie/quota_cli.go
forever e961836fb0 usage, gateway, gui, provider, cli: the tests pass when a run crosses midnight, and a usage answer reads the clock once (#1046)
* usage: the usage tests pass at any time of day, midnight included

The usage tests stamped their calls with the machine's time and then
asked for today, 7 or 30 days, or a cached answer, which the code
worked out from its own time.Now a moment later. A minute after
midnight a call made "a minute ago" was yesterday's, and when midnight
fell between the test's read and the code's, the call and the period
were of two days. magpie usage, the TUI's Usage page, the two Codex
cache-write tests and the computers' shared days failed in the first
minutes after midnight (the TUI's after midnight UTC, the zone its
package's tests run in since 1caea638), and 28 more failed when
midnight fell inside them.

usage.Clock is now the time every period is read by: the start of
today, 7 days and 30 days (LedgerOf, Summarize, Direct, QueryPage), the
hours or days of the chart (LedgerSeries, QueryPage), the day a cached
summary or page is for, the time Append gives a call that has none,
the days davsync shares and the date in an exported CSV's name. It is
time.Now, as before, and a variable for the tests, like LogCalls. Each
test that built its calls from the time now holds Clock at noon on
2026-09-30 (holdClock; holdUsageClock in gui and the CLI; three lines
in tui and davsync) and stamps its calls from that instant, among them
TestReasoningReplyTimedByItsAnswer, whose timeline rows 117b6c7f made
at time.Now. TestPeriodsReadTheClock checks that every one of these
reads goes through Clock. TestQueryPageSourceAndIdentityInvalidation is
held too; that it fails when run alone is fixed apart from this, in
#1045.

Seven held tests could not fail at midnight before, and are held so
that the tests read one clock: TestUsageSameSizeAndMtimeRewrite (All's
totals), TestDirectCountsOnlySessionFileCalls (30 days around its own
noon), TestQueryPageIOOutsideCacheLock (a file two days old), gui's
TestUsageLedgerDayRoutes (7 days hold its calls from either day),
TestLedgerSingleSnapshotAndLocalSession and
TestLedgerNamesModelAtProvider (their calls are stamped after the code
reads the time), and TestUsageRawAndPricedChunksShareBudget, which
fails only when the midnight in it starts a new week of All's chart, on
a Sunday night; at.sh sets the time of day and not the weekday, so that
one was not shown.

Verified with at.sh, a scratch script that runs a test binary under a
zone made with zic, so that the local time of day is the one asked for
as the run starts (HOME in a sandbox, Go's caches pinned). The runs were
first made on c5a54bc7, and those marked (698da36f) again on 698da36f,
with the same results. The tui package runs in UTC since 1caea638, a
zone at.sh doesn't move, so its runs on 698da36f used copies of both
trees whose TestMain keeps the zone TZ sets.

Before, at 00:00:30, 00:01:30 and 12:00:00 (698da36f):
  TestUsageShowsCallsNotThroughMagpie, 00:00:30:
    usage_cli_test.go:95: no calls today · route an agent through
    magpie and its usage shows up here
  00:01:30: "5 tokens today · 1 call · no price", without the calls
  not through magpie; TestUsagePageShowsCallsNotThroughMagpie (tui)
  fails the same two ways at usage_test.go:138
  --- FAIL: TestCodexCacheWriteNotInInput (00:00:30)
    codex_cache_write_test.go:104: rows []
  --- FAIL: TestCodexCacheWriteMatchesGatewayCall (00:01:30)
    codex_cache_write_test.go:73: filter {...}: 0 rows, 0 calls, want
    the gateway's 2: []
  --- FAIL: TestUsageSharedBetweenComputers (00:01:30)
    usage_share_test.go:134: a's days on the server:
    [...-2026-10-05 ...-2026-10-06 ...-2026-10-07]
  12:00:00: PASS

Midnight inside a test, in scratch copies of the head. Started at
23:59:59.3 with the code's first period read held 1.5s, so that it
reads after midnight what the test stamped before it, 16 usage tests,
3 gui tests and TestUsageCSV FAIL, among them
  ledger_test.go:257: today: 0
  review_test.go:69: chart counted 0
  incremental_log_test.go:202: concurrent append lost
  TestCachedSummaryRemainsCallerOwned: panic: runtime error: index out
  of range [0] with length 0
  usage_test.go:121: second page: {... Period:today Rows:[] ...
  Total:0 ...}
  usage_test.go:311: "": magpie-requests-7d-2026-10-06
Started at 23:59:55 with the test waiting past midnight between two of
its reads, 7 more FAIL:
  request_day_test.go:45: selecting a day changed the chart or its
  legend
  cache_bounds_test.go:84: uncached today summary stale
  growing_rewrite_test.go:71: today summary stale
  cache_bounds_test.go:153: page key changed instead of exercising the
  same cache
  priced_blocks_test.go:55, caller_provider_test.go:129 and :172: page
  differs
TestReasoningReplyTimedByItsAnswer, with a scratch 1.5s sleep before
its LedgerSeries, started at 23:59:59.3 (698da36f):
  usage_test.go:255: part: {Calls:0 Tokens:0 Cost:0 Timed:0 TTFT:0
  DecodeMs:0 DecodeOut:0}

After: the same at.sh runs PASS at 00:00:30, 00:01:30 and 12:00:00
(698da36f); the same scratch copies made of this change PASS, each
test taking 1.5s or 5.2s, so midnight did fall inside it (698da36f for
TestReasoningReplyTimedByItsAnswer's); and every changed test PASS at
23:59:59.9, 00:00:30 and 12:00:00 (698da36f).

Without the change (698da36f): with the reads put back on time.Now, 21
usage tests, 6 gui, 2 CLI, the tui and the davsync test fail, among
them
  --- FAIL: TestPeriodsReadTheClock
    clock_test.go:55: Direct(Today): 0 calls since 2026-10-06 00:00:00
    -0700 PDT, want 1 since 2026-09-30 00:00:00 -0700 PDT
    clock_test.go:63: Summarize(Today) the next day: 1 calls, want 0
  --- FAIL: TestReasoningReplyTimedByItsAnswer
    usage_test.go:255: part: {Calls:0 Tokens:0 ...}
Each of the eleven reads put back alone fails a test:
TestPeriodsReadTheClock for the nine in usage (LedgerSeries:
"clock_test.go:52: LedgerSeries(Today): 0 calls, want 2"; the page's
cache key: "clock_test.go:66: QueryPage(Today) the next day: 1 rows,
want 0"), TestCSVStamp for the CSV's name (usage_test.go:325: "":
magpie-requests-7d-2026-10-06) and TestUsageSharedBetweenComputers for
davsync (usage_share_test.go:224: all computers: 4 rows, 707 in; want
5, 737).

On this commit: go test -tags nogui -count=1 . ./internal/usage
./internal/tui ./internal/davsync ./internal/gui ok; -race -count=20
over the changed tests of each of these packages ok; gofmt -l prints
nothing, and go vet -tags nogui on the five packages finds nothing.

* usage: pages, CSVs and magpie usage asked at midnight show one day

An answer about usage read the time more than once. The Requests page
read it for the day it is kept for, for the period its log and session
files are read over, for the period of its rows and for its chart's
hours; the page from the ledger (QueryPage's path when LogCalls is
set, as in the tests) for its rows and for its chart; Direct for its
period and for its rows; a CSV for its rows and for its name;
magpie usage and the TUI's Usage page for Summarize and then for
Direct. Most of these reads have the log and the session files read
between them, seconds on a large log, and when midnight fell between
two of them the answer was of two days: yesterday's calls listed over
today's empty chart, Direct starting yesterday and counting none of
yesterday's calls, the calls not through magpie left out of magpie
usage and the Usage page (they were the new day's, none yet), and
yesterday's calls saved as magpie-requests-today-<the new day>.csv.

Each answer now reads Clock once and passes that moment down. queryPage
reads it for its key, its sources, its rows and its chart
(buildRequestBlocks takes it); the page from the ledger reads its rows
and its chart at it (LedgerOfAt, ledgerSeriesAt); Direct reads the
ledger at the moment of its period. usage.Summaries(p) is Summarize(p)
and Direct(p) at one moment, and magpie usage and the TUI's Usage page,
when a period is picked and when it is read again, ask for it: one
exported name, the clock left inside usage and one line changed at
each caller, where SummarizeAt and DirectAt would be two names and
each caller would read the clock itself. The CSV download and export
read their rows with the new usage.LedgerOfAt at the moment their file
is named for, which csvStamp now takes; the rows and the name are read
in two packages, so LedgerOfAt is exported. LedgerOf, LedgerSeries,
Summarize, Direct and QueryPage keep their signatures, each reading
Clock once.

The new tests set Clock to read a tenth of a second before midnight
the first time and a tenth of a second after it from then on, set
again for each answer. The midnight is the one that starts 2026-10-01,
local time: a past instant, at midnight because that is what is
tested, and the day before it is the 2026-09-30 the held tests use.
TestAnswerReadsTheClockOnce asks QueryPage(Today) from the indexed log
and from the ledger: its 3 rows, one from a session file written that
day at 23:00, are under that day's chart, and asked again after
midnight the page is the next day's, not the one kept for the day
before; Direct(Today) counts the call and starts that day.
TestUsageAskedAtMidnight (magpie usage today), TestUsagePageAtMidnight
(the TUI's Usage page, today picked and read again) and
TestUsageCSVAtMidnight (download and export) check that the calls
through magpie and those not through it, or the rows and the file's
name, are of the day asked on. TestCSVStamp passes its time.

Verified on the previous commit, at.sh as there. The runs were first
made on c5a54bc7, when the tui package's tests still ran in the local
zone, and those marked (698da36f) again on 698da36f, with the same
results.

Before: the new tests on the previous commit, at any time of day
(at.sh at 12:00:00 too) (698da36f):
  --- FAIL: TestAnswerReadsTheClockOnce
    clock_test.go:131: QueryPage(Today): 0 rows, 0 in a chart from
    2026-10-01 00:00:00 -0700 PDT; want 3 under one from 2026-09-30
    00:00:00 -0700 PDT
    clock_test.go:141: QueryPage(Today): 3 rows, 0 in a chart from
    2026-10-01 ...; want 3 under one from 2026-09-30 ...
    clock_test.go:145: Direct(Today): 0 calls since 2026-09-30
    00:00:00 -0700 PDT, want 1 since 2026-09-30 00:00:00 -0700 PDT
  --- FAIL: TestUsageAskedAtMidnight
    usage_cli_test.go:154: 5 tokens today · 1 call · no price ...,
    with no part for the calls not through magpie
  --- FAIL: TestUsagePageAtMidnight
    usage_test.go:184: today picked: ... (the same)
    usage_test.go:187: read again: ...
  --- FAIL: TestUsageCSVAtMidnight
    usage_test.go:367: download: 200, attachment;
    filename="magpie-requests-today-2026-10-01.csv" with 2 rows
    (<nil>), want magpie-requests-today-2026-09-30.csv with 2
    usage_test.go:378: export: 200
    {"path":"~/Downloads/magpie-requests-today-2026-10-01.csv",
    "rows":1}, want magpie-requests-today-2026-09-30.csv with 1 row

With the real clock, in scratch copies of both trees: scratch tests
held one of Clock's reads 1.5s after it was made, as reading a large
log does (the first; for one of them the page's third, its chart's),
and at.sh started each at 23:59:59.3. Before, all 9 FAIL, among them
  asked 23:59:59.564, answered 00:00:01.068
  Direct(Today): 0 calls since 2026-10-05 00:00:00 -0940 MID, want 1
  since 2026-10-05 00:00:00 -0940 MID
  QueryPage(Today): 2 rows, 0 in a chart from 2026-10-06 ...; want 2
  under one from 2026-10-05 ...
  download: attachment; filename="magpie-requests-today-2026-10-06.csv"
  with a call of 2026-10-05 23:59
After, all 9 PASS, answered at 00:00:00.9 to 00:00:01.0, so midnight
fell inside each, except the chart's: its held read is gone with the
change. A scratch sleep before the chart shows that one: before "2
rows, 0 in a chart from 2026-10-06", after PASS, asked 23:59:59.344
and answered 00:00:00.941. At 12:00:00 all of them PASS in both trees.

After: every changed test PASS with at.sh at 23:59:59.9, 00:00:30 and
12:00:00 (698da36f).

Without the change (698da36f): with the production put back to the
previous commit's (gui/usage.go only so far that TestCSVStamp compiles:
csvStamp takes the time, read apart from the rows as before), go test
-count=1 . ./internal/usage ./internal/tui ./internal/gui fails the four
new tests and no other, with the lines above. Each of the 16 places put
back alone fails one of them: the page's key (clock_test.go:134: "3
rows, 3 in a chart from 2026-09-30 ...; want 0 under one from 2026-10-01
..."), its sources (:131: "2 rows"), its rows (:131: "0 rows"), its
chart (:131), the ledger page's rows and chart (:141), LedgerOfAt (:141,
:145), ledgerSeriesAt (:141), Direct (:145); Summaries, indexedSummary
and magpie usage (usage_cli_test.go:154); Summaries and the TUI's period
key and reload (usage_test.go:184, :187); the CSV's download and export
(usage_test.go:367, :378).

On this commit: go test -tags nogui -count=1 . ./internal/usage
./internal/gui ./internal/tui ./internal/davsync ./internal/gateway ok;
-race -count=20 over the changed tests of each package ok; gofmt -l
prints nothing, and go vet -tags nogui finds nothing.

* gateway: the key-limit tests pass when a run crosses local midnight

A go test run that crossed local midnight could fail the gateway's
key-limit tests. keyLimited and the /v1/magpie/limit endpoint each read
time.Now() when they check a key, and access.Window turns that into a
calendar window (a day from midnight, a week from Monday, a month from
the 1st). The tests seed "yesterday" from their own time.Now() and then
make several calls, so a run that crossed midnight saw its calls split
over two windows: the second call of a 350-token day was let in, or a
restart counted nothing.

The gateway's key-limit path now reads limitClock, a variable for the
tests like ruleClock and idClock, and production behaviour is unchanged
(it is time.Now). TestKeyLimitRefusesBeforeUpstream,
TestKeyLimitHoldsParallelRequests and TestKeyLimitWindowResets pin it
at 2026-09-30 12:00 local and seed "yesterday" from that instant. Calls
are still recorded at the real time, after the pinned one, and a window
counts every call from its start, so they all count in the pinned
window; the "tomorrow's window" check in TestKeyLimitWindowResets stays
on the real now, the first window with none of those calls in it. No
other test sets a key limit with records in its window (the gateway-key
CLI test and the GUI caller-key tests count 0 calls or have no limit).

TestKeyLimitRefusesBeforeUpstream now also checks that the 429's
Retry-After and X-Magpie-Limit-Reset name the next midnight after the
check, and that the limit endpoint's week starts on the pinned
Monday.

Verified with at.sh, a scratch script that runs the test binary under
a zone made with zic, so that the local time of day is the one asked
for as the run starts:
- before, at.sh at 23:59:59 with COUNT=500/300 and FRAC 0.3/0.6/0.8:
  3 of 6 runs of each test failed with
  key_limit_test.go:217: spent key let in
  key_limit_test.go:117: a restart forgot what the key used 200
  key_limit_test.go:121: status &{Period:day Start:2026-10-06 ...
  Calls:0 Tokens:0 ...}
- after, the same 6 runs (COUNT=500, both tests) print PASS, and so do
  ^TestKeyLimit at 12:00:00 and 00:00:00 with COUNT=20.
- go test -tags nogui -count=1 ./internal/gateway: ok (79.5s);
  -race -count=20 -run '^TestKeyLimit' ./internal/gateway: ok.
- gofmt -l prints nothing; go vet -tags nogui ./internal/gateway is
  clean.

The month boundary (TestKeyLimitHoldsParallelRequests) and Monday can't
be reproduced by changing the time of day; the pinned clock covers them
the same way.

Fails without the change (tests pinned, keyLimited back on time.Now):
--- FAIL: TestKeyLimitRefusesBeforeUpstream
    key_limit_test.go:107: headers map[... Retry-After:[80228]
    X-Magpie-Limit-Reset:[2026-10-07T00:00:00-07:00] ...]
and with only the limit endpoint back on time.Now:
--- FAIL: TestKeyLimitRefusesBeforeUpstream
    key_limit_test.go:159: limit endpoint {... "period":"week",
    "start":"2026-10-05T00:00:00-07:00" ...}

* gateway: the routing history tests pass when a run crosses midnight

TestHistoryKeepsDaysAndDropsOld and TestRemoteMagpieGroupHistory took
now := time.Now() and saved routes at now and now+1s. saveRoute files a
route by its own day (r.Time.Local()), so a test started at 23:59:59 put
the +1s route in tomorrow's file and History(today) found one route
less. TestHistoryKeepsSessionAndTokenTiers raced the other clock:
saveRoute prunes by time.Now(), so when the day turned between the
test's time.Now() and the first saveRoute, the prune gzipped "today"
after the first route, the second went to a new .jsonl, and the test's
own pruneHistory gzipped that over the first .gz, losing route 1.

saveRoute now prunes by historyClock (time.Now outside the tests). The
three tests take historyNoon(t), which holds that clock at
2026-09-30 12:00 local and returns it as their now, restored with
t.Cleanup. Every route, day file and prune is then on that one past day.
The fixture can't be a fixed past day without the clock: pruning by
the real today gzips that day's file at once (and, a month on, drops
it as too old).

The other history tests keep the real clock:
TestHistoryRouteOutsideDayLimit writes its day files itself from one
now and the code under test reads no clock, and
TestHistoryRouteDayAndNoLock already uses a fixed day.

Verified with at.sh, a scratch script that sets the local time of day
through a zone made with zic as the run starts:
- before, at.sh ./internal/gateway at 23:59:59:
  --- FAIL: TestHistoryKeepsDaysAndDropsOld
      history_test.go:50: days [{2026-10-06 1} {2026-10-05 1} {2026-10-04 1}]
  --- FAIL: TestRemoteMagpieGroupHistory
      remote_group_served_test.go:144: routes [... only ID 1 ...]
  and, with a 2s sleep after now := time.Now() standing in for the turn
  of the day, --- FAIL: TestHistoryKeepsSessionAndTokenTiers
      history_test.go:22: compressed history lost accounting: [only ID 2]
  12:00:00 control: PASS.
- after: the three tests PASS at 23:59:59, 23:59:59.9 (FRAC=0.9),
  12:00:00, and 23:59:59 with the same 2s sleep; all TestHistory* and
  TestRemoteMagpieGroupHistory PASS at 23:59:59 with -test.count=3.
- with saveRoute pruning by time.Now() again (the fixtures still pinned),
  at any time of day:
  --- FAIL: TestHistoryKeepsSessionAndTokenTiers
      history_test.go:32: compressed history lost accounting: [only ID 2]
- go test -tags nogui -count=1 ./internal/gateway: ok;
  -race -count=20 on the three tests: ok; gofmt -l and
  go vet -tags nogui ./internal/gateway print nothing.

* gateway, davsync: the request archive tests pass when a run crosses UTC midnight

The archive names a call "<date>/<id>" from the call's own start, in
UTC (archiving in archive.go). TestArchiveOn read time.Now().UTC()
before posting and TestRequestArchiveToS3 read it before post(), and
each expected that read's date. A run where UTC midnight (17:00 PDT)
fell between the test's read and the request's start expected the day
before the one the archive used, and failed.

Both tests now take the date from the Time the call carries
(s.Recent()[0].Time, the same start the archive's name is made from),
in UTC, so nothing is read from the clock a second time. No production
code changes. The other archive tests already go by the call's Archive
name, and internal/gui's archive test uses a fixed name; no other test
in internal/gateway, internal/davsync or internal/gui predicts an
archive key or a UTC-dated name from its own clock.

Verified: at.sh only moves the local time of day, and this is a UTC
crossing (UTC midnight was ~16h away), so it was shown in a scratch
copy whose serve starts each request 0.2s after the next UTC midnight,
as a request does when the test read the clock just before it. There
the old tests fail:
  archive_test.go:115: call archive "2026-10-07/000000-f4690de23ee8a859",
  want "2026-10-06/000000-f4690de23ee8a859"
  archive_test.go:93: nothing at
  "team x+y/magpie/archive/2026-10-06/000000-0e74a87816ef6a56.json"
and the new ones pass. Unshifted, under at.sh at local 23:59:59,
00:00:00 and 12:00:00, both pass, as the old ones do at 23:59:59 and
00:00:00 (local time doesn't enter the name). With archiving naming by
start.Format (local) instead of start.UTC().Format, both new tests fail
under at.sh at local 23:00:00, where the local date is a day behind
UTC's:
  --- FAIL: TestArchiveOn ... archive_test.go:116: call archive
  "2026-10-05/082500-7322712bd89f71da", want
  "2026-10-06/082500-7322712bd89f71da"
  --- FAIL: TestRequestArchiveToS3 ... archive_test.go:95: nothing at
  "team x+y/magpie/archive/2026-10-06/082500-3587ab4192da2cd5.json"
and pass at 12:00:00. go test -tags nogui -count=1 ./internal/gateway
./internal/davsync passes in a sandbox HOME; -race -count=20 -run
'^(TestArchiveOn|TestRequestArchiveToS3)$' passes; gofmt -l and go vet
-tags nogui on both packages are clean.

* gui: TestWhatsNewTodayRoute passes when the run crosses local midnight

TestWhatsNewTodayRoute posts {"on":true} to /api/whatsnew/today, which
writes today() to whatsnew-quiet-day, then checks quietToday(), which
compares the file with a second today(). A run that crossed local
midnight between the two read different days and failed with
"on: 204 false". The neighbouring TestWhatsNewQuietAfterRestartToUpdate
already pins today; this test didn't.

The test now pins today to 2026-09-30 (restored with t.Cleanup), so the
post and the check read the same day. The other What's new tests start
from an empty folder, so quietToday finds no file and never reads
today().

Verified: with a temporary 1s sleep between the post and the check, the
unpinned test under at.sh (local time of day set by a zone) started at
local 23:59:59.5 failed with "whatsnew_test.go:237: on: 204 false" and
passed at 12:00:00; pinned, it passes at 23:59:59.5 (same sleep), and,
without the sleep, at 23:59:59, 00:00:00 and 12:00:00. With
setQuietToday writing time.Now().Format("2006-01-02") instead of
today(), the test fails: "--- FAIL: TestWhatsNewTodayRoute ...
whatsnew_test.go:239: on: 204 false". go test -tags nogui -count=1
./internal/gui passes in a sandbox HOME; -race -count=20 -run
'^TestWhatsNewTodayRoute$' passes; gofmt -l and go vet -tags nogui
./internal/gui are clean.

* provider: TestZCodeStartSentAsTheApp passes when the run crosses midnight

A ZCode Start Plan request opens with a context prefix that says
"Today's date is <day>" (zcodeDateReminder), the day zcodeStartRequest
took from its own time.Now(). TestZCodeStartSentAsTheApp sent the
request, then built the expected prefix from a second time.Now(). A run
that crossed midnight between the two failed on "the context prefix",
the request holding the day before.

zcodeStartRequest now takes the time from zcodeClock (a package
variable, time.Now outside tests, as gateway's ruleClock), and the test
pins it to 2026-09-30 12:00 local, restored with t.Cleanup, and expects
"Today's date is 2026-09-30." The test sets TZ=Asia/Shanghai, which
moves time.Local only when nothing has read it yet; a mid-day instant in
time.Local formats to the same day either way. The other calls to
zcodeStartBody in the test check shaping only, not the day.

No other provider test builds a day string from a second clock: the
WorkBuddy credits, daily warm-up and check-in tests pass fixed times to
code that takes now as an argument, and TestQoderCheckinOnTheCard's day
is only carried, not compared.

Verified with at.sh (local time of day set by a zone, run as
-run '^(TestDailyCredits|TestZCodeStartSentAsTheApp)$' so that
time.Local is read before the test's t.Setenv("TZ"), as in a full run;
alone, Local becomes Asia/Shanghai): with a temporary 1s sleep after the
request, the unpinned test started at local 23:59:59.5 failed with
"zcode_client_test.go:195: the context prefix: ... Today's date is
2026-10-05." and passed at 12:00:00; pinned, it passes at 23:59:59.5
(same sleep) and, without the sleep, at 23:59:59, 00:00:00 and 12:00:00.
With zcodeStartRequest back on time.Now(), the pinned test fails:
"--- FAIL: TestZCodeStartSentAsTheApp ... zcode_client_test.go:196: the
context prefix: ... Today's date is 2026-10-06." go test -tags nogui
-count=1 ./internal/provider passes in a sandbox HOME; -race -count=20
-run '^TestZCodeStartSentAsTheApp$' passes; gofmt -l and go vet -tags
nogui ./internal/provider are clean.

* accounts, quota: a window's reset reads against one time per listing; TestQuotaCell passes across midnight

quotaCell, a window in `magpie accounts` and `magpie quota`, read the
clock three times: whether the reset had passed, how long until it, and
ResetClock's label, which names the calendar day ("tomorrow 02:13" or
"02:13"). TestQuotaCell set the reset 2h13m30s from time.Now() and
built the expected label from yet another time.Now(); a run that crossed
local midnight between quotaCell's read and the test's got "tomorrow
02:13" from one and "02:13" from the other and failed.

quotaCell now takes now. accountsCmd and quotaCmd take one time.Now()
just before printing and pass it to every window's cell, so a listing's
windows agree on what "today" is. The test passes a fixed 2026-09-30
12:00 local and expects "↻2h13m 14:13", and for a reset the next day
"↻14h13m tomorrow 02:13"; no zone in tzdata 2026c changes its clock
between those instants, so both read the same in any TZ. resetsCell's
label, quotaReadingCell's expired check and quotaResetCmd's prompt
still read time.Now(); no test compares them against a second clock.
internal/tui's quotaCell and quotaLines already take now, and their
tests pass a fixed one.

Verified with at.sh (local time of day set by a zone): with a temporary
1s sleep between quotaCell and the check, the old test started at local
23:59:59.5 failed with "accounts_cli_test.go:128: \"5h 42% ↻2h13m
tomorrow 02:13\"" and passed at 12:00:00; the new one passes at
23:59:59.5 (same sleep) and, without the sleep, at 23:59:59, 00:00:00
and 12:00:00. Putting any of quotaCell's three reads back on the wall
clock fails the test ("--- FAIL: TestQuotaCell"). ResetClock's now
alone fails the next-day case: "accounts_cli_test.go:134: tomorrow:
\"7d 42% ↻14h13m 02:13\"". The passed check alone, or all three:
"accounts_cli_test.go:129: \"5h 42% · reset time passed Sep 30
14:13\"". untilShort alone: "accounts_cli_test.go:129: \"5h 42% ↻now
14:13\"". go test -tags nogui -count=1 . passes in a sandbox HOME;
-race -count=20 -run '^TestQuotaCell$' passes; gofmt -l and go vet
-tags nogui . are clean.
2026-10-07 02:14:29 +08:00

575 lines
20 KiB
Go

package main
import (
"cmp"
"context"
"encoding/json"
"fmt"
"slices"
"strconv"
"strings"
"time"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/settings"
)
const quotaUsage = `usage: magpie quota [<provider>…] [--json]
magpie quota reset [<codex account>] [--yes]
magpie quota auto-reset [<codex account>] [on|off]
magpie quota credits [<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
last answered through the gateway, and last: true on the latest; the gateway answers the same at
GET http://127.0.0.1:3425/v1/magpie/quotas (on Settings' Gateway port), and to another machine only while magpie is
shared on the local network, with its key as the API key (Authorization: Bearer or x-api-key)
A Codex account that holds rate-limit resets says how many; quota reset spends one,
starting the account's current windows again (the one Codex is signed in to unless
named). It can't be undone, so it asks first; --yes doesn't. quota auto-reset on lets
the account spend one by itself when its weekly window is used up and no other
account can take a request, one a week at most (the five hours running out never
does), and spend the one about to expire about half an hour before it does, if
the account's windows have been used, so what they have left can be used until
then and it isn't lost; at once when the account is held up until after then.
It is the account's standing setting, held resets or not; off stops it, and alone
it says which accounts have it on. It is off until turned on.
quota credits off keeps a Codex account from spending its credits: once one of
its windows is used up, by magpie's latest reading (refreshed about every
minute), routing holds it as used up until that window renews and the request
goes to the other accounts, groups and fallbacks, or is refused with why (with
auto-reset on, its week used up and no one else left, it spends a reset first).
on, the default, lets the vendor answer on its credits then, so the task goes
on; alone it says which accounts don't spend theirs.
quota alert 80 has the magpie app notify when any window of a subscription or plan
reaches 80% used, once each time the window runs (not windows set aside, such as
on-demand spending); --balance 5 when a balance falls to 5 or under, in its own
currency or credits, once until topped up past it. off turns either off, and alone
it says what is set. Both are off until set.
quota wait codex returns once any Codex account magpie has on has allowance again
(a window that stops it no longer used up), and an account's email or login, or
<provider>/<account>, waits for that one alone: it reads the vendors itself, gateway
running or not, again shortly after the soonest reset (every 1 to 10 minutes), and
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.
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 {
if len(args) > 1 && args[1] == "reset" {
return quotaResetCmd(args[2:])
}
if len(args) > 1 && args[1] == "auto-reset" {
return quotaAutoResetCmd(args[2:])
}
if len(args) > 1 && args[1] == "credits" {
return quotaCreditsCmd(args[2:])
}
if len(args) > 1 && args[1] == "alert" {
return quotaAlertCmd(args[2:])
}
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:] {
switch a {
case "--json", "-j":
asJSON = true
case "help", "-h", "--help":
fmt.Println(quotaUsage)
return nil
default:
only = append(only, strings.ToLower(a))
}
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
qs := []provider.Quota{}
provider.AskClaudeUsage()
for _, q := range provider.QuotaReport(ctx, time.Now()) {
if len(only) == 0 || quotaMatches(q, only) {
qs = append(qs, q)
}
}
qs, err := withUntold(qs, only)
if err != nil {
return err
}
if asJSON {
b, _ := json.MarshalIndent(qs, "", " ")
fmt.Println(string(b))
return nil
}
if len(qs) == 0 {
if len(only) > 0 {
return fmt.Errorf("no subscription, plan or key balance of %s", strings.Join(only, ", "))
}
fmt.Println(muted.Render("nothing to tell ·"), "a signed-in subscription, a coding plan or a key whose vendor tells its balance shows up here")
return nil
}
width := 0
for _, q := range qs {
width = max(width, len([]rune(quotaTitle(q))))
}
now := time.Now() // one instant for every window's reset time
for _, q := range qs {
line := fmt.Sprintf("%-*s %s", width, quotaTitle(q), muted.Render(fmt.Sprintf("%-12s", q.Kind)))
for _, w := range q.Windows {
line += " " + quotaCell(w, now)
}
if q.Balance != "" {
line += " " + balanceCell(q.Balance, q.Provider, q.User)
}
if q.Resets != nil {
line += " " + resetsCell(q.Resets, provider.AutoResets(q.Provider, q.User))
}
if q.Error != "" {
line += " " + muted.Render(q.Error)
}
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"))
return nil
}
// balanceCell is what an account holds in a line: "1.2K credits left",
// and "· not spent" for a Codex account set not to spend its credits.
func balanceCell(balance, id, user string) string {
cell := bold.Render(balance) + muted.Render(" left")
if id == "codex" && user != "" && !provider.CodexCredits(user) {
cell += muted.Render(" · not spent")
}
return cell
}
// resetsCell is a Codex account's rate-limit resets in a line: "↺ 2
// resets until Oct 3 14:30", no date when they don't run out, and "· auto"
// when one is spent by itself once the week is used up.
func resetsCell(r *provider.ResetCredits, auto bool) string {
cell := "↺ " + r.Words()
if r.Until != nil {
cell += muted.Render(" until " + provider.ResetClock(*r.Until, time.Now()))
}
if auto {
cell += muted.Render(" · auto")
}
return cell
}
// quotaResetCmd: magpie quota reset [<codex account>] [--yes] — spends one
// of the account's rate-limit resets, once the user has said so.
func quotaResetCmd(args []string) error {
yes, user := false, ""
for _, a := range args {
switch a {
case "--yes", "-y":
yes = true
case "help", "-h", "--help":
fmt.Println(quotaUsage)
return nil
default:
if user != "" {
return fmt.Errorf("one account at a time: %s or %s", user, a)
}
user = a
}
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
// what the account holds, and whose it is when none was named
var held *provider.SubscriptionQuota
for _, q := range provider.SubscriptionUsage(ctx) {
if q.Provider == "codex" && (user == "" || strings.EqualFold(q.User, user)) {
held = &q
break
}
}
if held == nil {
if user != "" {
return fmt.Errorf("no Codex account %s", user)
}
return fmt.Errorf("Codex isn't signed in")
}
who := cmp.Or(held.User, "the Codex account")
if held.Resets == nil && held.Error == "" {
return fmt.Errorf("%s holds no rate-limit reset", who)
}
if !yes {
n := "one of its resets"
if held.Resets != nil {
n = "one of its " + plural(held.Resets.Count, "reset")
}
if held.Resets != nil && held.Resets.Until != nil {
n += " (the one that runs out first, " + provider.ResetClock(*held.Resets.Until, time.Now()) + ")"
}
fmt.Printf("Spend %s on %s, starting its current windows again? It can't be undone. [y/N] ", n, who)
line, _ := stdin.ReadString('\n')
if a := strings.ToLower(strings.TrimSpace(line)); a != "y" && a != "yes" {
return fmt.Errorf("nothing spent")
}
}
out, err := provider.UseCodexReset(ctx, held.User)
if err != nil {
return err
}
if out.Code != "reset" {
return fmt.Errorf("%s: %s", who, out.Text())
}
fmt.Println(green.Render("✓"), who+":", out.Text())
return nil
}
// quotaAutoResetCmd: magpie quota auto-reset [<codex account>] [on|off] —
// whether the account spends a reset by itself once its week is used up.
func quotaAutoResetCmd(args []string) error {
user, set, on := "", false, false
for _, a := range args {
switch strings.ToLower(a) {
case "on", "off":
set, on = true, strings.EqualFold(a, "on")
case "help", "-h", "--help":
fmt.Println(quotaUsage)
return nil
default:
if user != "" {
return fmt.Errorf("one account at a time: %s or %s", user, a)
}
user = a
}
}
if !set && user == "" {
on := settings.Load().CodexAutoReset
if len(on) == 0 {
fmt.Println(muted.Render("no Codex account uses a reset by itself ·"), "magpie quota auto-reset <account> on")
return nil
}
for _, u := range on {
fmt.Println(green.Render("●"), u, muted.Render("uses a reset by itself when its week runs out, or before one expires"))
}
return nil
}
user, err := codexAccountNamed(user)
if err != nil {
return err
}
if !set {
if provider.CodexAutoReset(user) {
fmt.Println(user, "uses a reset by itself when its week runs out, or before one expires")
} else {
fmt.Println(user, "uses its resets only when told to")
}
return nil
}
if err := provider.SetCodexAutoReset(user, on); err != nil {
return err
}
if on {
fmt.Println(green.Render("✓"), user+":", "uses a reset by itself once its week is used up and no other account can answer, one a week at most, and the one about to expire half an hour before it does, or at once when held up past then")
} else {
fmt.Println(green.Render("✓"), user+":", "no longer uses a reset by itself")
}
return nil
}
// quotaCreditsCmd: magpie quota credits [<codex account>] [on|off] —
// whether the account spends its credits once its allowance is used up.
func quotaCreditsCmd(args []string) error {
user, set, on := "", false, false
for _, a := range args {
switch strings.ToLower(a) {
case "on", "off":
set, on = true, strings.EqualFold(a, "on")
case "help", "-h", "--help":
fmt.Println(quotaUsage)
return nil
default:
if user != "" {
return fmt.Errorf("one account at a time: %s or %s", user, a)
}
user = a
}
}
if !set && user == "" {
off := settings.Load().CodexNoCredits
if len(off) == 0 {
fmt.Println(muted.Render("every Codex account spends its credits once its allowance is used up ·"), "magpie quota credits <account> off")
return nil
}
for _, u := range off {
fmt.Println(muted.Render("○"), u, muted.Render("doesn't spend its credits: held once magpie reads a window used up"))
}
return nil
}
user, err := codexAccountNamed(user)
if err != nil {
return err
}
if !set {
if provider.CodexCredits(user) {
fmt.Println(user, "spends its credits once its allowance is used up")
} else {
fmt.Println(user, "doesn't spend its credits: once magpie reads a window used up it is held till it renews")
}
return nil
}
if err := provider.SetCodexCredits(user, on); err != nil {
return err
}
if on {
fmt.Println(green.Render("✓"), user+":", "spends its credits once its allowance is used up, so a task goes on")
} else {
fmt.Println(green.Render("✓"), user+":", "no longer spends its credits: once magpie reads a window used up, requests go to the other accounts till it renews")
}
return nil
}
// codexAccountNamed is the Codex account user names, the one Codex is
// signed in to when "".
func codexAccountNamed(user string) (string, error) {
if user == "" {
live, ok := provider.CodexSignedIn()
if !ok {
return "", fmt.Errorf("Codex isn't signed in: name the account")
}
return live, nil
}
if !slices.ContainsFunc(provider.Logins("codex"), func(l provider.Login) bool { return strings.EqualFold(l.User, user) }) {
if live, ok := provider.CodexSignedIn(); !ok || !strings.EqualFold(live, user) {
return "", fmt.Errorf("no Codex account %s", user)
}
}
return user, nil
}
// quotaAlertCmd: magpie quota alert [<percent>|off] [--balance <amount>|off]
// — the usage alerts Settings sets (#368), the notifications the app shows.
func quotaAlertCmd(args []string) error {
s := settings.Load()
changed := false
for i := 0; i < len(args); i++ {
a := strings.ToLower(strings.TrimSpace(args[i]))
switch {
case a == "help" || a == "-h" || a == "--help":
fmt.Println(quotaUsage)
return nil
case a == "--balance" || strings.HasPrefix(a, "--balance="):
v, ok := strings.CutPrefix(a, "--balance=")
if !ok {
if i+1 >= len(args) {
return fmt.Errorf("--balance needs an amount, or off")
}
i++
v = strings.ToLower(strings.TrimSpace(args[i]))
}
if v == "off" {
s.BalanceAlert = 0
} else {
n, err := strconv.ParseFloat(v, 64)
if err != nil || n <= 0 {
return fmt.Errorf("a balance alert is at an amount over 0, or off, not %q", v)
}
s.BalanceAlert = n
}
changed = true
case a == "off":
s.UsageAlert = 0
changed = true
default:
n, err := strconv.Atoi(strings.TrimSuffix(a, "%"))
if err != nil || n < 1 || n > 100 {
return fmt.Errorf("a usage alert is at a percentage from 1 to 100, or off, not %q", args[i])
}
s.UsageAlert = n
changed = true
}
}
if changed {
if err := settings.Save(s); err != nil {
return err
}
fmt.Print(green.Render("✓") + " ")
}
if s.UsageAlert > 0 {
fmt.Printf("notifies when a window reaches %d%% used, once each time it runs", s.UsageAlert)
} else {
fmt.Print("no usage alert")
}
if s.BalanceAlert > 0 {
fmt.Printf("; and when a balance falls to %s or under, once until topped up\n", strconv.FormatFloat(s.BalanceAlert, 'f', -1, 64))
} else {
fmt.Println("; no balance alert")
}
if s.UsageAlert > 0 || s.BalanceAlert > 0 {
fmt.Println(muted.Render(" the notifications come from the magpie app, while it runs"))
}
return nil
}
// quotaTitle is a quota's line head: its provider, plan and account.
func quotaTitle(q provider.Quota) string {
t := q.Provider
if q.Plan != "" {
t += " · " + q.Plan
}
if s := provider.PlanTerm(q.Until, q.Renew); s != "" {
t += " · " + s
}
if q.User != "" {
t += " · " + q.User
}
return t
}
func quotaMatches(q provider.Quota, only []string) bool {
for _, o := range only {
if strings.EqualFold(q.Provider, o) || strings.EqualFold(q.Name, o) || strings.EqualFold(q.Kind, o) {
return true
}
// a remote magpie's card: by the remote ("office", or its name)
// or by the provider there ("codex" is office/codex too)
if rid, there, ok := strings.Cut(q.Provider, "/"); ok && q.From != "" &&
(strings.EqualFold(rid, o) || strings.EqualFold(q.From, o) || strings.EqualFold(there, o)) {
return true
}
}
return false
}
// withUntold adds a line for each provider named that told nothing, saying
// why, rather than leaving it out as if it weren't there; a name that is no
// provider at all is an error.
func withUntold(qs []provider.Quota, only []string) ([]provider.Quota, error) {
for _, o := range only {
if o == "subscription" || o == "plan" || o == "balance" || slices.ContainsFunc(qs, func(q provider.Quota) bool { return quotaMatches(q, []string{o}) }) {
continue
}
p, err := provider.Find(o)
if err != nil {
return nil, err
}
qs = append(qs, provider.Quota{Provider: p.ID, Name: p.Name, Kind: "balance", Windows: []provider.QuotaSpan{},
Error: "not configured · no balance endpoint known for it; set Balance URL and Balance field in its editor"})
}
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{}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
all := provider.QuotaHistories(provider.QuotaHistorySince(days, now), "", "")
// and each remote magpie's, as it keeps them (office/codex)
all = append(all, provider.RemoteQuotaHistories(ctx, days)...)
for _, h := range all {
rid, there, _ := strings.Cut(h.Provider, "/")
if len(only) == 0 || slices.ContainsFunc(only, func(o string) bool {
return o == h.Provider || o == h.User || o == h.Provider+"/"+h.User || there != "" && (o == rid || o == there || o == there+"/"+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
}