From 7909de22aca8b0935e45bba6791ca0be4a2956cc Mon Sep 17 00:00:00 2001 From: yetone Date: Sun, 4 Oct 2026 23:03:12 +0800 Subject: [PATCH] =?UTF-8?q?usage:=20magpie=20usage=20and=20the=20TUI's=20U?= =?UTF-8?q?sage=20page=20show=20the=20agents'=20own=20calls,=20read=20from?= =?UTF-8?q?=20their=20session=20files,=20beside=20the=20gateway's,=20and?= =?UTF-8?q?=20a=20Codex-based=20client's=20calls=20aren't=20counted=20twic?= =?UTF-8?q?e=20(Kumo31=20on=20Discord:=20Codex=20used=20directly,=20not=20?= =?UTF-8?q?through=20magpie,=20was=20on=20the=20window's=20Usage=20tab=20b?= =?UTF-8?q?ut=20not=20in=20magpie=20usage)=20=E2=80=94=20magpie=20usage=20?= =?UTF-8?q?and=20the=20TUI=20only=20ever=20summed=20the=20gateway's=20log?= =?UTF-8?q?=20(usage.Summarize);=20the=20calls=20the=20agents'=20session?= =?UTF-8?q?=20files=20record=20that=20the=20gateway=20never=20saw=20(Codex?= =?UTF-8?q?=20on=20the=20user's=20own=20ChatGPT=20sign-in,=20Claude=20Code?= =?UTF-8?q?,=20Claude=20Desktop=E2=80=A6)=20were=20merged=20only=20into=20?= =?UTF-8?q?the=20ledger=20the=20window's=20Requests=20tab=20and=20the=20tr?= =?UTF-8?q?ay=20panel=20read=20(#239).=20usage.Direct=20sums=20those=20row?= =?UTF-8?q?s=20of=20the=20ledger=20(Source=20"log",=20this=20computer's,?= =?UTF-8?q?=20rejected=20ones=20left=20out)=20by=20agent,=20model,=20accou?= =?UTF-8?q?nt=20and=20session,=20priced=20as=20the=20ledger=20prices=20the?= =?UTF-8?q?m,=20and=20magpie=20usage=20prints=20them=20after=20the=20gatew?= =?UTF-8?q?ay's=20tables=20under=20"not=20through=20magpie=20=C2=B7=20the?= =?UTF-8?q?=20agents'=20own=20requests,=20read=20from=20their=20session=20?= =?UTF-8?q?files"=20(a=20model=20whose=20provider=20the=20file=20doesn't?= =?UTF-8?q?=20tell=20reads=20"local=20session/");=20the=20TUI's=20U?= =?UTF-8?q?sage=20page=20shows=20the=20same=20block=20under=20the=20gatewa?= =?UTF-8?q?y's,=20each=20with=20its=20totals=20and=20agents'=20and=20model?= =?UTF-8?q?s'=20tables.=20The=20two=20parts=20are=20separate,=20so=20nothi?= =?UTF-8?q?ng=20is=20counted=20in=20both;=20a=20period=20with=20only=20the?= =?UTF-8?q?=20agents'=20own=20calls=20shows=20them=20under=20"no=20calls?= =?UTF-8?q?=20through=20magpie".=20All=20of=20magpie=20usage's=20output=20?= =?UTF-8?q?now=20goes=20to=20the=20writer=20it=20is=20given.=20Checking=20?= =?UTF-8?q?it=20against=20the=20owner's=20real=20data=20showed=20the=20led?= =?UTF-8?q?ger=20counted=20a=20Codex-based=20client's=20calls=20twice:=20m?= =?UTF-8?q?iyi-helper's=20requests=20through=20the=20gateway=20are=20logge?= =?UTF-8?q?d=20under=20its=20own=20name=20while=20its=20Codex=20session=20?= =?UTF-8?q?file=20names=20Codex,=20and=20the=20gateway/session-file=20matc?= =?UTF-8?q?her=20keyed=20on=20the=20agent,=20so=20they=20never=20matched;?= =?UTF-8?q?=20the=20matcher=20now=20keys=20on=20the=20session,=20tokens=20?= =?UTF-8?q?and=20time=20alone=20(a=20session=20id=20names=20one=20conversa?= =?UTF-8?q?tion),=20which=20also=20corrects=20the=20Requests=20tab.=20With?= =?UTF-8?q?=20read-only=20copies=20of=20the=20owner's=20~/.config/magpie?= =?UTF-8?q?=20and=20four=20days=20of=20~/.codex/sessions=20in=20a=20sandbo?= =?UTF-8?q?x=20HOME:=20magpie=20usage=20week=20printed=20177=20calls=20not?= =?UTF-8?q?=20through=20magpie,=20in=201.3M,=20out=2052.4K,=20cache=20read?= =?UTF-8?q?=203.9M,=20gpt-6-astra=20154=20and=20gpt-6-sol=2023=20=E2=80=94?= =?UTF-8?q?=20the=20same=20rows,=20to=20the=20token,=20as=20usage.LedgerOf?= =?UTF-8?q?=20(what=20/api/usage/requests=20serves)=20gave=20for=20the=20p?= =?UTF-8?q?eriod;=20before=20the=20matcher=20fix,=20today's=207=20miyi-hel?= =?UTF-8?q?per=20calls=20showed=20in=20both=20parts.=20TestDirectCountsOnl?= =?UTF-8?q?ySessionFileCalls=20(the=20miyi-helper=20case=20included),=20Te?= =?UTF-8?q?stUsageShowsCallsNotThroughMagpie=20and=20TestUsagePageShowsCal?= =?UTF-8?q?lsNotThroughMagpie=20fail=20on=20the=20old=20code.=20go=20vet,?= =?UTF-8?q?=20builds=20(darwin,=20GOOS=3Dlinux=20nogui,=20GOOS=3Dwindows)?= =?UTF-8?q?=20and=20go=20test=20-tags=20nogui=20-count=3D1=20./...=20under?= =?UTF-8?q?=20a=20temp=20HOME=20pass.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/pages.go | 43 +++++++++++++++++----- internal/tui/tui.go | 3 +- internal/tui/usage_test.go | 25 +++++++++++++ internal/usage/direct.go | 64 +++++++++++++++++++++++++++++++++ internal/usage/direct_test.go | 65 ++++++++++++++++++++++++++++++++++ internal/usage/request_page.go | 14 +++++--- usage_cli.go | 62 +++++++++++++++++++++++--------- usage_cli_test.go | 33 +++++++++++++++++ 8 files changed, 277 insertions(+), 32 deletions(-) create mode 100644 internal/usage/direct.go create mode 100644 internal/usage/direct_test.go diff --git a/internal/tui/pages.go b/internal/tui/pages.go index 531a1389..baf3d45a 100644 --- a/internal/tui/pages.go +++ b/internal/tui/pages.go @@ -723,7 +723,7 @@ func (m model) updateUsage(msg tea.KeyMsg) (tea.Model, tea.Cmd) { default: return m, nil } - m.sum = usage.Summarize(m.period) + m.sum, m.direct = usage.Summarize(m.period), usage.Direct(m.period) return m, nil } @@ -806,10 +806,36 @@ func (m model) viewUsage() string { if len(ql) > 0 { b.WriteString("\n") } - if s.Calls == 0 { + d := m.direct + if s.Calls == 0 && d.Calls == 0 { b.WriteString(pad + " " + sMuted.Render("no calls in this time · route an agent through magpie and its usage shows up here")) return b.String() } + names := map[string]string{} + for _, a := range agent.All() { + names[a.ID] = a.Name + } + room := max(2, (m.h-16-len(ql))/2) + if s.Calls > 0 && d.Calls > 0 { + room = max(2, room/2) + } + if s.Calls == 0 { + b.WriteString(pad + " " + sMuted.Render("no calls through magpie in this time") + "\n\n") + } else { + m.usageBlock(&b, s, room, names) + } + // the calls the agents made on their own, as the app's Requests tab + // counts them beside the gateway's (Kumo31 on Discord) + if d.Calls > 0 { + b.WriteString(pad + " " + sFaint.Render("not through magpie · the agents' own requests, read from their session files") + "\n") + m.usageBlock(&b, d, room, names) + } + return strings.TrimRight(b.String(), "\n") +} + +// usageBlock is one summary's totals, timeline and agents' and models' +// tables, the gateway's or the agents' own calls. +func (m model) usageBlock(b *strings.Builder, s usage.Summary, room int, names map[string]string) { line := sName.Render(fmtTokens(s.Tokens())+" tokens") + sMuted.Render(fmt.Sprintf(" · %d call%s · ", s.Calls, plural(s.Calls))) + sOK.Render(fmtCost(s.Totals)) if s.Errors > 0 { line += sMuted.Render(" · ") + sBad.Render(fmt.Sprintf("%d error%s", s.Errors, plural(s.Errors))) @@ -835,11 +861,6 @@ func (m model) viewUsage() string { b.WriteString(pad + " " + sCursor.Render(sb.String()) + " " + sFaint.Render(first+" – "+last) + "\n\n") } - names := map[string]string{} - for _, a := range agent.All() { - names[a.ID] = a.Name - } - room := max(2, (m.h-16-len(ql))/2) table := func(head string, gs []usage.Group, name func(usage.Group) string) { b.WriteString(pad + " " + sFaint.Render(head) + "\n") w := 0 @@ -862,8 +883,12 @@ func (m model) viewUsage() string { } return g.ID }) - table("models", s.Models, func(g usage.Group) string { return g.ID }) - return strings.TrimRight(b.String(), "\n") + table("models", s.Models, func(g usage.Group) string { + if g.Provider == usage.UnknownProvider { + return g.Model // a session file's call whose provider isn't known + } + return g.ID + }) } // quotaLines is the accounts' allowances, a line each as the app's cards diff --git a/internal/tui/tui.go b/internal/tui/tui.go index e7dba8b1..3269f8a0 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -113,6 +113,7 @@ type model struct { gsel int // its member or rule picked: the members, then the rules period usage.Period sum usage.Summary + direct usage.Summary // the agents' own calls, read from their session files quotas []provider.SubscriptionQuota // nil while the vendors are asked qasked bool // quotas were asked for qleft bool // windows read as what is left, not what is used @@ -187,7 +188,7 @@ func (m *model) reload() { m.gsel = clamp(m.gsel, len(g.Members)+len(g.Rules)) } case pageUsage: - m.sum = usage.Summarize(m.period) + m.sum, m.direct = usage.Summarize(m.period), usage.Direct(m.period) case pageSessions: m.reloadSessions() case pageLibrary: diff --git a/internal/tui/usage_test.go b/internal/tui/usage_test.go index 5e7ac1ea..f52bc4fc 100644 --- a/internal/tui/usage_test.go +++ b/internal/tui/usage_test.go @@ -10,6 +10,7 @@ import ( "github.com/yetone/magpie/internal/fx" "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/sessions" "github.com/yetone/magpie/internal/settings" "github.com/yetone/magpie/internal/usage" ) @@ -109,3 +110,27 @@ func TestUnlimitedQuotaCell(t *testing.T) { } } } + +// The Usage page shows, under the gateway's calls, those the agents made +// on their own, read from their session files, as the app's Usage page +// counts them (Kumo31 on Discord: Codex used outside magpie wasn't in the +// TUI). +func TestUsagePageShowsCallsNotThroughMagpie(t *testing.T) { + home(t) + now := time.Now() + 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 { + return []sessions.Call{{Time: now.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 }) + m := press(t, model{w: 160, h: 60, page: pageUsage}, "t") + v := m.viewUsage() + gw, own, ok := strings.Cut(v, "not through magpie") + if !ok || !strings.Contains(gw, "relay/m") || strings.Contains(gw, "gpt-6-luna") || !strings.Contains(own, "gpt-6-luna") { + t.Fatal(v) + } + if strings.Contains(own, usage.UnknownProvider) { + t.Fatalf("the unknown provider's id is shown:\n%s", own) + } +} diff --git a/internal/usage/direct.go b/internal/usage/direct.go new file mode 100644 index 00000000..94b08924 --- /dev/null +++ b/internal/usage/direct.go @@ -0,0 +1,64 @@ +package usage + +import ( + "sort" + "time" +) + +// Direct is a period's calls the agents made on their own, which the gateway +// never saw: those their session files record (Codex, Claude Code, Claude +// Desktop…), summed up as Summarize sums the gateway's, by agent, model and +// account. The Usage page's Requests tab and the tray panel count them +// beside the gateway's; magpie usage and the TUI show them in a part of +// 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) +} + +func direct(p Period, now time.Time, rows []Row) Summary { + s := Summary{Period: p, Since: p.Since(now), Bucket: "day", Agents: []Group{}, Models: []Group{}, ProviderKeys: []Group{}, Accounts: []Group{}, CallerKeys: []Group{}, Sessions: []Group{}, Series: []Point{}} + if p != Today && p != Week && p != Month { + s.Period = All + } + agents, models, accounts, sessions := map[string]*Group{}, map[string]*Group{}, map[string]*Group{}, map[string]*Group{} + put := func(m map[string]*Group, k string, g Group, r Row) { + if m[k] == nil { + m[k] = &g + } + m[k].addRow(r) + } + for _, r := range rows { + if r.Source != "log" || r.IsRejected() || r.Time.Before(s.Since) { + continue + } + s.Totals.addRow(r) + id := AgentOf(r.Agent) + put(agents, id, Group{ID: id}, r) + put(models, r.Provider+"/"+r.Model, Group{ID: r.Provider + "/" + r.Model, Provider: r.Provider, Model: r.Model}, r) + if who := r.Account(); who != "" { + put(accounts, r.Provider+"@"+who, Group{ID: r.Provider + "@" + who, Provider: r.Provider, Account: who}, r) + } + if r.Session != "" { + put(sessions, id+"|"+r.Session, Group{ID: r.Session, Agent: id}, r) + } + } + list := func(m map[string]*Group) []Group { + gs := []Group{} + for _, g := range m { + gs = append(gs, *g) + } + 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 + }) + return gs + } + s.Agents, s.Models, s.Accounts, s.Sessions = list(agents), list(models), list(accounts), list(sessions) + return s +} diff --git a/internal/usage/direct_test.go b/internal/usage/direct_test.go new file mode 100644 index 00000000..5d05caf0 --- /dev/null +++ b/internal/usage/direct_test.go @@ -0,0 +1,65 @@ +package usage + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/sessions" +) + +// Direct sums up only the calls the agents made on their own, as their +// session files tell them — a Codex turn on the user's own ChatGPT sign-in, +// say — by agent, model and session, priced by their model; the gateway's +// calls stay Summarize's (Kumo31 on Discord: Codex used outside magpie was +// on the window's Usage tab but not in magpie usage). +func TestDirectCountsOnlySessionFileCalls(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + os.MkdirAll(filepath.Dir(catalog.CachePath()), 0o755) + os.WriteFile(catalog.CachePath(), []byte(`{"openai":{"id":"openai","models":{"gpt-6":{"id":"gpt-6","cost":{"input":2,"output":10}}}}}`), 0o644) + + t0 := time.Now() + now := time.Date(t0.Year(), t0.Month(), t0.Day(), 12, 0, 0, 0, t0.Location()) + 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 + // the agents' own + Append(Record{Time: now.Add(-30 * time.Minute), Agent: "miyi-helper", Provider: "codex", Model: "gpt-6", Input: 700, Output: 70, CacheRead: 300, Millis: 9000, Status: 200, Session: "h1"}) + old := LogCalls + LogCalls = func(time.Time) []sessions.Call { + return []sessions.Call{ + {Time: now.Add(-time.Hour), Agent: "codex", Session: "c1", Model: "gpt-6", Tokens: sessions.Tokens{Input: 1000, Output: 100}}, + {Time: now.Add(-2 * time.Hour), Agent: "codex", Session: "c1", Model: "gpt-6", Tokens: sessions.Tokens{Input: 3000, Output: 300}}, + {Time: now.Add(-30*time.Minute + 9500*time.Millisecond), Agent: "codex", Session: "h1", Model: "gpt-6", Tokens: sessions.Tokens{Input: 700, Output: 70, CacheRead: 300}}, + } + } + t.Cleanup(func() { LogCalls = old }) + + d := direct(Month, now, LedgerOf(Month, Filter{}).Rows) + if d.Calls != 2 || d.Input != 4000 || d.Output != 400 { + t.Fatalf("direct totals %+v", d.Totals) + } + if len(d.Agents) != 1 || d.Agents[0].ID != "codex" || d.Agents[0].Calls != 2 { + t.Fatalf("agents %+v", d.Agents) + } + if len(d.Models) != 1 || d.Models[0].Model != "gpt-6" || len(d.Sessions) != 1 || d.Sessions[0].ID != "c1" { + t.Fatalf("models %+v sessions %+v", d.Models, d.Sessions) + } + if d.Cost == 0 || d.Unpriced != 0 { + t.Fatalf("not priced: %+v", d.Totals) + } + // the gateway's calls are Summarize's alone + if s := Summarize(Month); s.Calls != 2 || len(s.Agents) != 2 { + t.Fatalf("summary %+v", s.Totals) + } + // a period that starts after the calls has none of them + if d := direct(Today, now.Add(48*time.Hour), LedgerOf(Month, Filter{}).Rows); d.Calls != 0 { + t.Fatalf("today two days on: %+v", d.Totals) + } +} diff --git a/internal/usage/request_page.go b/internal/usage/request_page.go index e94d609e..4769aadf 100644 --- a/internal/usage/request_page.go +++ b/internal/usage/request_page.go @@ -535,10 +535,14 @@ func visibleLocal(chunks []*rowChunk) map[rowRef]bool { // matchKey narrows candidates before comparing their end times. Request IDs // are consumed first; fallback matching still requires uniqueness both ways. +// The agent isn't in it: a session's id names the one conversation, and a +// client built on Codex (miyi-helper, say) is logged under its own name by +// the gateway while its Codex session file names Codex, so its calls were +// counted twice, once through magpie and once as not. type matchKey struct { - session, agent string - tokens [3]int64 - failed, hasID bool + session string + tokens [3]int64 + failed, hasID bool } // matchTokens is what a call's tokens are matched by: its input with what it @@ -623,7 +627,7 @@ func matchedBlocks(gateways []*rowChunk, chunks []*rowChunk, skip map[rowRef]boo if session == "" { continue } - key := matchKey{session, gateway.Strings[p.Text[0]], matchTokens(p.Tokens), p.Status >= 400 || p.Text[9] != 0, p.Text[11] != 0} + key := matchKey{session, matchTokens(p.Tokens), p.Status >= 400 || p.Text[9] != 0, p.Text[11] != 0} g := groups[key] if g == nil { g = &matchGroup{} @@ -647,7 +651,7 @@ func matchedBlocks(gateways []*rowChunk, chunks []*rowChunk, skip map[rowRef]boo if p.Tokens[0]+p.Tokens[1]+p.Tokens[2]+p.Tokens[3] == 0 && !failed { continue } - key := matchKey{session: c.Strings[p.Text[13]], agent: c.Strings[p.Text[0]], tokens: matchTokens(p.Tokens), failed: failed} + key := matchKey{session: c.Strings[p.Text[13]], tokens: matchTokens(p.Tokens), failed: failed} count, candidate := 0, (rowRef{}) // Without a local ID either gateway partition can match. With an ID, // only an unnamed gateway call can match (different IDs stay distinct). diff --git a/usage_cli.go b/usage_cli.go index d5b9340f..614d80fd 100644 --- a/usage_cli.go +++ b/usage_cli.go @@ -81,28 +81,47 @@ func usageTo(out io.Writer, args []string) error { } 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) 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 { - fmt.Println(muted.Render("no calls "+title+" ·"), "route an agent through magpie and its usage shows up here") - fmt.Println(faint.Render(" " + stats.Path())) + 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") + fmt.Fprintln(out, faint.Render(" "+stats.Path())) return nil } - fmt.Println(bold.Render(fmtTokens(s.Tokens())+" tokens"), muted.Render(title+" ·"), plural(s.Calls, "call"), muted.Render("·"), cost(s.Totals)) - fmt.Println(muted.Render(" in "+fmtTokens(s.Input)+" out "+fmtTokens(s.Output)+" cache read "+fmtTokens(s.CacheRead)+" cache write "+fmtTokens(s.CacheWrite)+" reasoning "+fmtTokens(s.Reasoning)), + names := map[string]string{} + for _, a := range agent.All() { + names[a.ID] = a.Name + } + if s.Calls == 0 { + fmt.Fprintln(out, muted.Render("no calls through magpie "+title)) + } else { + printUsage(out, s, title, names) + } + if d.Calls > 0 { + fmt.Fprintln(out) + fmt.Fprintln(out, muted.Render("not through magpie · the agents' own requests, read from their session files")) + printUsage(out, d, title, names) + } + fmt.Fprintln(out, faint.Render(" "+stats.Path())) + return nil +} + +// printUsage is a summary's totals and its tables: agents, models, +// upstream keys, accounts, gateway keys and the top sessions. +func printUsage(out io.Writer, s stats.Summary, title string, names map[string]string) { + fmt.Fprintln(out, bold.Render(fmtTokens(s.Tokens())+" tokens"), muted.Render(title+" ·"), plural(s.Calls, "call"), muted.Render("·"), cost(s.Totals)) + fmt.Fprintln(out, muted.Render(" in "+fmtTokens(s.Input)+" out "+fmtTokens(s.Output)+" cache read "+fmtTokens(s.CacheRead)+" cache write "+fmtTokens(s.CacheWrite)+" reasoning "+fmtTokens(s.Reasoning)), func() string { if s.Errors > 0 { return muted.Render(" · ") + plural(s.Errors, "error") } return "" }()) - - names := map[string]string{} - for _, a := range agent.All() { - names[a.ID] = a.Name - } table := func(head string, gs []stats.Group, name func(stats.Group) string) { - fmt.Println() - fmt.Println(faint.Render(" " + head)) + fmt.Fprintln(out) + fmt.Fprintln(out, faint.Render(" "+head)) w := 0 for _, g := range gs { w = max(w, len(name(g))) @@ -112,16 +131,27 @@ func usageTo(out io.Writer, args []string) error { if s.Tokens() > 0 { share = fmt.Sprintf("%3.0f%%", 100*float64(g.Tokens())/float64(s.Tokens())) } - fmt.Println(" "+pad(name(g), w), muted.Render(share), pad(fmtTokens(g.Tokens()), 7), faint.Render(pad(plural(g.Calls, "call"), 10)), cost(g.Totals), faint.Render(speed(g.Totals))) + fmt.Fprintln(out, " "+pad(name(g), w), muted.Render(share), pad(fmtTokens(g.Tokens()), 7), faint.Render(pad(plural(g.Calls, "call"), 10)), cost(g.Totals), faint.Render(speed(g.Totals))) } } + provider := func(id string) string { + if id == stats.UnknownProvider { + return "local session" + } + return id + } table("agents", s.Agents, func(g stats.Group) string { if n := names[g.ID]; n != "" { return n } return g.ID }) - table("models", s.Models, func(g stats.Group) string { return g.ID }) + table("models", s.Models, func(g stats.Group) string { + if g.Provider == stats.UnknownProvider { + return provider(g.Provider) + "/" + g.Model + } + return g.ID + }) if len(s.ProviderKeys) > 0 { table("upstream provider keys", s.ProviderKeys, func(g stats.Group) string { name := g.ProviderKeyName @@ -140,7 +170,7 @@ func usageTo(out io.Writer, args []string) error { if who == "" { who = "account not recorded" } - return g.Provider + " / " + who + return provider(g.Provider) + " / " + who }) } if len(s.CallerKeys) > 0 { @@ -165,8 +195,6 @@ func usageTo(out io.Writer, args []string) error { return n + " " + g.ID }) } - fmt.Println(faint.Render(" " + stats.Path())) - return nil } // speed is how long the timed calls took to their first token, and how diff --git a/usage_cli_test.go b/usage_cli_test.go index d513edda..76657cd5 100644 --- a/usage_cli_test.go +++ b/usage_cli_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/sessions" stats "github.com/yetone/magpie/internal/usage" ) @@ -71,3 +72,35 @@ func TestUsageProviderKeys(t *testing.T) { t.Fatal(csv.String()) } } + +// magpie usage shows, beside the gateway's calls, those the agents made on +// their own, read from their session files, as the window's Usage page +// does (Kumo31 on Discord: Codex used outside magpie wasn't in it). +func TestUsageShowsCallsNotThroughMagpie(t *testing.T) { + groupsHome(t) + now := time.Now() + 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 { + return []sessions.Call{{Time: now.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 }) + var b strings.Builder + if err := usageTo(&b, []string{"usage", "today"}); err != nil { + t.Fatal(err) + } + out := b.String() + gw, own, ok := strings.Cut(out, "not through magpie") + if !ok || !strings.Contains(gw, "relay/m") || strings.Contains(gw, "gpt-6-luna") || !strings.Contains(own, "gpt-6-luna") || !strings.Contains(own, "1.5K") { + t.Fatal(out) + } + + // a call of the agents' own from before this month shows under all + stats.LogCalls = func(time.Time) []sessions.Call { + return []sessions.Call{{Time: now.AddDate(0, 0, -40), Agent: "codex", Model: "gpt-6-luna", Tokens: sessions.Tokens{Input: 7}}} + } + b.Reset() + if err := usageTo(&b, []string{"usage", "all"}); err != nil || !strings.Contains(b.String(), "not through magpie") || !strings.Contains(b.String(), "gpt-6-luna") { + t.Fatal(b.String(), err) + } +}