* 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 since1caea638), 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 rows117b6c7fmade 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 onc5a54bc7, and those marked (698da36f) again on698da36f, with the same results. The tui package runs in UTC since1caea638, a zone at.sh doesn't move, so its runs on698da36fused 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 (698da36ffor 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 onc5a54bc7, when the tui package's tests still ran in the local zone, and those marked (698da36f) again on698da36f, 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.
760 lines
26 KiB
Go
760 lines
26 KiB
Go
// Package usage keeps the token count of every call the gateway serves, so
|
||
// magpie can show what each agent and model consumed and roughly what it cost.
|
||
// Records go to one JSON-lines file next to providers.json. Metadata leaves
|
||
// the machine only when OTLP export is explicitly enabled.
|
||
package usage
|
||
|
||
import (
|
||
"bufio"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"slices"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/yetone/magpie/internal/catalog"
|
||
"github.com/yetone/magpie/internal/provider"
|
||
)
|
||
|
||
// Record is one call.
|
||
type Record struct {
|
||
Operation string `json:"operation,omitempty"`
|
||
RouteID int64 `json:"route_id,omitempty"` // the gateway Route, shared by its attempts
|
||
Time time.Time `json:"t"`
|
||
Agent string `json:"agent"` // magpie agent id, or the client's product name
|
||
Provider string `json:"provider"`
|
||
Host string `json:"host,omitempty"` // where the call went: provider.Where then
|
||
ProviderKeyID string `json:"providerKeyId,omitempty"` // fingerprint of the API key actually used
|
||
ProviderKeyName string `json:"providerKeyName,omitempty"`
|
||
// ProviderAccount is the subscription account that answered the call,
|
||
// as the Routing trace names it (provider.Account.User: an email, a
|
||
// login): the one that took over after a failover, the one pinned by
|
||
// X-Magpie-Account. Never a token. "" for a key or a provider without
|
||
// an account, and in a record written before it was kept (#557).
|
||
ProviderAccount string `json:"providerAccount,omitempty"`
|
||
CallerKeyID string `json:"callerKeyId,omitempty"`
|
||
CallerKeyName string `json:"callerKeyName,omitempty"`
|
||
// SessionProvider is the provider ID recorded by the agent. SessionAccount
|
||
// identifies the session's creator, not the account used for an API request.
|
||
// Neither field establishes an upstream route from today's configuration.
|
||
SessionProvider string `json:"session_provider,omitempty"`
|
||
SessionAccount string `json:"session_account,omitempty"`
|
||
// SessionOfficialLogin marks a matched account's explicit official login
|
||
// metadata, not this call's endpoint or billing authentication.
|
||
SessionOfficialLogin bool `json:"session_official_login,omitempty"`
|
||
Model string `json:"model"` // the provider's model id
|
||
// Requested is the model id the agent asked for (a magpie alias, a
|
||
// routing group, provider/model…), and Served the model the vendor's
|
||
// reply says answered, when it named one: a ledger to set beside the
|
||
// vendor's own bill. Neither is in a record written before they were.
|
||
Requested string `json:"req,omitempty"`
|
||
Served string `json:"served,omitempty"`
|
||
// Upstream: the provider an aggregator (OpenRouter …) said answered
|
||
// behind it — DeepInfra, Novita — when its reply named one
|
||
Upstream string `json:"upstream,omitempty"`
|
||
Input int `json:"in"`
|
||
Output int `json:"out"`
|
||
CacheRead int `json:"cache_read,omitempty"`
|
||
CacheWrite int `json:"cache_write,omitempty"`
|
||
// CacheWrite1h is how many of CacheWrite were written to be kept for
|
||
// an hour (Anthropic's 1-hour TTL, billed at 2× input where 5 minutes'
|
||
// is 1.25×); the rest were for 5 minutes. A record from before it was
|
||
// kept has none: all its writes are counted as 5-minute ones, as they
|
||
// were.
|
||
CacheWrite1h int `json:"cache_write_1h,omitempty"`
|
||
Reasoning int `json:"reasoning,omitempty"`
|
||
// Effort is the reasoning the model was asked for — a routing group's
|
||
// pick for the turn, or the agent's own — as it takes it; "" for none
|
||
Effort string `json:"effort,omitempty"`
|
||
Millis int64 `json:"ms"`
|
||
// TTFT: ms from the request to its reply's first content — text,
|
||
// reasoning or a tool call — and FirstText to its first text, counted
|
||
// as Millis is, so Millis-TTFT is how long the reply took to write;
|
||
// none for a reply that wasn't streamed (#196)
|
||
TTFT int64 `json:"ttft_ms,omitempty"`
|
||
FirstText int64 `json:"first_text_ms,omitempty"`
|
||
// Sent: ms from the request to its answering try going out to the
|
||
// vendor, its body written, as TTFT is counted: magpie's own time and
|
||
// the tries that failed first are before it, and TTFT-Sent is how
|
||
// long the vendor took to its first content. 0 where it isn't known
|
||
// (a reply not streamed, or a vendor magpie doesn't reach over HTTP).
|
||
Sent int64 `json:"sent_ms,omitempty"`
|
||
Status int `json:"status"`
|
||
// Error is why a call failed, in the vendor's words and cut short;
|
||
// ErrType what its body called the error (rate_limit_error,
|
||
// usage_limit_reached); RequestID the id the vendor gave the call; and
|
||
// Endpoint the path the agent called, with the one it was sent to when
|
||
// it went out in another protocol (/v1/messages → /v1/chat/completions)
|
||
Error string `json:"err,omitempty"`
|
||
ErrType string `json:"err_type,omitempty"`
|
||
RequestID string `json:"rid,omitempty"`
|
||
// ResponseID is the response object ID actually sent to the client.
|
||
ResponseID string `json:"response_id,omitempty"`
|
||
Endpoint string `json:"ep,omitempty"`
|
||
// Stop is why the upstream said its reply ended, in its own words
|
||
// (stop_reason, finish_reason …): "" when it said none, a stream that
|
||
// just stopped. Told apart by it, a reply that ended too soon is the
|
||
// upstream's own end_turn or a cut (蓝猫 on Discord).
|
||
Stop string `json:"stop,omitempty"`
|
||
// Session is the conversation the call was part of, as its agent names
|
||
// it (X-Magpie-Session, or the session header Claude Code, Codex or
|
||
// OpenCode sends): several sessions on one model told apart
|
||
Session string `json:"session,omitempty"`
|
||
// NativeSession retains the client header when X-Magpie-Session overrides it.
|
||
NativeSession string `json:"native_session,omitempty"`
|
||
// Rejected is a request refused locally before an upstream was contacted.
|
||
Rejected bool `json:"rejected,omitempty"`
|
||
// Kind is what the agent made the call for when it isn't a turn of
|
||
// the conversation: a Codex subagent's (review, compact, guardian…)
|
||
Kind string `json:"kind,omitempty"`
|
||
// Via is the computer whose magpie passed the call on to this one (a
|
||
// Remote magpie provider there), Agent being the agent's on it; "" when
|
||
// no other magpie forwarded it (including direct LAN/container clients).
|
||
Via string `json:"via,omitempty"`
|
||
// Archive is where the request archive keeps the call, "<date>/<id>"
|
||
// (gateway/archive.go), when it was on: the Usage page reads it back
|
||
// by it long after Recent calls has let the call go (#447)
|
||
Archive string `json:"archive,omitempty"`
|
||
// Computer is the other computer the call was made on, by its id, for
|
||
// a call brought here by sync (#542); "" for one made here, as every
|
||
// call in usage.jsonl is
|
||
Computer string `json:"computer,omitempty"`
|
||
// OTel is transient trace context; SkipOTel avoids exporting ledger records
|
||
// when the gateway exports its attempts separately. Neither is persisted.
|
||
OTel *OTelSpan `json:"-"`
|
||
SkipOTel bool `json:"-"`
|
||
// Local is true only for verified loopback gateway requests. It is used
|
||
// for local-session deduplication, never persisted or exported.
|
||
Local bool `json:"-"`
|
||
// BodyIn and BodyOut are the request and reply as the gateway
|
||
// captured them, filled only for an OTLP export with bodies on (#538)
|
||
// and never written to usage.jsonl.
|
||
BodyIn string `json:"-"`
|
||
BodyOut string `json:"-"`
|
||
}
|
||
|
||
// Path is the log file: ~/.config/magpie/usage.jsonl (XDG-aware).
|
||
func Path() string { return filepath.Join(filepath.Dir(provider.Path()), "usage.jsonl") }
|
||
|
||
var mu sync.Mutex
|
||
|
||
// Append writes one record. Errors are swallowed: accounting must never
|
||
// break a call.
|
||
func Append(r Record) {
|
||
if r.Time.IsZero() {
|
||
r.Time = Clock()
|
||
}
|
||
offerOTel(r)
|
||
b, err := json.Marshal(r)
|
||
if err != nil {
|
||
return
|
||
}
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
if err := os.MkdirAll(filepath.Dir(Path()), 0o755); err != nil {
|
||
return
|
||
}
|
||
f, err := os.OpenFile(Path(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||
if err != nil {
|
||
return
|
||
}
|
||
defer f.Close()
|
||
before, _ := statLogHandle(f)
|
||
if _, err := f.Write(append(b, '\n')); err == nil {
|
||
after, _ := statLogHandle(f)
|
||
noteLogAppend(Path(), before, after)
|
||
}
|
||
}
|
||
|
||
// IsRejected also recognizes local rejections written before the explicit flag.
|
||
func (r Record) IsRejected() bool { return r.Rejected || r.Provider == "" && r.Status >= 400 }
|
||
|
||
// Load streams complete records from the log. Historical request bodies are
|
||
// not retained by a global cache after the caller finishes with this snapshot.
|
||
func Load(since time.Time) []Record {
|
||
mu.Lock()
|
||
f, err := os.Open(Path())
|
||
mu.Unlock()
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
defer f.Close()
|
||
var out []Record
|
||
reader := bufio.NewReaderSize(f, 64<<10)
|
||
for {
|
||
b, err := reader.ReadBytes('\n')
|
||
if err != nil {
|
||
break
|
||
} // an unfinished line is left for the next read
|
||
var r Record
|
||
if json.Unmarshal(b, &r) == nil && (since.IsZero() || !r.Time.Before(since)) {
|
||
out = append(out, r)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Known is an agent as its requests name it.
|
||
type Known struct {
|
||
ID string
|
||
Names []string // its id and aliases, which a record may already carry
|
||
UA []string // what its User-Agent begins with
|
||
}
|
||
|
||
// Agents lists the agents magpie knows. Package agent sets it from each
|
||
// agent's own description, so they are listed in one place only.
|
||
var Agents func() []Known
|
||
|
||
var knownAgents = sync.OnceValue(func() []Known {
|
||
if Agents == nil {
|
||
return nil
|
||
}
|
||
return Agents()
|
||
})
|
||
|
||
// AgentOf names the agent behind a client User-Agent. Known agents map to
|
||
// their magpie id; anything else keeps its product name.
|
||
func AgentOf(ua string) string {
|
||
ua = strings.TrimSpace(ua)
|
||
name, _, _ := strings.Cut(ua, "/")
|
||
name, _, _ = strings.Cut(name, " ")
|
||
l := strings.ToLower(name)
|
||
// a name first: cursor-local is its own, though Cursor's UA (cursor)
|
||
// begins it
|
||
for _, k := range knownAgents() {
|
||
if slices.Contains(k.Names, l) {
|
||
return k.ID
|
||
}
|
||
}
|
||
for _, k := range knownAgents() {
|
||
if slices.ContainsFunc(k.UA, func(p string) bool { return strings.HasPrefix(l, p) }) {
|
||
return k.ID
|
||
}
|
||
}
|
||
if name == "" {
|
||
return "other"
|
||
}
|
||
return name
|
||
}
|
||
|
||
// ---- summaries --------------------------------------------------------------
|
||
|
||
// Period is a window of time to sum over.
|
||
type Period string
|
||
|
||
const (
|
||
Today Period = "today"
|
||
Week Period = "7d"
|
||
Month Period = "30d"
|
||
All Period = "all"
|
||
)
|
||
|
||
// Periods lists the windows in order.
|
||
var Periods = []Period{Today, Week, Month, All}
|
||
|
||
// CostAt is what the call costs at a price: at the tier its input reaches,
|
||
// its 1-hour cache writes at theirs.
|
||
func (r Record) CostAt(p catalog.Price) float64 {
|
||
return p.CostSplit(r.Input, r.Output, r.CacheRead, r.CacheWrite, r.CacheWrite1h)
|
||
}
|
||
|
||
// Totals is a sum of calls.
|
||
type Totals struct {
|
||
Calls int `json:"calls"`
|
||
Errors int `json:"errors"`
|
||
Input int `json:"input"`
|
||
Output int `json:"output"`
|
||
CacheRead int `json:"cache_read"`
|
||
CacheWrite int `json:"cache_write"`
|
||
// CacheWrite1h is how many of CacheWrite were 1-hour writes
|
||
CacheWrite1h int `json:"cache_write_1h,omitempty"`
|
||
Reasoning int `json:"reasoning"`
|
||
Cost float64 `json:"cost"` // USD at the effective price, for the priced calls
|
||
Unpriced int `json:"unpriced"` // calls with tokens but no known price
|
||
// Timed: the answered calls whose first token was timed (streamed),
|
||
// TTFT the sum of their ttft_ms; DecodeMs the time from it to the end
|
||
// of those that wrote any, over which DecodeOut tokens came (a
|
||
// reasoning reply's answer, Record.Decode): their mean wait, and how
|
||
// fast they wrote (#196)
|
||
Timed int `json:"timed,omitempty"`
|
||
TTFT int64 `json:"ttft_ms,omitempty"`
|
||
DecodeMs int64 `json:"decode_ms,omitempty"`
|
||
DecodeOut int `json:"decode_out,omitempty"`
|
||
}
|
||
|
||
// Tokens is what went in and out, excluding cache traffic.
|
||
func (t Totals) Tokens() int { return t.Input + t.Output }
|
||
|
||
// MeanTTFT is the mean ms to the first token of the timed calls, 0 for none.
|
||
func (t Totals) MeanTTFT() int64 {
|
||
if t.Timed == 0 {
|
||
return 0
|
||
}
|
||
return t.TTFT / int64(t.Timed)
|
||
}
|
||
|
||
// Speed is how fast the timed calls wrote, in tokens a second after their
|
||
// first: 0 for none.
|
||
func (t Totals) Speed() float64 {
|
||
if t.DecodeMs <= 0 {
|
||
return 0
|
||
}
|
||
return float64(t.DecodeOut) / (float64(t.DecodeMs) / 1000)
|
||
}
|
||
|
||
// A reply's speed is its output tokens over its decode window, the ms
|
||
// from its first content to its end (#196; one that reasoned, DecodeOf); a window counts only when it
|
||
// timed the writing (#731). One that is shorter than MinDecodeMs, or over
|
||
// which the tokens would have come faster than MaxDecodeSpeed a second,
|
||
// didn't: the reply came in one burst at its end, written before the
|
||
// window opened — a turn that is one big tool call from Gemini, whose
|
||
// functionCall comes whole, has its first content a millisecond before
|
||
// its end, and 8264 tokens in 1 ms read 8,264,000 tok/s. Such a reply
|
||
// tells no speed and is left out of the summed one, where its tokens
|
||
// over next to no time would lift everyone's; its TTFT still counts.
|
||
// The bounds are wide of any real stream: the fastest vendors write a few
|
||
// thousand tokens a second, and a tenth of a second is the least a
|
||
// stream's timing tells anything by. routing.js's speedOf has the same.
|
||
const (
|
||
MinDecodeMs = 100
|
||
MaxDecodeSpeed = 10000
|
||
)
|
||
|
||
// DecodeWindow is the ms a reply of out tokens, ms long with its first
|
||
// content at ttft, took to write them: 0 when it wasn't timed, wrote
|
||
// nothing, or came too fast to tell a speed by (above).
|
||
func DecodeWindow(out int, ms, ttft int64) int64 {
|
||
w := ms - ttft
|
||
if out <= 0 || ttft <= 0 || w < MinDecodeMs || int64(out)*1000 > MaxDecodeSpeed*w {
|
||
return 0
|
||
}
|
||
return w
|
||
}
|
||
|
||
// DecodeOf is what a reply's speed is told by: the tokens it was seen to
|
||
// write and the ms it took (tony on Discord: gpt-6.1-sol read 163 tok/s,
|
||
// with a 14 s first token). A reply that reasoned counts only its answer —
|
||
// its output less the reasoning, from its first text — as most vendors
|
||
// keep the reasoning out of the stream (OpenAI's is encrypted, its summary
|
||
// sent when it's done; Claude's may come with no text): its tokens were
|
||
// written before the first content, over time the window from it doesn't
|
||
// hold, and counting them read a 2,000-token think and a 200-token answer
|
||
// in 1.5 s as 1,467 tok/s. Its time goes to the wait before the answer.
|
||
// One with reasoning and no text (all tool calls) tells no speed. The
|
||
// window is DecodeWindow's: 0 for a reply that tells none.
|
||
func DecodeOf(out, reasoning int, ms, ttft, firstText int64) (tokens int, w int64) {
|
||
start := ttft
|
||
if reasoning > 0 {
|
||
out, start = out-reasoning, firstText
|
||
}
|
||
if ttft <= 0 {
|
||
return 0, 0
|
||
}
|
||
if w = DecodeWindow(out, ms, start); w == 0 {
|
||
return 0, 0
|
||
}
|
||
return out, w
|
||
}
|
||
|
||
// Decode is the record's DecodeOf.
|
||
func (r Record) Decode() (tokens int, w int64) {
|
||
return DecodeOf(r.Output, r.Reasoning, r.Millis, r.TTFT, r.FirstText)
|
||
}
|
||
|
||
// FormatCost renders an effective-price cost, kept in USD everywhere it's
|
||
// stored, as the CLI and TUI show it: at amountUSD's own price when
|
||
// currency isn't "cny", else converted at rate (CNY per one USD, from
|
||
// internal/fx; a rate of 0 or below also falls back to USD, a stale or
|
||
// missing rate being no reason to hide the number). Whole dollars or yuan
|
||
// above 100, cents above 1, else thousandths, so a fraction of a cent
|
||
// still shows as something.
|
||
func FormatCost(amountUSD float64, currency string, rate float64) string {
|
||
amount, sign := amountUSD, "$"
|
||
if currency == "cny" && rate > 0 {
|
||
amount, sign = amountUSD*rate, "¥"
|
||
}
|
||
switch {
|
||
case amount >= 100:
|
||
return fmt.Sprintf("%s%.0f", sign, amount)
|
||
case amount >= 1:
|
||
return fmt.Sprintf("%s%.2f", sign, amount)
|
||
default:
|
||
return fmt.Sprintf("%s%.3f", sign, amount)
|
||
}
|
||
}
|
||
|
||
func (t *Totals) add(r Record, price *catalog.Price) {
|
||
t.Calls++
|
||
if r.Failed() {
|
||
t.Errors++
|
||
}
|
||
t.Input += r.Input
|
||
t.Output += r.Output
|
||
t.CacheRead += r.CacheRead
|
||
t.CacheWrite += r.CacheWrite
|
||
t.CacheWrite1h += r.CacheWrite1h
|
||
t.Reasoning += r.Reasoning
|
||
if r.TTFT > 0 && !r.Failed() {
|
||
t.Timed++
|
||
t.TTFT += r.TTFT
|
||
if n, w := r.Decode(); w > 0 {
|
||
t.DecodeMs += w
|
||
t.DecodeOut += n
|
||
}
|
||
}
|
||
if r.Input+r.Output == 0 {
|
||
return
|
||
}
|
||
if price == nil {
|
||
t.Unpriced++
|
||
return
|
||
}
|
||
t.Cost += r.CostAt(*price)
|
||
}
|
||
|
||
// Group is the share of one agent or model.
|
||
type Group struct {
|
||
ID string `json:"id"`
|
||
Provider string `json:"provider,omitempty"` // models only
|
||
Model string `json:"model,omitempty"`
|
||
ProviderKeyID string `json:"providerKeyId,omitempty"`
|
||
ProviderKeyName string `json:"providerKeyName,omitempty"`
|
||
// Account is the subscription account of an Accounts group: "" for
|
||
// the calls of an account's provider whose record names none
|
||
Account string `json:"account,omitempty"`
|
||
CallerKeyID string `json:"callerKeyId,omitempty"`
|
||
CallerKeyName string `json:"callerKeyName,omitempty"`
|
||
// Host is where the calls went, when the provider's id has gone to
|
||
// more than one place, or elsewhere than the provider goes now: its
|
||
// calls are then told apart by it, not summed under the id.
|
||
Host string `json:"host,omitempty"`
|
||
// Agent is the agent a session is of (sessions only).
|
||
Agent string `json:"agent,omitempty"`
|
||
Totals
|
||
}
|
||
|
||
// who is where a subscription's calls went as the account signed in
|
||
// ("dee@example.com" of "api.factory.ai as dee@example.com"), the host
|
||
// alone for the rest. The account is what tells one place from another: a
|
||
// built-in moved onto its plugin sends the same account's calls through
|
||
// plugin://…, which isn't another place.
|
||
func who(where string) string {
|
||
if i := strings.LastIndex(where, " as "); i >= 0 {
|
||
return where[i+4:]
|
||
}
|
||
return where
|
||
}
|
||
|
||
// Account is the subscription account that answered the call: the one the
|
||
// record names, or, in a record written before it named one, the account
|
||
// its Host said the call went out as then ("chatgpt.com as dee@example.com")
|
||
// — what the record itself says, never today's sign-in. "" when neither
|
||
// tells (#557).
|
||
func (r Record) Account() string {
|
||
if r.ProviderAccount != "" {
|
||
return r.ProviderAccount
|
||
}
|
||
if i := strings.LastIndex(r.Host, " as "); i >= 0 {
|
||
return r.Host[i+4:]
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// Point is one bar of the timeline.
|
||
type Point struct {
|
||
Label string `json:"label"`
|
||
Time time.Time `json:"time"`
|
||
Totals
|
||
}
|
||
|
||
// Summary is a period, summed up.
|
||
type Summary struct {
|
||
Period Period `json:"period"`
|
||
Since time.Time `json:"since"`
|
||
Totals
|
||
Bucket string `json:"bucket"` // hour | day | week
|
||
Series []Point `json:"series"`
|
||
Agents []Group `json:"agents"`
|
||
Models []Group `json:"models"`
|
||
ProviderKeys []Group `json:"providerKeys"`
|
||
// Accounts are the subscription accounts' calls, by provider and the
|
||
// account that answered (#557)
|
||
Accounts []Group `json:"accounts"`
|
||
CallerKeys []Group `json:"callerKeys"`
|
||
// Sessions are the calls that named their session, by session.
|
||
Sessions []Group `json:"sessions"`
|
||
}
|
||
|
||
// Summarize caches the four periods over an indexed log snapshot. Callers
|
||
// receive their own result slices, without retaining historical Records.
|
||
func Summarize(p Period) Summary {
|
||
return indexedSummary(p, Clock())
|
||
}
|
||
|
||
func summarize(p Period, now time.Time, recs []Record) Summary {
|
||
var first time.Time
|
||
keys := map[string]bool{}
|
||
for _, r := range recs {
|
||
if !r.IsRejected() && (first.IsZero() || r.Time.Before(first)) {
|
||
first = r.Time
|
||
}
|
||
if r.ProviderKeyID != "" {
|
||
keys[r.Provider] = true
|
||
}
|
||
}
|
||
return summarizeFrom(p, now, first, keys, func(fn func(Record)) {
|
||
for _, r := range recs {
|
||
fn(r)
|
||
}
|
||
})
|
||
}
|
||
|
||
func summarizeFrom(p Period, now time.Time, first time.Time, historicalKeys map[string]bool, read func(func(Record))) Summary {
|
||
renamed := provider.Renamed()
|
||
normalizedKeys := map[string]bool{}
|
||
for id := range historicalKeys {
|
||
if next, ok := renamed[id]; ok {
|
||
id = next
|
||
}
|
||
normalizedKeys[id] = true
|
||
}
|
||
historicalKeys = normalizedKeys
|
||
visit := func(fn func(Record)) {
|
||
read(func(r Record) {
|
||
if next, ok := renamed[r.Provider]; ok {
|
||
r.Provider = next
|
||
}
|
||
fn(r)
|
||
})
|
||
}
|
||
s := Summary{Period: p, Bucket: "day", Agents: []Group{}, Models: []Group{}, ProviderKeys: []Group{}, Accounts: []Group{}, CallerKeys: []Group{}, Sessions: []Group{}, Series: []Point{}}
|
||
s.Since, s.Bucket, s.Series = timeline(p, now, first)
|
||
if p != Today && p != Week && p != Month {
|
||
s.Period = All
|
||
}
|
||
|
||
priceOf := pricer()
|
||
// the places each provider id went in the period, and goes now
|
||
hosts := map[string]map[string]bool{}
|
||
// the providers whose calls are told apart by account: those an
|
||
// account answered for in the period, and those signed in now
|
||
accountProviders := map[string]bool{}
|
||
visit(func(r Record) {
|
||
if r.IsRejected() {
|
||
return
|
||
}
|
||
if r.Account() != "" && !r.Time.Before(s.Since) {
|
||
accountProviders[r.Provider] = true
|
||
}
|
||
if r.Host != "" && !r.Time.Before(s.Since) {
|
||
if hosts[r.Provider] == nil {
|
||
hosts[r.Provider] = map[string]bool{}
|
||
}
|
||
hosts[r.Provider][who(r.Host)] = true
|
||
}
|
||
})
|
||
goesNow := map[string]string{}
|
||
keyProviders := map[string]bool{}
|
||
for _, p := range provider.All() {
|
||
goesNow[p.ID] = who(p.Where())
|
||
keyProviders[p.ID] = p.Account == nil && p.Key != ""
|
||
if p.Account != nil {
|
||
accountProviders[p.ID] = true
|
||
}
|
||
}
|
||
for id := range historicalKeys {
|
||
keyProviders[id] = true
|
||
}
|
||
agents := map[string]*Group{}
|
||
models := map[string]*Group{}
|
||
keys := map[string]*Group{}
|
||
accounts := map[string]*Group{}
|
||
callerKeys := map[string]*Group{}
|
||
sessions := map[string]*Group{}
|
||
visit(func(r Record) {
|
||
if r.IsRejected() {
|
||
return
|
||
}
|
||
t := r.Time.In(now.Location())
|
||
if t.Before(s.Since) {
|
||
return
|
||
}
|
||
pr := priceOf(r)
|
||
s.Totals.add(r, pr)
|
||
i := bucketIndex(s.Bucket, s.Since, t)
|
||
if i >= 0 && i < len(s.Series) {
|
||
s.Series[i].add(r, pr)
|
||
}
|
||
id := AgentOf(r.Agent) // one kept before magpie knew the agent by name
|
||
a := agents[id]
|
||
if a == nil {
|
||
a = &Group{ID: id}
|
||
agents[id] = a
|
||
}
|
||
a.add(r, pr)
|
||
k, host := r.Provider+"/"+r.Model, ""
|
||
if w := who(r.Host); w != "" && (len(hosts[r.Provider]) > 1 || w != goesNow[r.Provider]) {
|
||
k, host = k+" @ "+w, w
|
||
}
|
||
m := models[k]
|
||
if m == nil {
|
||
m = &Group{ID: k, Provider: r.Provider, Model: r.Model, Host: host}
|
||
models[k] = m
|
||
}
|
||
m.add(r, pr)
|
||
if r.CallerKeyID != "" {
|
||
g := callerKeys[r.CallerKeyID]
|
||
if g == nil {
|
||
g = &Group{ID: r.CallerKeyID, CallerKeyID: r.CallerKeyID}
|
||
callerKeys[r.CallerKeyID] = g
|
||
}
|
||
g.CallerKeyName = r.CallerKeyName
|
||
g.add(r, pr)
|
||
}
|
||
if keyProviders[r.Provider] {
|
||
id := r.Provider + "#" + r.ProviderKeyID
|
||
g := keys[id]
|
||
if g == nil {
|
||
g = &Group{ID: id, Provider: r.Provider, ProviderKeyID: r.ProviderKeyID}
|
||
keys[id] = g
|
||
}
|
||
g.ProviderKeyName = r.ProviderKeyName
|
||
g.add(r, pr)
|
||
}
|
||
if accountProviders[r.Provider] {
|
||
who := r.Account()
|
||
id := r.Provider + "@" + who
|
||
g := accounts[id]
|
||
if g == nil {
|
||
g = &Group{ID: id, Provider: r.Provider, Account: who}
|
||
accounts[id] = g
|
||
}
|
||
g.add(r, pr)
|
||
}
|
||
if r.Session != "" {
|
||
g := sessions[id+"|"+r.Session]
|
||
if g == nil {
|
||
g = &Group{ID: r.Session, Agent: id}
|
||
sessions[id+"|"+r.Session] = g
|
||
}
|
||
g.add(r, pr)
|
||
}
|
||
})
|
||
for _, g := range agents {
|
||
s.Agents = append(s.Agents, *g)
|
||
}
|
||
for _, g := range models {
|
||
s.Models = append(s.Models, *g)
|
||
}
|
||
byTokens := func(gs []Group) {
|
||
sort.SliceStable(gs, func(i, j int) bool {
|
||
if a, b := gs[i].Tokens(), gs[j].Tokens(); a != b {
|
||
return a > b
|
||
}
|
||
if gs[i].Calls != gs[j].Calls {
|
||
return gs[i].Calls > gs[j].Calls
|
||
}
|
||
return gs[i].ID < gs[j].ID
|
||
})
|
||
}
|
||
byTokens(s.Agents)
|
||
byTokens(s.Models)
|
||
for _, g := range keys {
|
||
s.ProviderKeys = append(s.ProviderKeys, *g)
|
||
}
|
||
byTokens(s.ProviderKeys)
|
||
for _, g := range accounts {
|
||
s.Accounts = append(s.Accounts, *g)
|
||
}
|
||
byTokens(s.Accounts)
|
||
for _, g := range callerKeys {
|
||
s.CallerKeys = append(s.CallerKeys, *g)
|
||
}
|
||
byTokens(s.CallerKeys)
|
||
for _, g := range sessions {
|
||
s.Sessions = append(s.Sessions, *g)
|
||
}
|
||
byTokens(s.Sessions)
|
||
return s
|
||
}
|
||
|
||
// ---- last seen --------------------------------------------------------------
|
||
|
||
// seen is when each agent's latest request reached the gateway in this
|
||
// process — at its start, where a record is written only once it is answered.
|
||
var seen sync.Map // agent id → time.Time
|
||
|
||
// Via is where the gateway sent a session's calls: one model at one
|
||
// reasoning effort, how often and how much.
|
||
type Via struct {
|
||
Provider string `json:"provider"`
|
||
Model string `json:"model"`
|
||
Effort string `json:"effort,omitempty"`
|
||
Calls int `json:"calls"`
|
||
Tokens int `json:"tokens"` // in and out, as Totals counts them
|
||
Last time.Time `json:"last"`
|
||
}
|
||
|
||
// Vias is what the gateway sent each session's calls to since a time, by
|
||
// agent id and the session's id ("codex|<id>"), the most calls first.
|
||
func Vias(since time.Time) map[string][]Via {
|
||
out := map[string][]Via{}
|
||
readLogSnapshot().visit(since, func(r Record) {
|
||
if r.IsRejected() || r.Session == "" || r.Model == "" {
|
||
return
|
||
}
|
||
k := AgentOf(r.Agent) + "|" + r.Session
|
||
vs := out[k]
|
||
i := slices.IndexFunc(vs, func(v Via) bool { return v.Provider == r.Provider && v.Model == r.Model && v.Effort == r.Effort })
|
||
if i < 0 {
|
||
vs, i = append(vs, Via{Provider: r.Provider, Model: r.Model, Effort: r.Effort}), len(vs)
|
||
}
|
||
vs[i].Calls++
|
||
vs[i].Tokens += r.Input + r.Output
|
||
if r.Time.After(vs[i].Last) {
|
||
vs[i].Last = r.Time
|
||
}
|
||
out[k] = vs
|
||
})
|
||
for _, vs := range out {
|
||
sort.SliceStable(vs, func(i, j int) bool { return vs[i].Calls > vs[j].Calls })
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Saw notes a request from an agent arriving now.
|
||
func Saw(agent string) { seen.Store(agent, time.Now()) }
|
||
|
||
// LastSeen is when a request from the agent last reached the gateway: this
|
||
// process's own, else the newest in the log's last stretch. Zero if none.
|
||
func LastSeen(agent string) time.Time {
|
||
var last time.Time
|
||
if t, ok := seen.Load(agent); ok {
|
||
last = t.(time.Time)
|
||
}
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
f, err := os.Open(Path())
|
||
if err != nil {
|
||
return last
|
||
}
|
||
defer f.Close()
|
||
const tail = 256 << 10
|
||
if st, err := f.Stat(); err == nil && st.Size() > tail {
|
||
f.Seek(st.Size()-tail, 0)
|
||
}
|
||
sc := bufio.NewScanner(f)
|
||
sc.Buffer(make([]byte, 64<<10), 1<<20)
|
||
for sc.Scan() {
|
||
var r Record
|
||
if json.Unmarshal(sc.Bytes(), &r) == nil && r.Agent == agent && r.Via == "" && r.Time.After(last) {
|
||
last = r.Time
|
||
}
|
||
}
|
||
return last
|
||
}
|