diff --git a/accounts_cli.go b/accounts_cli.go
index ed1d13de..7dd3c16a 100644
--- a/accounts_cli.go
+++ b/accounts_cli.go
@@ -169,6 +169,7 @@ func accountsCmd(args []string) error {
for _, r := range rows {
width = max(width, len(r.User))
}
+ now := time.Now() // one instant for every window's reset time
for _, r := range rows {
mark := " "
switch {
@@ -183,7 +184,7 @@ func accountsCmd(args []string) error {
}
line := fmt.Sprintf("%s%-7s %-*s %s", mark, r.Agent, width, r.User, muted.Render(fmt.Sprintf("%-8s", plan)))
for _, w := range r.Windows {
- line += " " + quotaCell(w)
+ line += " " + quotaCell(w, now)
}
if r.Balance != "" {
line += " " + balanceCell(r.Balance, r.Agent, r.User)
@@ -276,18 +277,18 @@ func accountRows(ls []provider.Login, now time.Time) []accountRow {
return rows
}
-// quotaCell is one window in a line: "5h 42% ↻2h13m".
-func quotaCell(w quotaSpan) string {
+// quotaCell is one window in a line as of now: "5h 42% ↻2h13m 14:13".
+func quotaCell(w quotaSpan, now time.Time) string {
name := strings.NewReplacer(" hours", "h", " hour", "h", " days", "d", " day", "d", " · ", " ").Replace(w.Name)
cell := fmt.Sprintf("%s %.0f%%", name, w.Used)
if w.Display != "" {
cell += " (" + w.Display + ")"
}
if w.ResetsAt != nil {
- if !w.ResetsAt.After(time.Now()) {
+ if !w.ResetsAt.After(now) {
cell += muted.Render(" · reset time passed " + w.ResetsAt.Local().Format("Jan 2 15:04"))
} else {
- cell += muted.Render(" ↻" + untilShort(time.Until(*w.ResetsAt)) + " " + provider.ResetClock(*w.ResetsAt, time.Now()))
+ cell += muted.Render(" ↻" + untilShort(w.ResetsAt.Sub(now)) + " " + provider.ResetClock(*w.ResetsAt, now))
}
}
return cell
diff --git a/accounts_cli_test.go b/accounts_cli_test.go
index fb03a2e8..19e20e59 100644
--- a/accounts_cli_test.go
+++ b/accounts_cli_test.go
@@ -121,14 +121,21 @@ func TestUntilShort(t *testing.T) {
}
func TestQuotaCell(t *testing.T) {
- at := time.Now().Add(2*time.Hour + 13*time.Minute + 30*time.Second)
- got := quotaCell(quotaSpan{Name: "5 hours", Used: 42, ResetsAt: &at})
- if !strings.HasPrefix(got, "5h 42%") || !strings.Contains(got, "↻2h13m "+provider.ResetClock(at, time.Now())) {
+ // a fixed now, so the reset reads the same day whenever the test runs
+ now := time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local)
+ at := now.Add(2*time.Hour + 13*time.Minute + 30*time.Second)
+ got := quotaCell(quotaSpan{Name: "5 hours", Used: 42, ResetsAt: &at}, now)
+ if !strings.HasPrefix(got, "5h 42%") || !strings.Contains(got, "↻2h13m 14:13") {
t.Fatalf("%q", got)
}
+ // a reset on the next day reads "tomorrow" against the same now
+ next := now.Add(14*time.Hour + 13*time.Minute + 30*time.Second)
+ if got := quotaCell(quotaSpan{Name: "7 days", Used: 42, ResetsAt: &next}, now); !strings.Contains(got, "↻14h13m tomorrow 02:13") {
+ t.Fatalf("tomorrow: %q", got)
+ }
// a pool's own window comes in named with its pool (PooledWindows) and
// reads compactly, without the " · "
- pooled := quotaCell(quotaSpan{Name: "Gemini · 7 days", Pool: "Gemini", Used: 80})
+ pooled := quotaCell(quotaSpan{Name: "Gemini · 7 days", Pool: "Gemini", Used: 80}, now)
if !strings.HasPrefix(pooled, "Gemini 7d 80%") {
t.Fatalf("pooled: %q", pooled)
}
diff --git a/internal/davsync/archive_test.go b/internal/davsync/archive_test.go
index 53821d21..2822a230 100644
--- a/internal/davsync/archive_test.go
+++ b/internal/davsync/archive_test.go
@@ -76,11 +76,13 @@ func TestRequestArchiveToS3(t *testing.T) {
// on
settings.Save(settings.Settings{RequestArchive: true})
- date := time.Now().UTC().Format("2006-01-02")
id := post()
if id == "" {
t.Fatal("no archive id")
}
+ // the date is the one the call carries, in UTC, not the clock read
+ // again here, which a UTC midnight can fall between
+ date := s.Recent()[0].Time.UTC().Format("2006-01-02")
key := "team x+y/magpie/archive/" + date + "/" + id + ".json"
deadline := time.Now().Add(5 * time.Second)
for archived() == 0 && time.Now().Before(deadline) {
diff --git a/internal/davsync/usage.go b/internal/davsync/usage.go
index aa5340a6..0ec9468c 100644
--- a/internal/davsync/usage.go
+++ b/internal/davsync/usage.go
@@ -102,7 +102,7 @@ func shareUsage(ctx context.Context, c Config, st *state, force bool) {
func shareWith(ctx context.Context, c Config, u *usageState, fs files) error {
id, name := usage.Computer()
- now := time.Now()
+ now := usage.Clock()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
first := today.AddDate(0, 0, 1-usage.SharedDays)
from := first.Format(time.DateOnly)
diff --git a/internal/davsync/usage_share_test.go b/internal/davsync/usage_share_test.go
index 319e1ba9..22b0b2bd 100644
--- a/internal/davsync/usage_share_test.go
+++ b/internal/davsync/usage_share_test.go
@@ -112,7 +112,12 @@ func TestUsageSharedBetweenComputers(t *testing.T) {
t.Helper()
return usage.QueryPage(usage.All, f, 0, 100)
}
- now := time.Now()
+ // the usage clock held at noon, so that a's calls of today are of one
+ // day whenever the test runs
+ now := time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local)
+ oldClock := usage.Clock
+ usage.Clock = func() time.Time { return now }
+ t.Cleanup(func() { usage.Clock = oldClock })
// a: two calls today, one two days ago, and one another magpie passed
// on to it, which that one counts itself
diff --git a/internal/gateway/archive_test.go b/internal/gateway/archive_test.go
index 75151876..bbe77810 100644
--- a/internal/gateway/archive_test.go
+++ b/internal/gateway/archive_test.go
@@ -13,7 +13,6 @@ import (
"strings"
"sync"
"testing"
- "time"
"github.com/yetone/magpie/internal/settings"
)
@@ -103,18 +102,20 @@ func TestArchiveOn(t *testing.T) {
b := &memBucket{objs: map[string][]byte{}}
archiveTo(t, b)
s := New()
- before := time.Now().UTC()
rec := archivePost(t, s)
id := rec.Header().Get(ArchiveHeader)
if !regexp.MustCompile(`^\d{6}-[0-9a-f]{16}$`).MatchString(id) {
t.Fatalf("id %q", id)
}
- name := before.Format("2006-01-02") + "/" + id
+ // the date is the one the call carries, in UTC, not the clock read
+ // again here, which a UTC midnight can fall between
c := s.Recent()[0]
+ date := c.Time.UTC().Format("2006-01-02")
+ name := date + "/" + id
if c.Archive != name || c.wire != nil {
t.Fatalf("call archive %q, want %q", c.Archive, name)
}
- if p, ok := ArchiveName(before.Format("2006-01-02"), id); !ok || p != "archive/"+name+".json" {
+ if p, ok := ArchiveName(date, id); !ok || p != "archive/"+name+".json" {
t.Fatalf("ArchiveName %q %v", p, ok)
}
data, ok := b.objs["archive/"+name+".json"]
diff --git a/internal/gateway/history.go b/internal/gateway/history.go
index 348bb322..e091a188 100644
--- a/internal/gateway/history.go
+++ b/internal/gateway/history.go
@@ -52,6 +52,10 @@ type counted struct {
const dayForm = "2006-01-02"
+// historyClock is the time saveRoute prunes the days by: which day is today,
+// and which are over or too old. A variable for the tests.
+var historyClock = time.Now
+
// saveRoute adds a done route to its day. Errors are swallowed: keeping
// the history must never break a call.
func saveRoute(r Route) {
@@ -72,9 +76,9 @@ func saveRoute(r Route) {
}
f.Write(append(b, '\n'))
f.Close()
- if time.Since(history.pruned) > time.Hour {
- history.pruned = time.Now()
- pruneHistory(dir, time.Now())
+ if now := historyClock(); now.Sub(history.pruned) > time.Hour {
+ history.pruned = now
+ pruneHistory(dir, now)
}
}
diff --git a/internal/gateway/history_test.go b/internal/gateway/history_test.go
index 0a3d08a0..6933dba1 100644
--- a/internal/gateway/history_test.go
+++ b/internal/gateway/history_test.go
@@ -9,9 +9,20 @@ import (
"time"
)
+// historyNoon holds the history's clock at noon of a past day and returns
+// it: a test's routes, a second apart, then fall on that one day, and
+// saveRoute prunes by it rather than by the real day, which may have
+// turned since the test began.
+func historyNoon(t *testing.T) time.Time {
+ now := time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local)
+ historyClock = func() time.Time { return now }
+ t.Cleanup(func() { historyClock = time.Now })
+ return now
+}
+
func TestHistoryKeepsSessionAndTokenTiers(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
- now := time.Now()
+ now := historyNoon(t)
records := routeUsage("relay", "m", Usage{Input: 20, Output: 5, CacheRead: 100, CacheWrite: 40})
saveRoute(Route{ID: 1, Time: now, Session: "conversation", ParentSession: "parent-conversation", Kind: "thread_title", Usage: records, Done: true})
saveRoute(Route{ID: 2, Time: now, Tokens: 1000, Done: true}) // legacy route
@@ -29,7 +40,7 @@ func TestHistoryKeepsSessionAndTokenTiers(t *testing.T) {
// still read, and a day too old or past the size kept dropped.
func TestHistoryKeepsDaysAndDropsOld(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
- now := time.Now()
+ now := historyNoon(t)
yday := now.AddDate(0, 0, -1)
saveRoute(Route{ID: 1, Time: yday, Model: "a", Done: true})
saveRoute(Route{ID: 2, Time: now, Model: "b", Done: true})
diff --git a/internal/gateway/key_limit.go b/internal/gateway/key_limit.go
index 85d86391..fac71a27 100644
--- a/internal/gateway/key_limit.go
+++ b/internal/gateway/key_limit.go
@@ -14,6 +14,11 @@ import (
"github.com/yetone/magpie/internal/usage"
)
+// limitClock is the time a gateway key's limit is checked at: the window
+// a request counts in and when its 429 says the key resets. A variable for
+// the tests.
+var limitClock = time.Now
+
// keyLimited holds a gateway key's requests to its limit (#585, see
// package budget for the rules): one that would go over is refused with a
// 429 before any provider is asked, saying the key, the limit and when it
@@ -37,7 +42,7 @@ func keyLimited(next http.Handler) http.Handler {
r.Body = io.NopCloser(bytes.NewReader(b))
size, model = int64(len(b)), budget.ModelOf(b)
}
- now := time.Now()
+ now := limitClock()
release, refused := budget.Reserve(who, size, model, now)
if refused != nil {
msg := refused.Error()
@@ -78,6 +83,6 @@ func (s *Server) keyLimit(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"limited": false, "key": who.KeyName})
return
}
- st := budget.Of(access.Key{ID: who.KeyID, Name: who.KeyName, Limit: who.Limit}, time.Now())
+ st := budget.Of(access.Key{ID: who.KeyID, Name: who.KeyName, Limit: who.Limit}, limitClock())
writeJSON(w, 200, map[string]any{"limited": true, "key": who.KeyName, "limit": st})
}
diff --git a/internal/gateway/key_limit_test.go b/internal/gateway/key_limit_test.go
index 5d546921..cc9179ab 100644
--- a/internal/gateway/key_limit_test.go
+++ b/internal/gateway/key_limit_test.go
@@ -44,6 +44,18 @@ func setLimit(t *testing.T, id string, l *access.Limit) {
}
}
+// pinLimitClock holds the gateway's limit checks at a fixed past noon, so
+// a test can't see its calls split across a midnight, a Monday or a 1st.
+// The calls are still recorded at the real time, later than that, and a
+// window counts every call from its start, so they all count in its window.
+func pinLimitClock(t *testing.T) time.Time {
+ t.Helper()
+ at := time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local)
+ limitClock = func() time.Time { return at }
+ t.Cleanup(func() { limitClock = time.Now })
+ return at
+}
+
// A key past its limit is refused before any provider is asked, with a
// 429 that names the key, the limit and when it resets; another key and a
// keyless request from this computer go on; what was used survives a
@@ -51,6 +63,7 @@ func setLimit(t *testing.T, id string, l *access.Limit) {
func TestKeyLimitRefusesBeforeUpstream(t *testing.T) {
fresh(t)
budget.Forget()
+ now := pinLimitClock(t)
calls := limitedUpstream(t, 0)
keys, secrets := newCaller(t, "Capped", "Other")
setLimit(t, keys[0].ID, &access.Limit{Period: "day", Tokens: 700})
@@ -88,7 +101,9 @@ func TestKeyLimitRefusesBeforeUpstream(t *testing.T) {
if e.Error.Type != "rate_limit_error" || !strings.Contains(e.Error.Message, `"Capped"`) || !strings.Contains(e.Error.Message, "700 of its 700") || !strings.Contains(e.Error.Message, "resets at") {
t.Fatal("refusal says", w.Body.String())
}
- if ra, _ := strconv.Atoi(w.Header().Get("Retry-After")); ra <= 0 || ra > 86400+3600 || w.Header().Get("X-Should-Retry") != "false" {
+ // it resets at the next midnight after the check
+ _, reset := access.Window("day", now)
+ if ra := w.Header().Get("Retry-After"); ra != strconv.Itoa(int(reset.Sub(now).Seconds())) || w.Header().Get("X-Magpie-Limit-Reset") != reset.Format(time.RFC3339) || w.Header().Get("X-Should-Retry") != "false" {
t.Fatal("headers", w.Header())
}
rec := lastUsage(t)
@@ -116,7 +131,7 @@ func TestKeyLimitRefusesBeforeUpstream(t *testing.T) {
if w := call(secrets[0], "/v1/chat/completions", chatReq); w.Code != 429 {
t.Fatal("a restart forgot what the key used", w.Code)
}
- st := budget.Of(access.Key{ID: keys[0].ID, Limit: &access.Limit{Period: "day", Tokens: 700}}, time.Now())
+ st := budget.Of(access.Key{ID: keys[0].ID, Limit: &access.Limit{Period: "day", Tokens: 700}}, now)
if st == nil || st.Tokens != 700 || st.Calls != 2 || !st.Spent || st.TokensLeft != 0 {
t.Fatalf("status %+v", st)
}
@@ -139,7 +154,8 @@ func TestKeyLimitRefusesBeforeUpstream(t *testing.T) {
Limit budget.Status
}
json.Unmarshal(lw.Body.Bytes(), &got)
- if !got.Limited || got.Limit.Tokens != 1400 || got.Limit.TokensLeft != 3600 || got.Limit.Period != "week" {
+ monday, _ := access.Window("week", now)
+ if !got.Limited || got.Limit.Tokens != 1400 || got.Limit.TokensLeft != 3600 || got.Limit.Period != "week" || !got.Limit.Start.Equal(monday) {
t.Fatal("limit endpoint", lw.Body.String())
}
}
@@ -150,6 +166,7 @@ func TestKeyLimitRefusesBeforeUpstream(t *testing.T) {
func TestKeyLimitHoldsParallelRequests(t *testing.T) {
fresh(t)
budget.Forget()
+ now := pinLimitClock(t)
calls := limitedUpstream(t, 80*time.Millisecond)
keys, secrets := newCaller(t, "Busy")
setLimit(t, keys[0].ID, &access.Limit{Period: "month", Tokens: 2000})
@@ -179,7 +196,7 @@ func TestKeyLimitHoldsParallelRequests(t *testing.T) {
})
}
wg.Wait()
- st := budget.Of(access.Key{ID: keys[0].ID, Limit: &access.Limit{Period: "month", Tokens: 2000}}, time.Now())
+ st := budget.Of(access.Key{ID: keys[0].ID, Limit: &access.Limit{Period: "month", Tokens: 2000}}, now)
if st.Tokens > 2000+350 || refused.Load() == 0 || ok.Load()+refused.Load() != 20 {
t.Fatalf("20 at once: %d let in, %d refused, %d tokens used of 2000, %d upstream calls", ok.Load(), refused.Load(), st.Tokens, calls.Load())
}
@@ -196,12 +213,13 @@ func TestKeyLimitHoldsParallelRequests(t *testing.T) {
func TestKeyLimitWindowResets(t *testing.T) {
fresh(t)
budget.Forget()
+ now := pinLimitClock(t)
limitedUpstream(t, 0)
keys, secrets := newCaller(t, "Daily")
lim := &access.Limit{Period: "day", Tokens: 350}
setLimit(t, keys[0].ID, lim)
// yesterday's call
- budget.Append(usage.Record{Time: time.Now().AddDate(0, 0, -1), Provider: "plan", Model: "m1", Input: 5000, Status: 200, CallerKeyID: keys[0].ID})
+ budget.Append(usage.Record{Time: now.AddDate(0, 0, -1), Provider: "plan", Model: "m1", Input: 5000, Status: 200, CallerKeyID: keys[0].ID})
h := New().Handler()
call := func() int {
r := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(chatReq))
@@ -217,9 +235,11 @@ func TestKeyLimitWindowResets(t *testing.T) {
t.Fatal("spent key let in")
}
who := access.Identity{KeyID: keys[0].ID, KeyName: "Daily", Limit: lim}
- if _, refused := budget.Reserve(who, 10, "", time.Now()); refused == nil {
+ if _, refused := budget.Reserve(who, 10, "", now); refused == nil {
t.Fatal("spent today")
}
+ // the calls were recorded at the real time, so the next window that has
+ // none of them is the one after the real now
_, reset := access.Window("day", time.Now())
release, refused := budget.Reserve(who, 10, "", reset.Add(time.Minute))
if refused != nil {
diff --git a/internal/gateway/remote_group_served_test.go b/internal/gateway/remote_group_served_test.go
index 30dddd98..b6cee520 100644
--- a/internal/gateway/remote_group_served_test.go
+++ b/internal/gateway/remote_group_served_test.go
@@ -132,7 +132,7 @@ func TestRemoteMagpieGroupLedgerRow(t *testing.T) {
// routed; a real swap kept beside it is still one.
func TestRemoteMagpieGroupHistory(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
- now := time.Now()
+ now := historyNoon(t)
saveRoute(Route{ID: 1, Time: now, Model: "office/group/auto-deepseek-v4-1-flash", Done: true, Status: 200,
Tries: []Try{{ID: "office", Model: "group/auto-deepseek-v4-1-flash", Done: true, Status: 200, Served: "deepseek/deepseek-v4.1-flash", Swapped: true}},
Served: "deepseek/deepseek-v4.1-flash", Swapped: true})
diff --git a/internal/gui/usage.go b/internal/gui/usage.go
index 7f72e3f2..8fbe3def 100644
--- a/internal/gui/usage.go
+++ b/internal/gui/usage.go
@@ -116,12 +116,13 @@ func periodOf(s string) usage.Period {
return usage.Month
}
-// csvStamp names the selected day, or the period when no day is selected.
-func csvStamp(p usage.Period, day string) string {
+// csvStamp names the selected day, or, when no day is selected, the period
+// and the day of now, the moment its rows were read at.
+func csvStamp(p usage.Period, day string, now time.Time) string {
if _, err := time.Parse(time.DateOnly, day); err == nil {
return "magpie-requests-day-" + day
}
- return "magpie-requests-" + string(p) + "-" + time.Now().Format(time.DateOnly)
+ return "magpie-requests-" + string(p) + "-" + now.Format(time.DateOnly)
}
func ledgerFilter(q url.Values) usage.Filter {
@@ -397,23 +398,26 @@ func usageRoutes(mux *http.ServeMux, w Windows) {
mux.HandleFunc("GET /api/usage/requests.csv", func(rw http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
p := periodOf(q.Get("period"))
- rows, _, _ := usage.Ledger(p, ledgerFilter(q))
+ // one moment for the rows and the day in the file's name
+ now := usage.Clock()
+ rows := usage.LedgerOfAt(p, ledgerFilter(q), now).Rows
rw.Header().Set("Content-Type", "text/csv; charset=utf-8")
- rw.Header().Set("Content-Disposition", `attachment; filename="`+csvStamp(p, q.Get("day"))+`.csv"`)
+ rw.Header().Set("Content-Disposition", `attachment; filename="`+csvStamp(p, q.Get("day"), now)+`.csv"`)
usage.WriteCSV(rw, rows)
})
// the rows the ledger shows, all its pages, as a CSV in Downloads
mux.HandleFunc("POST /api/usage/requests/export", func(rw http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
p := periodOf(q.Get("period"))
- rows, _, _ := usage.Ledger(p, ledgerFilter(q))
+ now := usage.Clock()
+ rows := usage.LedgerOfAt(p, ledgerFilter(q), now).Rows
var b bytes.Buffer
if err := usage.WriteCSV(&b, rows); err != nil {
fail(rw, err)
return
}
dir := downloads()
- stamp := csvStamp(p, q.Get("day"))
+ stamp := csvStamp(p, q.Get("day"), now)
name := filepath.Join(dir, stamp+".csv")
for i := 2; ; i++ { // never over an earlier one
if _, err := os.Stat(name); err != nil {
diff --git a/internal/gui/usage_purpose_test.go b/internal/gui/usage_purpose_test.go
index fc6301df..e62db0a6 100644
--- a/internal/gui/usage_purpose_test.go
+++ b/internal/gui/usage_purpose_test.go
@@ -21,7 +21,7 @@ func TestUsagePurposeRoutes(t *testing.T) {
if err := os.MkdirAll(filepath.Join(home, "Downloads"), 0o755); err != nil {
t.Fatal(err)
}
- start := usage.Today.Since(time.Now()).Add(time.Second)
+ start := usage.Today.Since(holdUsageClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))).Add(time.Second)
for i, kind := range []string{"thread_title", "thread_title_reconsideration", "review", "", "future_kind", "title_generation"} {
usage.Append(usage.Record{Time: start.Add(time.Duration(i) * time.Second), Agent: "codex", Provider: "relay", Model: "m", Kind: kind, Input: 10, Output: 2, Status: 200})
}
diff --git a/internal/gui/usage_test.go b/internal/gui/usage_test.go
index ef543bc6..4385e0ae 100644
--- a/internal/gui/usage_test.go
+++ b/internal/gui/usage_test.go
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"strings"
+ "sync/atomic"
"testing"
"time"
@@ -16,12 +17,23 @@ import (
"github.com/yetone/magpie/internal/usage"
)
+// holdUsageClock stops usage.Clock at at for the test and gives at back, for
+// the test to stamp its calls by: midnight then never falls between a call
+// and the period it is asked for in.
+func holdUsageClock(t *testing.T, at time.Time) time.Time {
+ t.Helper()
+ old := usage.Clock
+ usage.Clock = func() time.Time { return at }
+ t.Cleanup(func() { usage.Clock = old })
+ return at
+}
+
func TestUsageLedgerDayRoutes(t *testing.T) {
home := sandboxHome(t)
if err := os.MkdirAll(filepath.Join(home, "Downloads"), 0o755); err != nil {
t.Fatal(err)
}
- start := usage.Today.Since(time.Now()).AddDate(0, 0, -1)
+ start := usage.Today.Since(holdUsageClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))).AddDate(0, 0, -1)
for i, at := range []time.Time{start.Add(-time.Second), start, start.Add(12 * time.Hour), start.AddDate(0, 0, 1)} {
usage.Append(usage.Record{Time: at, Agent: "codex", Provider: "relay", Model: "m", Input: 10 + i, Output: 1, Status: 200})
}
@@ -86,8 +98,8 @@ func TestUsageLedgerRoutes(t *testing.T) {
if err := os.MkdirAll(filepath.Join(home, "Downloads"), 0o755); err != nil {
t.Fatal(err)
}
- y, m, d := time.Now().Date()
- at := time.Date(y, m, d, 0, 0, 1, 0, time.Local) // today, whenever the test runs
+ now := holdUsageClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
+ at := usage.Today.Since(now).Add(time.Second)
for i, r := range []usage.Record{
{RouteID: 123, Agent: "codex", Provider: "relay", Model: "gpt-6-sol", Requested: "sol", Served: "gpt-6-luna", Input: 10, Output: 1, Status: 200},
{Agent: "claude", Provider: "anthropic", Model: "claude-sonnet-5", Requested: "sonnet", Input: 20, Output: 2, Status: 200},
@@ -184,7 +196,7 @@ func TestUsageLedgerRoutes(t *testing.T) {
}
p1, n := export()
p2, _ := export()
- day := time.Now().Format("2006-01-02")
+ day := now.Format("2006-01-02")
if n != 2 || p1 != filepath.Join("~", "Downloads", "magpie-requests-today-"+day+".csv") || p2 != filepath.Join("~", "Downloads", "magpie-requests-today-"+day+"-2.csv") {
t.Fatalf("export: %s %s %d", p1, p2, n)
}
@@ -269,11 +281,12 @@ func TestUsageRequestContent(t *testing.T) {
// filters set. Calls without recorded routing are shown as local sessions.
func TestLedgerSingleSnapshotAndLocalSession(t *testing.T) {
sandboxHome(t)
+ now := holdUsageClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
before := usage.LogCalls
reads := 0
usage.LogCalls = func(time.Time) []sessions.Call {
reads++
- return []sessions.Call{{Time: time.Now(), Agent: "claude", Session: "s", Model: "claude-sonnet-5", Tokens: sessions.Tokens{Input: 10, Output: 2}}}
+ return []sessions.Call{{Time: now, Agent: "claude", Session: "s", Model: "claude-sonnet-5", Tokens: sessions.Tokens{Input: 10, Output: 2}}}
}
t.Cleanup(func() { usage.LogCalls = before })
l := ledgerPage(usage.Today, usage.Filter{Provider: usage.UnknownProvider, Agent: "claude"}, 0, 1)
@@ -292,9 +305,10 @@ func TestLedgerSingleSnapshotAndLocalSession(t *testing.T) {
// one model's speed at each provider reads apart (inaction on Discord).
func TestLedgerNamesModelAtProvider(t *testing.T) {
sandboxHome(t)
+ now := holdUsageClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
before := usage.LogCalls
usage.LogCalls = func(time.Time) []sessions.Call {
- return []sessions.Call{{Time: time.Now(), Agent: "claude", Session: "s", Model: "claude-sonnet-5", Tokens: sessions.Tokens{Input: 10, Output: 2}}}
+ return []sessions.Call{{Time: now, Agent: "claude", Session: "s", Model: "claude-sonnet-5", Tokens: sessions.Tokens{Input: 10, Output: 2}}}
}
t.Cleanup(func() { usage.LogCalls = before })
l := ledgerPage(usage.Today, usage.Filter{}, 0, 1)
@@ -305,10 +319,62 @@ func TestLedgerNamesModelAtProvider(t *testing.T) {
}
func TestCSVStamp(t *testing.T) {
+ now := time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local)
for _, day := range []string{"", "2026-02-30", "../../other", "2026-09-30\""} {
- want := "magpie-requests-7d-" + time.Now().Format(time.DateOnly)
- if got := csvStamp(usage.Period("7d"), day); got != want {
+ want := "magpie-requests-7d-2026-09-30"
+ if got := csvStamp(usage.Period("7d"), day, now); got != want {
t.Fatalf("%q: %s", day, got)
}
}
}
+
+// midnightUsageClock sets usage.Clock to read a tenth of a second before
+// midnight the first time and a tenth of a second after it from then on, as
+// when midnight falls while an answer is worked out. It is set again for
+// each answer.
+func midnightUsageClock(t *testing.T, midnight time.Time) {
+ t.Helper()
+ var read atomic.Bool
+ old := usage.Clock
+ usage.Clock = func() time.Time {
+ if read.Swap(true) {
+ return midnight.Add(100 * time.Millisecond)
+ }
+ return midnight.Add(-100 * time.Millisecond)
+ }
+ t.Cleanup(func() { usage.Clock = old })
+}
+
+// A period's CSV asked for as midnight falls is named for the day of the
+// calls in it, downloaded or saved to Downloads: its rows and its name are
+// read at one moment, not the rows of one day under the next day's name.
+func TestUsageCSVAtMidnight(t *testing.T) {
+ home := sandboxHome(t)
+ if err := os.MkdirAll(filepath.Join(home, "Downloads"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ midnight := time.Date(2026, 10, 1, 0, 0, 0, 0, time.Local)
+ usage.Append(usage.Record{Time: midnight.Add(-time.Minute), Agent: "codex", Provider: "relay", Model: "m", Input: 10, Output: 1, Status: 200})
+ mux := http.NewServeMux()
+ usageRoutes(mux, folderOnly{})
+ want := "magpie-requests-today-2026-09-30.csv"
+
+ midnightUsageClock(t, midnight)
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, httptest.NewRequest("GET", "/api/usage/requests.csv?period=today", nil))
+ rows, err := csv.NewReader(w.Body).ReadAll()
+ if got := w.Header().Get("Content-Disposition"); w.Code != 200 || err != nil || len(rows) != 2 || got != `attachment; filename="`+want+`"` {
+ t.Errorf("download: %d, %s with %d rows (%v), want %s with 2", w.Code, got, len(rows), err, want)
+ }
+
+ midnightUsageClock(t, midnight)
+ w = httptest.NewRecorder()
+ mux.ServeHTTP(w, httptest.NewRequest("POST", "/api/usage/requests/export?period=today", nil))
+ var out struct {
+ Path string
+ Rows int
+ }
+ if err := json.Unmarshal(w.Body.Bytes(), &out); w.Code != 200 || err != nil || out.Rows != 1 || out.Path != filepath.Join("~", "Downloads", want) {
+ t.Errorf("export: %d %s, want %s with 1 row", w.Code, w.Body, want)
+ }
+}
diff --git a/internal/gui/whatsnew_test.go b/internal/gui/whatsnew_test.go
index a0197d00..ebadfa46 100644
--- a/internal/gui/whatsnew_test.go
+++ b/internal/gui/whatsnew_test.go
@@ -224,6 +224,10 @@ func TestWhatsNewQuietAfterRestartToUpdate(t *testing.T) {
func TestWhatsNewTodayRoute(t *testing.T) {
home := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", home)
+ // one day for the post and the check, so a run across midnight agrees
+ oldToday := today
+ t.Cleanup(func() { today = oldToday })
+ today = func() string { return "2026-09-30" }
mux := http.NewServeMux()
whatsNewRoutes(mux)
post := func(body string) int {
diff --git a/internal/provider/zcode_client_test.go b/internal/provider/zcode_client_test.go
index 14fbadd4..ed11fe5a 100644
--- a/internal/provider/zcode_client_test.go
+++ b/internal/provider/zcode_client_test.go
@@ -86,6 +86,10 @@ func TestZCodeStartSentAsTheApp(t *testing.T) {
t.Setenv("LC_MESSAGES", "")
t.Setenv("TZ", "Asia/Shanghai")
t.Setenv("SHELL", "/bin/zsh")
+ // the day the context prefix gives, the same whenever the test runs
+ oldClock := zcodeClock
+ t.Cleanup(func() { zcodeClock = oldClock })
+ zcodeClock = func() time.Time { return time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local) }
jwt := zcodeTestJWT(time.Now().Add(24 * time.Hour))
u := newZCodeStartUpstream(t, jwt)
u.balance = zcodeActiveStart(time.Now(), "active")
@@ -187,8 +191,7 @@ func TestZCodeStartSentAsTheApp(t *testing.T) {
}
var prefix []block
json.Unmarshal(got.Messages[0].Content, &prefix)
- want := "As you answer the user's questions, you can use the following context:\n# currentDate\nToday's date is " +
- time.Now().Format("2006-01-02") + ".\n\n IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task."
+ want := "As you answer the user's questions, you can use the following context:\n# currentDate\nToday's date is 2026-09-30.\n\n IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task."
if got.Messages[0].Role != "user" || len(prefix) != 1 || prefix[0].Type != "text" || prefix[0].Text != want || prefix[0].Cache != nil {
t.Fatalf("the context prefix: %s", got.Messages[0].Content)
}
diff --git a/internal/provider/zcode_start.go b/internal/provider/zcode_start.go
index aa4852f2..5bcbc6fe 100644
--- a/internal/provider/zcode_start.go
+++ b/internal/provider/zcode_start.go
@@ -273,6 +273,10 @@ func zcodeTimezone() string {
return "UTC"
}
+// zcodeClock is the time a Start Plan request's context prefix gives the
+// day of (zcodeDateReminder); tests set it.
+var zcodeClock = time.Now
+
// zcodeStartRequest makes req, a request to the Start Plan, ZCode's own:
// its headers, and a model request's body shaped as ZCode's
// (zcodeStartBody) for an account on base.
@@ -281,7 +285,7 @@ func zcodeStartRequest(req *http.Request, base string, body []byte) {
if len(body) == 0 || !strings.HasSuffix(req.URL.Path, "/v1/messages") {
return
}
- nb := zcodeStartBody(body, zcodeStartProvider(base), time.Now())
+ nb := zcodeStartBody(body, zcodeStartProvider(base), zcodeClock())
req.Body = io.NopCloser(bytes.NewReader(nb))
req.ContentLength = int64(len(nb))
req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(nb)), nil }
diff --git a/internal/tui/pages.go b/internal/tui/pages.go
index e84d932b..0c8cf43d 100644
--- a/internal/tui/pages.go
+++ b/internal/tui/pages.go
@@ -786,7 +786,7 @@ func (m model) updateUsage(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
default:
return m, nil
}
- m.sum, m.direct = usage.Summarize(m.period), usage.Direct(m.period)
+ m.sum, m.direct = usage.Summaries(m.period)
return m, nil
}
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 638f76da..f7051a66 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -199,7 +199,7 @@ func (m *model) reload() {
m.gsel = clamp(m.gsel, len(g.Members)+len(g.Rules))
}
case pageUsage:
- m.sum, m.direct = usage.Summarize(m.period), usage.Direct(m.period)
+ m.sum, m.direct = usage.Summaries(m.period)
case pageSessions:
m.reloadSessions()
case pageLibrary:
diff --git a/internal/tui/usage_test.go b/internal/tui/usage_test.go
index 4c7eb91a..ef4f9a26 100644
--- a/internal/tui/usage_test.go
+++ b/internal/tui/usage_test.go
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"strings"
+ "sync/atomic"
"testing"
"time"
@@ -124,7 +125,12 @@ func TestUnlimitedQuotaCell(t *testing.T) {
// TUI).
func TestUsagePageShowsCallsNotThroughMagpie(t *testing.T) {
home(t)
- now := time.Now()
+ // the usage clock held at noon, so that the calls a minute or two
+ // before it are today's whenever the test runs
+ now := time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local)
+ oldClock := usage.Clock
+ usage.Clock = func() time.Time { return now }
+ t.Cleanup(func() { usage.Clock = oldClock })
usage.Append(usage.Record{Time: now.Add(-time.Minute), Agent: "claude", Provider: "relay", Model: "m", Input: 5, Status: 200})
old := usage.LogCalls
usage.LogCalls = func(time.Time) []sessions.Call {
@@ -142,6 +148,45 @@ func TestUsagePageShowsCallsNotThroughMagpie(t *testing.T) {
}
}
+// The Usage page asked for as midnight falls shows the calls through magpie
+// and those not through it of one day, the day it was asked on, when a
+// period is picked and when the page is read again.
+func TestUsagePageAtMidnight(t *testing.T) {
+ home(t)
+ midnight := time.Date(2026, 10, 1, 0, 0, 0, 0, time.Local)
+ usage.Append(usage.Record{Time: midnight.Add(-time.Minute), Agent: "claude", Provider: "relay", Model: "m", Input: 5, Status: 200})
+ old := usage.LogCalls
+ usage.LogCalls = func(time.Time) []sessions.Call {
+ return []sessions.Call{{Time: midnight.Add(-2 * time.Minute), Agent: "codex", Session: "c1", Model: "gpt-6-luna", Tokens: sessions.Tokens{Input: 1200, Output: 300}}}
+ }
+ t.Cleanup(func() { usage.LogCalls = old })
+ // the usage clock reads a tenth of a second before midnight the first
+ // time and a tenth of a second after it from then on, as when midnight
+ // falls while the page is worked out
+ var read atomic.Bool
+ oldClock := usage.Clock
+ usage.Clock = func() time.Time {
+ if read.Swap(true) {
+ return midnight.Add(100 * time.Millisecond)
+ }
+ return midnight.Add(-100 * time.Millisecond)
+ }
+ t.Cleanup(func() { usage.Clock = oldClock })
+ both := func(how string, m model) {
+ t.Helper()
+ v := m.viewUsage()
+ gw, own, ok := strings.Cut(v, "not through magpie")
+ if !ok || !strings.Contains(gw, "relay/m") || !strings.Contains(own, "gpt-6-luna") {
+ t.Errorf("%s:\n%s", how, v)
+ }
+ }
+ m := press(t, model{w: 160, h: 60, page: pageUsage}, "t")
+ both("today picked", m)
+ read.Store(false)
+ m.reload()
+ both("read again", m)
+}
+
// A Codex account set not to spend its credits says so beside them.
func TestQuotaLinesCreditsNotSpent(t *testing.T) {
home(t)
diff --git a/internal/usage/cache_bounds_test.go b/internal/usage/cache_bounds_test.go
index 7238251b..36516afa 100644
--- a/internal/usage/cache_bounds_test.go
+++ b/internal/usage/cache_bounds_test.go
@@ -12,6 +12,7 @@ func TestUsageSameSizeAndMtimeRewrite(t *testing.T) {
for _, appendAfter := range []bool{false, true} {
t.Run(fmt.Sprint("append=", appendAfter), func(t *testing.T) {
pageHome(t)
+ now := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
historyLog(t, 1034)
Summarize(All)
QueryPage(All, Filter{}, 0, 100)
@@ -34,9 +35,9 @@ func TestUsageSameSizeAndMtimeRewrite(t *testing.T) {
t.Fatal(err)
}
if appendAfter {
- Append(Record{Time: time.Now(), Agent: "codex", Provider: "relay", Model: "m", Input: 19})
+ Append(Record{Time: now, Agent: "codex", Provider: "relay", Model: "m", Input: 19})
}
- got, want := Summarize(All), summarize(All, time.Now(), Load(time.Time{}))
+ got, want := Summarize(All), summarize(All, now, Load(time.Time{}))
if got.Totals != want.Totals {
t.Fatalf("same-stat summary stale: got %+v want %+v", got.Totals, want.Totals)
}
@@ -54,13 +55,14 @@ func cacheBudget(t *testing.T, n int64) {
func TestUsageRawCacheBudget(t *testing.T) {
pageHome(t)
+ now := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
cacheBudget(t, 128)
historyLog(t, 1034)
assertFresh := func() {
t.Helper()
cold := Load(time.Time{})
for _, p := range []Period{Today, Week, Month, All} {
- got, want := Summarize(p), summarize(p, time.Now(), cold)
+ got, want := Summarize(p), summarize(p, now, cold)
if got.Totals != want.Totals {
t.Fatalf("uncached %s summary stale: %+v / %+v", p, got.Totals, want.Totals)
}
@@ -80,7 +82,7 @@ func TestUsageRawCacheBudget(t *testing.T) {
}
}
assertFresh()
- Append(Record{Time: time.Now(), Agent: "codex", Provider: "relay", Model: "m", Input: 11})
+ Append(Record{Time: now, Agent: "codex", Provider: "relay", Model: "m", Input: 11})
assertFresh()
historyLog(t, 20) // rewrite/truncate after the oversized metadata-only index
assertFresh()
@@ -88,6 +90,7 @@ func TestUsageRawCacheBudget(t *testing.T) {
func TestUsageRawAndPricedChunksShareBudget(t *testing.T) {
pageHome(t)
+ holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
historyLog(t, 1034)
snapshot := readLogSnapshot()
cacheBudget(t, snapshot.bytes+1)
@@ -127,6 +130,7 @@ func TestUsageLargeSummaryIsCompleteButNotCached(t *testing.T) {
// page key. The preceding query must leave an initialized page map to write.
func TestUsagePageCacheAfterUncachedQuery(t *testing.T) {
pageHome(t)
+ holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
historyLog(t, 1034)
normalBudget := requestCacheBytes
cacheBudget(t, 128)
diff --git a/internal/usage/caller_provider_test.go b/internal/usage/caller_provider_test.go
index cde1d8ce..085a48ce 100644
--- a/internal/usage/caller_provider_test.go
+++ b/internal/usage/caller_provider_test.go
@@ -97,7 +97,7 @@ func TestProviderAndCallerIdentitiesRemainIndependent(t *testing.T) {
func TestPackedRequestPageCallerAndRouteIdentity(t *testing.T) {
pageHome(t)
- now := time.Now()
+ now := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
for i := 0; i < 80; i++ {
caller := "desk"
if i%2 != 0 {
@@ -143,7 +143,7 @@ func TestPackedRequestPageCallerAndRouteIdentity(t *testing.T) {
func TestCallerPageKeepsUnattributedSessionsSeparate(t *testing.T) {
pageHome(t)
- now := time.Now()
+ now := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
recs := []Record{
{Time: now, Agent: "codex", Provider: "relay", Model: "m", CallerKeyID: "desk", CallerKeyName: "Desk", Input: 10, Status: 200},
{Time: now, Agent: "codex", Provider: "relay", Model: "m", CallerKeyID: "server", CallerKeyName: "Server", Input: 20, Status: 200},
diff --git a/internal/usage/clock_test.go b/internal/usage/clock_test.go
new file mode 100644
index 00000000..8278b251
--- /dev/null
+++ b/internal/usage/clock_test.go
@@ -0,0 +1,147 @@
+package usage
+
+import (
+ "os"
+ "path/filepath"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/yetone/magpie/internal/sessions"
+)
+
+// holdClock stops Clock at at for the test and gives at back, for the test
+// to stamp its calls by: midnight then never falls between a call and the
+// period it is asked for in.
+func holdClock(t *testing.T, at time.Time) time.Time {
+ t.Helper()
+ old := Clock
+ Clock = func() time.Time { return at }
+ t.Cleanup(func() { Clock = old })
+ return at
+}
+
+// Every period is read by Clock, not by the machine's own time: a call made
+// on a day Clock is held at is that day's in Summarize, the Requests page and
+// its chart, the ledger and Direct, and the next day's Today has none of it.
+// A path that reads time.Now itself fails here on any run.
+func TestPeriodsReadTheClock(t *testing.T) {
+ pageHome(t)
+ now := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
+ Append(Record{Agent: "claude", Provider: "openai", Model: "m", Input: 10, Status: 200}) // stamped by Clock
+ calls := func(pts []SeriesPoint) (n int) {
+ for _, p := range pts {
+ n += p.Calls
+ }
+ return n
+ }
+ if s := Summarize(Today); s.Calls != 1 {
+ t.Errorf("Summarize(Today): %d calls, want 1", s.Calls)
+ }
+ if p := QueryPage(Today, Filter{}, 0, 10); p.Total != 1 || calls(p.Series) != 1 {
+ t.Errorf("QueryPage(Today): %d rows, %d in the chart, want 1 and 1", p.Total, calls(p.Series))
+ }
+
+ old := LogCalls
+ LogCalls = func(time.Time) []sessions.Call {
+ return []sessions.Call{{Time: now.Add(-time.Minute), Agent: "codex", Session: "c1", Model: "m", Tokens: sessions.Tokens{Input: 20}}}
+ }
+ t.Cleanup(func() { LogCalls = old })
+ l := LedgerOf(Today, Filter{})
+ if len(l.Rows) != 2 {
+ t.Errorf("LedgerOf(Today): %d rows, want 2", len(l.Rows))
+ }
+ if _, pts := LedgerSeries(Today, l.Rows); calls(pts) != 2 {
+ t.Errorf("LedgerSeries(Today): %d calls, want 2", calls(pts))
+ }
+ if d := Direct(Today); d.Calls != 1 || !d.Since.Equal(Today.Since(now)) {
+ t.Errorf("Direct(Today): %d calls since %v, want 1 since %v", d.Calls, d.Since, Today.Since(now))
+ }
+
+ // the next day, today has none of it, though the answers for the day
+ // before are cached
+ LogCalls = old
+ holdClock(t, now.AddDate(0, 0, 1))
+ if s := Summarize(Today); s.Calls != 0 {
+ t.Errorf("Summarize(Today) the next day: %d calls, want 0", s.Calls)
+ }
+ if p := QueryPage(Today, Filter{}, 0, 10); p.Total != 0 {
+ t.Errorf("QueryPage(Today) the next day: %d rows, want 0", p.Total)
+ }
+}
+
+// midnightClock sets Clock to read a tenth of a second before midnight the
+// first time and a tenth of a second after it from then on, as when midnight
+// falls while an answer is worked out. It is set again for each answer.
+func midnightClock(t *testing.T, midnight time.Time) {
+ t.Helper()
+ var read atomic.Bool
+ old := Clock
+ Clock = func() time.Time {
+ if read.Swap(true) {
+ return midnight.Add(100 * time.Millisecond)
+ }
+ return midnight.Add(-100 * time.Millisecond)
+ }
+ t.Cleanup(func() { Clock = old })
+}
+
+// An answer reads the time once. A Requests page asked for as midnight falls
+// is of the day it was asked on: its rows, a session file written that day
+// among them, are under that day's chart, whether the page comes from the
+// indexed log or from the ledger, and it is kept for that day only. Direct's
+// period starts on the day of the calls it counts. Read more than once, the
+// rows were of one day and the chart or the period of the next.
+func TestAnswerReadsTheClockOnce(t *testing.T) {
+ pageHome(t)
+ midnight := time.Date(2026, 10, 1, 0, 0, 0, 0, time.Local)
+ day := midnight.AddDate(0, 0, -1)
+ for _, at := range []time.Time{day.Add(12 * time.Hour), midnight.Add(-time.Second)} {
+ Append(Record{Time: at, Agent: "claude", Provider: "openai", Model: "m", Input: 10, Status: 200})
+ }
+ // a session file last written at 23:00, with a call of that time
+ at := day.Add(23 * time.Hour)
+ path := filepath.Join(sessions.ClaudeDir(), "projects", "p", "s.jsonl")
+ if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("{}\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chtimes(path, at, at); err != nil {
+ t.Fatal(err)
+ }
+ call := []sessions.Call{{Time: at, Agent: "codex", Session: "s", Model: "m", Tokens: sessions.Tokens{Input: 20}}}
+ page := func(want int, from time.Time) {
+ t.Helper()
+ p := queryPage(Today, Filter{}, 0, 10, func(sessions.CallSource) []sessions.Call { return call })
+ var calls int
+ var first time.Time
+ for i, pt := range p.Series {
+ if i == 0 {
+ first = pt.Time
+ }
+ calls += pt.Calls
+ }
+ if p.Total != want || calls != want || !first.Equal(from) {
+ t.Errorf("QueryPage(Today): %d rows, %d in a chart from %v; want %d under one from %v", p.Total, calls, first, want, from)
+ }
+ }
+ midnightClock(t, midnight)
+ page(3, day)
+ // asked again after midnight, the next day's page, not the one kept for
+ // the day before
+ page(0, midnight)
+
+ // the same page from the ledger
+ old := LogCalls
+ LogCalls = func(time.Time) []sessions.Call { return call }
+ t.Cleanup(func() { LogCalls = old })
+ midnightClock(t, midnight)
+ page(3, day)
+
+ midnightClock(t, midnight)
+ if d := Direct(Today); d.Calls != 1 || !d.Since.Equal(day) {
+ t.Errorf("Direct(Today): %d calls since %v, want 1 since %v", d.Calls, d.Since, day)
+ }
+}
diff --git a/internal/usage/codex_cache_write_test.go b/internal/usage/codex_cache_write_test.go
index c37af3b4..d0a47d3f 100644
--- a/internal/usage/codex_cache_write_test.go
+++ b/internal/usage/codex_cache_write_test.go
@@ -18,10 +18,7 @@ import (
// gateway's record didn't, so their token counts never matched. However each
// side splits the write out of the input, a call is the gateway's alone.
func TestCodexCacheWriteMatchesGatewayCall(t *testing.T) {
- end := time.Now().Truncate(time.Second).Add(-time.Minute)
- if end.Before(Today.Since(time.Now())) {
- end = Today.Since(time.Now()).Add(10 * time.Minute)
- }
+ end := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local)).Add(-time.Minute)
// the upstream's usage for the two calls: the second wrote 9000 to its cache
type call struct{ in, cached, write, out int }
calls := []call{{120000, 113000, 0, 300}, {130000, 113000, 9000, 400}}
@@ -95,7 +92,7 @@ func TestCodexCacheWriteNotInInput(t *testing.T) {
pageHome(t)
path := filepath.Join(sessions.CodexDir(), "sessions", "rollout-2026-09-30T00-00-00-w.jsonl")
os.MkdirAll(filepath.Dir(path), 0700)
- at := time.Now().Add(-time.Minute).Format(time.RFC3339Nano)
+ at := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local)).Add(-time.Minute).Format(time.RFC3339Nano)
u := `{"input_tokens":130000,"cached_input_tokens":113000,"cache_write_input_tokens":9000,"output_tokens":400}`
os.WriteFile(path, []byte(`{"type":"session_meta","payload":{"id":"w"}}`+"\n"+
`{"timestamp":"`+at+`","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":`+u+`,"last_token_usage":`+u+`}}}`+"\n"), 0600)
diff --git a/internal/usage/direct.go b/internal/usage/direct.go
index 94b08924..bd30aaf1 100644
--- a/internal/usage/direct.go
+++ b/internal/usage/direct.go
@@ -13,7 +13,17 @@ import (
// their own (Kumo31 on Discord: Codex used outside magpie was on the
// window's Usage tab but not in magpie usage).
func Direct(p Period) Summary {
- return direct(p, time.Now(), LedgerOf(p, Filter{}).Rows)
+ now := Clock()
+ return direct(p, now, LedgerOfAt(p, Filter{}, now).Rows)
+}
+
+// Summaries is Summarize(p) and Direct(p) asked at one moment, for a page
+// that shows the two together: asked one after the other, midnight could
+// fall between them and set one day's calls through magpie beside the next
+// day's calls not through it.
+func Summaries(p Period) (Summary, Summary) {
+ now := Clock()
+ return indexedSummary(p, now), direct(p, now, LedgerOfAt(p, Filter{}, now).Rows)
}
func direct(p Period, now time.Time, rows []Row) Summary {
diff --git a/internal/usage/direct_test.go b/internal/usage/direct_test.go
index 850d9c2f..e99e74fe 100644
--- a/internal/usage/direct_test.go
+++ b/internal/usage/direct_test.go
@@ -26,8 +26,7 @@ func TestDirectCountsOnlySessionFileCalls(t *testing.T) {
catalog.Reset()
t.Cleanup(catalog.Reset)
- t0 := time.Now()
- now := time.Date(t0.Year(), t0.Month(), t0.Day(), 12, 0, 0, 0, t0.Location())
+ now := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
Append(Record{Time: now.Add(-10 * time.Minute), Agent: "claude", Provider: "relay", Model: "m", Input: 5, Output: 5, Status: 200, Session: "g1"})
// a client built on Codex, which the gateway logs under its own name
// while its Codex session file names Codex: the same call, not one of
diff --git a/internal/usage/growing_rewrite_test.go b/internal/usage/growing_rewrite_test.go
index 994e6035..c4937d23 100644
--- a/internal/usage/growing_rewrite_test.go
+++ b/internal/usage/growing_rewrite_test.go
@@ -13,6 +13,7 @@ func TestLogGrowingRewriteThenAppend(t *testing.T) {
for _, writer := range []string{"this process", "another writer"} {
t.Run(writer, func(t *testing.T) {
pageHome(t)
+ now := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
historyLog(t, logBlockRows*2+10)
for _, period := range []Period{Today, Week, Month, All} {
Summarize(period)
@@ -30,7 +31,7 @@ func TestLogGrowingRewriteThenAppend(t *testing.T) {
}
// Change an early record and grow the same inode. Treating this as an
// append would reuse stale blocks and seek into the rewritten prefix.
- edited := Record{Time: time.Now(), Agent: "codex", Provider: "relay", Model: "m", Session: "edited-prefix", Input: 73, Status: 200, RequestID: strings.Repeat("r", 2048)}
+ edited := Record{Time: now, Agent: "codex", Provider: "relay", Model: "m", Session: "edited-prefix", Input: 73, Status: 200, RequestID: strings.Repeat("r", 2048)}
first, err := json.Marshal(edited)
if err != nil {
t.Fatal(err)
@@ -42,7 +43,7 @@ func TestLogGrowingRewriteThenAppend(t *testing.T) {
if err := os.WriteFile(Path(), rewritten, 0600); err != nil {
t.Fatal(err)
}
- added := Record{Time: time.Now(), Agent: "codex", Provider: "relay", Model: "m", Session: "appended-tail", Input: 19, Status: 200}
+ added := Record{Time: now, Agent: "codex", Provider: "relay", Model: "m", Session: "appended-tail", Input: 19, Status: 200}
if writer == "this process" {
Append(added)
} else {
@@ -64,7 +65,7 @@ func TestLogGrowingRewriteThenAppend(t *testing.T) {
t.Fatalf("fixture rows: %d", len(cold))
}
for _, period := range []Period{Today, Week, Month, All} {
- got, want := Summarize(period), summarize(period, time.Now(), cold)
+ got, want := Summarize(period), summarize(period, now, cold)
if got.Totals != want.Totals {
t.Fatalf("%s summary stale: got %+v want %+v", period, got.Totals, want.Totals)
}
diff --git a/internal/usage/incremental_log_test.go b/internal/usage/incremental_log_test.go
index 9576d4fc..e4260c64 100644
--- a/internal/usage/incremental_log_test.go
+++ b/internal/usage/incremental_log_test.go
@@ -10,6 +10,8 @@ import (
"github.com/yetone/magpie/internal/testenv"
)
+// historyLog writes n calls of 90 days ago, a second apart, and one at
+// Clock's time.
func historyLog(t testing.TB, n int) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(Path()), 0700); err != nil {
@@ -20,11 +22,12 @@ func historyLog(t testing.TB, n int) {
t.Fatal(err)
}
enc := json.NewEncoder(f)
- old := time.Now().AddDate(0, 0, -90)
+ now := Clock()
+ old := now.AddDate(0, 0, -90)
for i := 0; i < n; i++ {
r := Record{Time: old.Add(time.Duration(i) * time.Second), Agent: "codex", Provider: "relay", Model: "m", Session: "s", Input: 10, Status: 200}
if i == n-1 {
- r.Time = time.Now()
+ r.Time = now
}
if err := enc.Encode(r); err != nil {
f.Close()
@@ -38,6 +41,7 @@ func historyLog(t testing.TB, n int) {
func TestUsageOverviewDoesNotReparseHistory(t *testing.T) {
pageHome(t)
+ holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
historyLog(t, 4000)
if s := Summarize(Today); s.Calls != 1 {
t.Fatal(s.Calls)
@@ -61,6 +65,7 @@ func TestUsageViasSkipsOldHistory(t *testing.T) {
func TestUsageOverviewRefreshesAfterAppendAndReplacement(t *testing.T) {
pageHome(t)
+ now := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
Append(Record{Agent: "codex", Provider: "relay", Model: "m", Input: 2, Status: 200})
if s := Summarize(Today); s.Input != 2 {
t.Fatal(s)
@@ -70,7 +75,7 @@ func TestUsageOverviewRefreshesAfterAppendAndReplacement(t *testing.T) {
t.Fatal(s)
}
f := filepath.Join(filepath.Dir(Path()), "replacement.jsonl")
- b, _ := json.Marshal(Record{Time: time.Now(), Agent: "codex", Provider: "relay", Input: 7, Status: 200})
+ b, _ := json.Marshal(Record{Time: now, Agent: "codex", Provider: "relay", Input: 7, Status: 200})
if err := os.WriteFile(f, append(b, '\n'), 0600); err != nil {
t.Fatal(err)
}
@@ -112,9 +117,10 @@ func TestLogAppendReusesSealedBlocks(t *testing.T) {
func TestLogPartialLineAndTruncation(t *testing.T) {
pageHome(t)
+ now := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
Append(Record{Agent: "codex", Input: 2, Status: 200})
Summarize(Today)
- b, _ := json.Marshal(Record{Time: time.Now(), Agent: "codex", Input: 7, Status: 200})
+ b, _ := json.Marshal(Record{Time: now, Agent: "codex", Input: 7, Status: 200})
f, e := os.OpenFile(Path(), os.O_APPEND|os.O_WRONLY, 0600)
if e != nil {
t.Fatal(e)
@@ -143,6 +149,7 @@ func TestLogPartialLineAndTruncation(t *testing.T) {
func TestCachedSummaryRemainsCallerOwned(t *testing.T) {
pageHome(t)
+ holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
Append(Record{Agent: "codex", Input: 3, Status: 200})
s := Summarize(Today)
s.Series[0].Calls = 999
@@ -184,6 +191,7 @@ func BenchmarkIncrementalUsage(b *testing.B) {
func TestAppendDoesNotWaitForIndexBuild(t *testing.T) {
pageHome(t)
+ holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
logIndex.Lock()
done := make(chan struct{})
go func() { Append(Record{Agent: "codex", Input: 1, Status: 200}); close(done) }()
diff --git a/internal/usage/ledger.go b/internal/usage/ledger.go
index 027f614b..67b1a3e6 100644
--- a/internal/usage/ledger.go
+++ b/internal/usage/ledger.go
@@ -134,6 +134,12 @@ func (p Period) Since(now time.Time) time.Time {
// ledger adds to those the gateway logged. A variable for the tests.
var LogCalls func(time.Time) []sessions.Call
+// Clock is the time the periods are read by: when today began, the hours
+// or days of the chart, the day a cached answer is for. A variable for the
+// tests, which hold it still so that midnight never falls between a call
+// they log and the period they ask for.
+var Clock = time.Now
+
// Ledgered is a period's calls that a filter keeps, newest first, with their
// sum, and the agents and providers that made any call in the period (their
// ids, for a filter to offer).
@@ -148,7 +154,13 @@ type Ledgered struct {
// comes with the calls the agents' session files record that the gateway did
// not see.
func LedgerOf(p Period, f Filter) Ledgered {
- since := p.Since(time.Now())
+ return LedgerOfAt(p, f, Clock())
+}
+
+// LedgerOfAt is LedgerOf with the period read at now, for an answer that
+// names that moment elsewhere too, as an exported CSV's name does.
+func LedgerOfAt(p Period, f Filter, now time.Time) Ledgered {
+ since := p.Since(now)
gatewaySince := since
if !since.IsZero() {
gatewaySince = since.Add(-24 * time.Hour)
@@ -654,7 +666,12 @@ const seriesKeep = 24
// hour, day or week, for the chart over them. A row is of the point its
// time falls in; the rows are in any order.
func LedgerSeries(p Period, rows []Row) (bucket string, pts []SeriesPoint) {
- now := time.Now()
+ return ledgerSeriesAt(p, rows, Clock())
+}
+
+// ledgerSeriesAt is LedgerSeries with the period read at now, the moment the
+// rows were read at, so that the chart is of the rows' day.
+func ledgerSeriesAt(p Period, rows []Row, now time.Time) (bucket string, pts []SeriesPoint) {
var first time.Time
for _, r := range rows {
if r.IsRejected() {
diff --git a/internal/usage/ledger_test.go b/internal/usage/ledger_test.go
index ec1a3e65..5c1058be 100644
--- a/internal/usage/ledger_test.go
+++ b/internal/usage/ledger_test.go
@@ -184,10 +184,9 @@ func TestLedgerWithSessionLogCalls(t *testing.T) {
catalog.Reset()
t.Cleanup(catalog.Reset)
- // noon today, so that what the test lists a few hours before it is still today,
- // whenever the test runs
- t0 := time.Now()
- now := time.Date(t0.Year(), t0.Month(), t0.Day(), 12, 0, 0, 0, t0.Location())
+ // noon of the day Clock is held at, so that what the test lists a few
+ // hours before it is that day's
+ now := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
// the gateway's: an answered request of session s1 that began at -10m and
// took 20 s, and a failure of another with what the vendor said
Append(Record{Time: now.Add(-10 * time.Minute), Agent: "claude", Provider: "claude", Model: "claude-sonnet-5", Input: 5, Output: 5,
@@ -274,7 +273,7 @@ func TestLedgerWithSessionLogCalls(t *testing.T) {
// with its calls, failures, tokens and cost; a row of before the period is
// in none.
func TestLedgerSeries(t *testing.T) {
- now := time.Now()
+ now := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
row := func(at time.Time, in, out, cr, cw int, cost float64, priced bool, status int, errText string) Row {
return Row{Record: Record{Time: at, Provider: "p", Input: in, Output: out, CacheRead: cr, CacheWrite: cw, Status: status, Error: errText}, Cost: cost, Priced: priced}
@@ -331,7 +330,7 @@ func TestLedgerSeries(t *testing.T) {
// as the point's own sums do, for a chart of speed to tell the models
// apart (#860): a failed call and one too fast to time count in neither.
func TestLedgerSeriesSpeed(t *testing.T) {
- now := time.Now()
+ now := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
at := time.Date(now.Year(), now.Month(), now.Day(), 0, 10, 0, 0, now.Location())
row := func(model string, out int, ms, ttft int64, status int) Row {
return Row{Record: Record{Time: at, Provider: "p", Model: model, Output: out, Millis: ms, TTFT: ttft, Status: status}}
@@ -363,7 +362,7 @@ func TestLedgerSeriesSpeed(t *testing.T) {
// timeline for a chart to stack — the ones with the most tokens over the
// period, the rest left to the point's own sums.
func TestLedgerByProvider(t *testing.T) {
- now := time.Now()
+ now := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
at := time.Date(now.Year(), now.Month(), now.Day(), 0, 10, 0, 0, now.Location())
rec := func(agent, prov, model string, in, cw int, mins int) Record {
return Record{Time: at.Add(time.Duration(mins) * time.Minute), Agent: agent, Provider: prov, Model: model, Input: in, CacheRead: cw, Output: 1, Status: 200}
diff --git a/internal/usage/priced_blocks_test.go b/internal/usage/priced_blocks_test.go
index c1695d8c..a0608a3b 100644
--- a/internal/usage/priced_blocks_test.go
+++ b/internal/usage/priced_blocks_test.go
@@ -24,6 +24,7 @@ func appendAndQueryAllocs(t *testing.T, n int) float64 {
}
func TestRequestPageAppendDoesNotRepriceHistory(t *testing.T) {
+ holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
// A query costs a fixed number of allocations that depends on the
// machine (on Windows, provider sign-in lookups read the environment and
// registry), so the budget is what 7990 more records of history add:
diff --git a/internal/usage/purpose_test.go b/internal/usage/purpose_test.go
index 4c14400f..53621737 100644
--- a/internal/usage/purpose_test.go
+++ b/internal/usage/purpose_test.go
@@ -41,7 +41,8 @@ func TestPurposeFilter(t *testing.T) {
func TestPurposeRequestPages(t *testing.T) {
pageHome(t)
- start := Today.Since(time.Now()).Add(time.Second)
+ now := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
+ start := Today.Since(now).Add(time.Second)
kinds := []string{"thread_title", "review", "thread_title_reconsideration", "future_kind", "", "title_generation", "unmarked"}
for i, kind := range kinds {
Append(Record{Time: start.Add(time.Duration(i) * time.Second), Agent: "codex", Provider: "openai", Model: "m",
diff --git a/internal/usage/request_day_test.go b/internal/usage/request_day_test.go
index b84842cd..1a3bcbf8 100644
--- a/internal/usage/request_day_test.go
+++ b/internal/usage/request_day_test.go
@@ -9,7 +9,7 @@ import (
func TestRequestPageDay(t *testing.T) {
pageHome(t)
- start := Today.Since(time.Now()).AddDate(0, 0, -1)
+ start := Today.Since(holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))).AddDate(0, 0, -1)
end := start.AddDate(0, 0, 1)
day := start.Format(time.DateOnly)
gateway := &rowChunk{}
diff --git a/internal/usage/request_index_review_test.go b/internal/usage/request_index_review_test.go
index f991b6d9..1cc8b187 100644
--- a/internal/usage/request_index_review_test.go
+++ b/internal/usage/request_index_review_test.go
@@ -60,7 +60,7 @@ func TestQueryPageIOOutsideCacheLock(t *testing.T) {
path := filepath.Join(sessions.ClaudeDir(), "projects", "p", "yesterday.jsonl")
os.MkdirAll(filepath.Dir(path), 0700)
os.WriteFile(path, []byte("{}\n"), 0600)
- at := time.Now().Add(-48 * time.Hour)
+ at := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local)).Add(-48 * time.Hour)
os.Chtimes(path, at, at)
entered, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{})
go func() {
diff --git a/internal/usage/request_page.go b/internal/usage/request_page.go
index be2e1cfe..7560de7a 100644
--- a/internal/usage/request_page.go
+++ b/internal/usage/request_page.go
@@ -214,9 +214,12 @@ func queryPage(p Period, f Filter, offset, limit int, readSource func(sessions.C
}
limit = min(limit, 500)
offset = max(0, offset)
+ // The time is read once, so that the rows, the chart and the day the page
+ // is kept for are of one day when midnight falls while it is worked out.
+ now := Clock()
// A replacement reader remains useful to callers supplying synthetic logs.
if LogCalls != nil {
- return pageFromLedger(p, f, offset, limit, LedgerOf(p, Filter{}))
+ return pageFromLedgerAt(p, f, offset, limit, LedgerOfAt(p, Filter{}, now), now)
}
sources := sessions.CallSources()
slices.SortFunc(sources, func(a, b sessions.CallSource) int {
@@ -248,7 +251,7 @@ func queryPage(p Period, f Filter, offset, limit int, readSource func(sessions.C
h.Reset()
snapshot := logSnapshotFor(true)
version := snapshot.version
- fmt.Fprint(h, meta, version, time.Now().Format("2006-01-02 MST"))
+ fmt.Fprint(h, meta, version, now.Format("2006-01-02 MST"))
for _, s := range sources {
fmt.Fprintf(h, "%s:%d:%d;", s.Path, s.Size, s.Modified.UnixNano())
}
@@ -301,7 +304,7 @@ func queryPage(p Period, f Filter, offset, limit int, readSource func(sessions.C
row.Agent = AgentOf(r.Agent)
return row
}
- since := p.Since(time.Now())
+ since := p.Since(now)
gatewaySince := since
if !since.IsZero() {
gatewaySince = since.Add(-24 * time.Hour)
@@ -444,7 +447,7 @@ func queryPage(p Period, f Filter, offset, limit int, readSource func(sessions.C
if someShared {
names = sharedNames()
}
- page := buildRequestBlocks(p, f, offset, limit, gateways, chunks, others, names)
+ page := buildRequestBlocks(p, f, offset, limit, now, gateways, chunks, others, names)
shared.Lock()
defer shared.Unlock()
// A query for an older filesystem snapshot may finish after a newer one.
@@ -724,13 +727,15 @@ func sharesOf(m map[string]*Share) []Share {
return out
}
func buildRequestPage(p Period, f Filter, offset, limit int, gateway *rowChunk, chunks []*rowChunk) RequestPage {
- return buildRequestBlocks(p, f, offset, limit, []*rowChunk{gateway}, chunks, nil, nil)
+ return buildRequestBlocks(p, f, offset, limit, Clock(), []*rowChunk{gateway}, chunks, nil, nil)
}
// Shared days are never matched to this computer's gateway or session calls.
-func buildRequestBlocks(p Period, f Filter, offset, limit int, gateways, chunks, others []*rowChunk, names map[string]string) RequestPage {
+// now is the moment the page is asked at: its period and its chart's hours or
+// days are read from it.
+func buildRequestBlocks(p Period, f Filter, offset, limit int, now time.Time, gateways, chunks, others []*rowChunk, names map[string]string) RequestPage {
skip := visibleLocal(chunks)
- since := p.Since(time.Now())
+ since := p.Since(now)
matched := matchedBlocks(gateways, chunks, skip, since, true)
all := append(append(slices.Clone(gateways), chunks...), others...)
visit := func(fn func(rowRef, Row)) {
@@ -897,7 +902,7 @@ func buildRequestBlocks(p Period, f Filter, offset, limit int, gateways, chunks,
out.Computers, out.Names = computerShares(computers, names)
var base []Point
chartSince := since
- chartSince, out.Bucket, base = timeline(p, time.Now(), first)
+ chartSince, out.Bucket, base = timeline(p, now, first)
out.Series = make([]SeriesPoint, len(base))
for i := range base {
out.Series[i] = SeriesPoint{Point: base[i], By: map[string]map[string]Part{}}
@@ -945,6 +950,12 @@ func buildRequestBlocks(p Period, f Filter, offset, limit int, gateways, chunks,
return out
}
func pageFromLedger(p Period, f Filter, offset, limit int, all Ledgered) RequestPage {
+ return pageFromLedgerAt(p, f, offset, limit, all, Clock())
+}
+
+// pageFromLedgerAt is the page of a ledger read at now, its chart over the
+// period read at the same moment.
+func pageFromLedgerAt(p Period, f Filter, offset, limit int, all Ledgered, now time.Time) RequestPage {
l := all.Filtered(f)
out := RequestPage{Sum: l.Sum, Total: len(l.Rows), Agents: l.Agents, Providers: l.Providers, Purposes: []string{}, By: map[string][]Share{}}
callers, accounts := map[string]*Group{}, map[string]*Group{}
@@ -969,7 +980,7 @@ func pageFromLedger(p Period, f Filter, offset, limit int, all Ledgered) Request
chartRows = all.Filtered(chartFilter).Rows
out.ChartBy = map[string][]Share{}
}
- out.Bucket, out.Series = LedgerSeries(p, chartRows)
+ out.Bucket, out.Series = ledgerSeriesAt(p, chartRows, now)
for _, d := range Dimensions {
g := f
if d == "provider" {
diff --git a/internal/usage/request_page_test.go b/internal/usage/request_page_test.go
index 266ff923..c7d0b790 100644
--- a/internal/usage/request_page_test.go
+++ b/internal/usage/request_page_test.go
@@ -67,7 +67,7 @@ func equalPage(t *testing.T, got, want RequestPage) {
func TestCompactPageMatchesLedger(t *testing.T) {
pageHome(t)
- now := time.Now().Truncate(time.Second)
+ now := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
var recs []Record
var logs []sessions.Call
for i := 0; i < 180; i++ {
@@ -147,7 +147,7 @@ func TestCompactPageMatchesLedger(t *testing.T) {
func TestRequestPageModelRankingKeepsAlternatives(t *testing.T) {
pageHome(t)
- now := time.Now()
+ now := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
records := []Record{
{Time: now, Provider: "a", Agent: "codex", Model: "gpt-5", Input: 10},
{Time: now, Provider: "a", Agent: "codex", Model: "gpt-5-mini", Input: 20},
@@ -204,11 +204,12 @@ func TestQueryPageSourceAndIdentityInvalidation(t *testing.T) {
provider.ForgetAccounts()
provider.Accounts()
sessionAuth(t, sessions.CodexDir(), "a", "u", "one@example.com")
+ now := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
path := filepath.Join(sessions.CodexDir(), "sessions", "rollout-2026-09-30T00-00-00-test.jsonl")
os.MkdirAll(filepath.Dir(path), 0700)
meta := `{"type":"session_meta","payload":{"id":"test","model_provider":"custom","creator_account_id":"a","creator_user_id":"u"}}` + "\n" + `{"type":"turn_context","payload":{"model":"m","effort":"high"}}` + "\n"
line := func(n int) string {
- return fmt.Sprintf(`{"timestamp":%q,"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":%d,"output_tokens":%d},"last_token_usage":{"input_tokens":10,"output_tokens":1}}}}`+"\n", time.Now().Format(time.RFC3339Nano), n*10, n)
+ return fmt.Sprintf(`{"timestamp":%q,"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":%d,"output_tokens":%d},"last_token_usage":{"input_tokens":10,"output_tokens":1}}}}`+"\n", now.Format(time.RFC3339Nano), n*10, n)
}
os.WriteFile(path, []byte(meta+line(1)), 0600)
check := func(n int, account string, official bool) {
@@ -245,7 +246,7 @@ func TestQueryPageSourceAndIdentityInvalidation(t *testing.T) {
sessionAuth(t, sessions.CodexDir(), "other", "u", "wrong@example.com")
check(2, "", false)
os.MkdirAll(filepath.Dir(Path()), 0700)
- r, _ := json.Marshal(Record{Time: time.Now(), Model: "m", Agent: "opencode", Input: 10, Output: 1})
+ r, _ := json.Marshal(Record{Time: now, Model: "m", Agent: "opencode", Input: 10, Output: 1})
os.WriteFile(Path(), r, 0600)
check(2, "", false) // partial gateway line
appendFile(Path(), "\n")
@@ -270,7 +271,7 @@ func TestQueryPageSourceAndIdentityInvalidation(t *testing.T) {
// (inaction on Discord).
func TestRequestPageRanksModelAtEachProvider(t *testing.T) {
pageHome(t)
- now := time.Now()
+ now := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
records := []Record{
{Time: now, Provider: "zhipu", Agent: "claude", Model: "glm-5.3", Output: 1000, Millis: 2200, TTFT: 200},
{Time: now, Provider: "zhipu", Agent: "claude", Model: "glm-5.3", Output: 1000, Millis: 2200, TTFT: 200},
diff --git a/internal/usage/review_test.go b/internal/usage/review_test.go
index d94da5fe..bcdfe2ed 100644
--- a/internal/usage/review_test.go
+++ b/internal/usage/review_test.go
@@ -51,7 +51,7 @@ func TestRejectedOnlyInRequestRows(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
catalog.Reset()
t.Cleanup(catalog.Reset)
- now := time.Now()
+ now := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
recs := []Record{{Time: now, Agent: "codex", Provider: "relay", Model: "m", Status: 200, Input: 2}, {Time: now, Agent: "codex", Status: 404, Error: "unknown model", Rejected: true}, {Time: now, Agent: "codex", Status: 400}}
rows, sum, _ := ledger(time.Time{}, Filter{}, recs)
if len(rows) != 3 || sum.Calls != 1 || sum.Errors != 0 {
diff --git a/internal/usage/summary_cache.go b/internal/usage/summary_cache.go
index 3eb211e0..c110d8a0 100644
--- a/internal/usage/summary_cache.go
+++ b/internal/usage/summary_cache.go
@@ -26,11 +26,10 @@ var summaries struct {
entries map[Period]summaryEntry
}
-func indexedSummary(p Period) Summary {
+func indexedSummary(p Period, now time.Time) Summary {
if p != Today && p != Week && p != Month {
p = All
}
- now := time.Now()
snapshot := logSnapshotFor(true)
_, offset := now.Zone()
meta := fmt.Sprintf("%s|%s|%s|%s|%s|%s|%d", statKey(settings.Path()), statKey(provider.Path()), statKey(catalog.CachePath()), statKey(catalog.LivePath("antigravity")), now.Format("2006-01-02"), now.Location(), offset)
diff --git a/internal/usage/summary_cache_test.go b/internal/usage/summary_cache_test.go
index 0154a86a..7fc6fd47 100644
--- a/internal/usage/summary_cache_test.go
+++ b/internal/usage/summary_cache_test.go
@@ -11,10 +11,11 @@ import (
func TestSummaryInvalidatesLiveFreePrice(t *testing.T) {
pageHome(t)
+ now := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
if err := provider.Save(provider.Provider{ID: "cline-relay", Chat: "https://api.cline.bot/v1", Key: "x", Catalog: "openai"}); err != nil {
t.Fatal(err)
}
- Append(Record{Time: time.Now(), Agent: "codex", Provider: "cline-relay", Model: "m", Input: 1000000, Status: 200})
+ Append(Record{Time: now, Agent: "codex", Provider: "cline-relay", Model: "m", Input: 1000000, Status: 200})
if err := catalog.SaveLive("cline-relay", "https://api.cline.bot/v1", []catalog.Model{{ID: "m", Free: false}}); err != nil {
t.Fatal(err)
}
@@ -25,7 +26,7 @@ func TestSummaryInvalidatesLiveFreePrice(t *testing.T) {
if err := catalog.SaveLive("cline-relay", "https://api.cline.bot/v1", []catalog.Model{{ID: "m", Free: true}}); err != nil {
t.Fatal(err)
}
- direct := summarize(Today, time.Now(), Load(time.Time{}))
+ direct := summarize(Today, now, Load(time.Time{}))
if direct.Cost != 0 {
t.Fatalf("need free direct summary: %#v", direct.Totals)
}
@@ -36,12 +37,13 @@ func TestSummaryInvalidatesLiveFreePrice(t *testing.T) {
func TestSummaryInvalidatesSameStampPrice(t *testing.T) {
pageHome(t)
+ now := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
s := settings.Load()
s.ModelPrices = map[string]settings.ModelPrice{"*/m": {Input: new(2.0), Output: new(8.0), CacheRead: new(0.0), CacheWrite: new(0.0)}}
if err := settings.Save(s); err != nil {
t.Fatal(err)
}
- Append(Record{Time: time.Now(), Agent: "codex", Provider: "relay", Model: "m", Input: 1000000, Status: 200})
+ Append(Record{Time: now, Agent: "codex", Provider: "relay", Model: "m", Input: 1000000, Status: 200})
first := Summarize(Today)
if first.Cost != 2 {
t.Fatalf("need paid baseline: %#v", first.Totals)
@@ -58,7 +60,7 @@ func TestSummaryInvalidatesSameStampPrice(t *testing.T) {
if err := os.Chtimes(settings.Path(), old.ModTime(), old.ModTime()); err != nil {
t.Fatal(err)
}
- direct := summarize(Today, time.Now(), Load(time.Time{}))
+ direct := summarize(Today, now, Load(time.Time{}))
if direct.Cost != 4 {
t.Fatalf("direct failed coarse-stamp guard: %#v", direct.Totals)
}
diff --git a/internal/usage/usage.go b/internal/usage/usage.go
index a3750cff..ab3d5ff2 100644
--- a/internal/usage/usage.go
+++ b/internal/usage/usage.go
@@ -146,7 +146,7 @@ var mu sync.Mutex
// break a call.
func Append(r Record) {
if r.Time.IsZero() {
- r.Time = time.Now()
+ r.Time = Clock()
}
offerOTel(r)
b, err := json.Marshal(r)
@@ -492,7 +492,7 @@ type Summary struct {
// 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)
+ return indexedSummary(p, Clock())
}
func summarize(p Period, now time.Time, recs []Record) Summary {
diff --git a/internal/usage/usage_test.go b/internal/usage/usage_test.go
index 12bcd54f..d45cd237 100644
--- a/internal/usage/usage_test.go
+++ b/internal/usage/usage_test.go
@@ -233,11 +233,12 @@ func TestReasoningReplyTimedByItsAnswer(t *testing.T) {
}
var rows Totals
var lr []Row
+ held := holdClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
for _, r := range recs {
rows.addRow(Row{Record: r})
- // LedgerSeries buckets today by the clock, so the timeline's rows
- // are made now
- r.Time = time.Now()
+ // LedgerSeries buckets today by Clock, so the timeline's rows are
+ // made at the time it is held at
+ r.Time = held
lr = append(lr, Row{Record: r})
}
if rows.Timed != 6 || rows.DecodeMs != ms || rows.DecodeOut != out {
diff --git a/quota_cli.go b/quota_cli.go
index 2b4b1532..bfcf050c 100644
--- a/quota_cli.go
+++ b/quota_cli.go
@@ -127,10 +127,11 @@ func quotaCmd(args []string) error {
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)
+ line += " " + quotaCell(w, now)
}
if q.Balance != "" {
line += " " + balanceCell(q.Balance, q.Provider, q.User)
diff --git a/usage_cli.go b/usage_cli.go
index 614d80fd..f6b9a043 100644
--- a/usage_cli.go
+++ b/usage_cli.go
@@ -80,10 +80,11 @@ func usageTo(out io.Writer, args []string) error {
return stats.WriteCSV(out, rows)
}
loadCostCurrency()
- s := stats.Summarize(period)
- // the calls the agents made on their own, read from their session files:
- // the window's Requests tab counts them too (Kumo31 on Discord)
- d := stats.Direct(period)
+ // s is the calls through magpie, d the calls the agents made on their
+ // own, read from their session files: the window's Requests tab counts
+ // them too (Kumo31 on Discord). Asked together, both are of one day when
+ // midnight falls while they are read.
+ s, d := stats.Summaries(period)
title := map[stats.Period]string{stats.Today: "today", stats.Week: "last 7 days", stats.Month: "last 30 days", stats.All: "all time"}[s.Period]
if s.Calls == 0 && d.Calls == 0 {
fmt.Fprintln(out, muted.Render("no calls "+title+" ·"), "route an agent through magpie and its usage shows up here")
diff --git a/usage_cli_test.go b/usage_cli_test.go
index 76657cd5..56ea6998 100644
--- a/usage_cli_test.go
+++ b/usage_cli_test.go
@@ -3,6 +3,7 @@ package main
import (
"encoding/csv"
"strings"
+ "sync/atomic"
"testing"
"time"
@@ -11,12 +12,38 @@ import (
stats "github.com/yetone/magpie/internal/usage"
)
+// holdUsageClock stops the usage clock at at for the test and gives at back,
+// for the test to stamp its calls by: midnight then never falls between a
+// call and the period it is asked for in.
+func holdUsageClock(t *testing.T, at time.Time) time.Time {
+ t.Helper()
+ old := stats.Clock
+ stats.Clock = func() time.Time { return at }
+ t.Cleanup(func() { stats.Clock = old })
+ return at
+}
+
+// midnightUsageClock sets the usage clock to read a tenth of a second before
+// midnight the first time and a tenth of a second after it from then on, as
+// when midnight falls while magpie usage is worked out.
+func midnightUsageClock(t *testing.T, midnight time.Time) {
+ t.Helper()
+ var read atomic.Bool
+ old := stats.Clock
+ stats.Clock = func() time.Time {
+ if read.Swap(true) {
+ return midnight.Add(100 * time.Millisecond)
+ }
+ return midnight.Add(-100 * time.Millisecond)
+ }
+ t.Cleanup(func() { stats.Clock = old })
+}
+
// magpie usage --csv writes the period's requests, newest first, one row
// each, with the model asked for, sent and served.
func TestUsageCSV(t *testing.T) {
groupsHome(t)
- y, m, d := time.Now().Date()
- day := time.Date(y, m, d, 0, 0, 0, 0, time.Local) // today's, whenever this runs
+ day := stats.Today.Since(holdUsageClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local)))
stats.Append(stats.Record{Time: day.Add(time.Second), Agent: "codex", Provider: "a", Model: "m", Requested: "fast", Served: "m-mini", Input: 10, Output: 2, Millis: 700, Status: 200})
stats.Append(stats.Record{Time: day.Add(2 * time.Second), Agent: "claude", Provider: "b", Model: "gpt-5.5", Requested: "b/gpt-5.5", Millis: 30, Status: 502})
stats.Append(stats.Record{Time: day.AddDate(0, 0, -3), Agent: "pi", Provider: "a", Model: "m", Input: 1, Status: 200})
@@ -78,7 +105,7 @@ func TestUsageProviderKeys(t *testing.T) {
// does (Kumo31 on Discord: Codex used outside magpie wasn't in it).
func TestUsageShowsCallsNotThroughMagpie(t *testing.T) {
groupsHome(t)
- now := time.Now()
+ now := holdUsageClock(t, time.Date(2026, 9, 30, 12, 0, 0, 0, time.Local))
stats.Append(stats.Record{Time: now.Add(-time.Minute), Agent: "claude", Provider: "relay", Model: "m", Input: 5, Status: 200})
old := stats.LogCalls
stats.LogCalls = func(time.Time) []sessions.Call {
@@ -104,3 +131,26 @@ func TestUsageShowsCallsNotThroughMagpie(t *testing.T) {
t.Fatal(b.String(), err)
}
}
+
+// magpie usage asked as midnight falls shows the calls through magpie and
+// those not through it of one day, the day it was asked on. Asked apart,
+// the calls not through magpie were the next day's, none yet, and left out.
+func TestUsageAskedAtMidnight(t *testing.T) {
+ groupsHome(t)
+ midnight := time.Date(2026, 10, 1, 0, 0, 0, 0, time.Local)
+ stats.Append(stats.Record{Time: midnight.Add(-time.Minute), Agent: "claude", Provider: "relay", Model: "m", Input: 5, Status: 200})
+ old := stats.LogCalls
+ stats.LogCalls = func(time.Time) []sessions.Call {
+ return []sessions.Call{{Time: midnight.Add(-2 * time.Minute), Agent: "codex", Session: "c1", Model: "gpt-6-luna", Tokens: sessions.Tokens{Input: 1200, Output: 300}}}
+ }
+ t.Cleanup(func() { stats.LogCalls = old })
+ midnightUsageClock(t, midnight)
+ var b strings.Builder
+ if err := usageTo(&b, []string{"usage", "today"}); err != nil {
+ t.Fatal(err)
+ }
+ gw, own, ok := strings.Cut(b.String(), "not through magpie")
+ if !ok || !strings.Contains(gw, "relay/m") || !strings.Contains(own, "gpt-6-luna") {
+ t.Fatal(b.String())
+ }
+}