Files
magpie/internal/gui/usage.go
T
yetone da834411b5 usage: with WebDAV or S3 sync, each computer shares its usage and sees the others', the total and each computer's part (#542) — sync carried keys, agents and the library, and each computer's Usage page counted only the calls made on it. Sync now has a Usage tick (off unless picked, usage=yes in magpie webdav/magpie s3): a computer with it on puts its own calls up as one file a day, magpie/usage/<id>-<day>.magpie-usage beside the bundle (under the prefix's usage/ on S3), each a gzipped list of times, agents, providers, models, tokens, costs and statuses, sealed with the sync passphrase (backup.SealData, the key cached per passphrase so a sync derives it once); no request body, session, archive name or key goes, a call another computer passed on over the LAN (Via) isn't put up again, and an error is cut to 300 characters. The id is a random one kept in usage-computer.json, which no backup carries, with the host name beside it. A computer only ever writes and deletes its own names, so none overwrites another's; the files are not in the bundle and its hash, so sharing usage never makes a sync or a conflict of the bundle; the first sync puts up the last 90 days, after that only yesterday and today, at most every 15 minutes (a Save or Sync now at once), and a day past 90 is deleted by the computer it is of. The others' files, listed (PROPFIND Depth 1; ListObjectsV2) and fetched only when changed, are kept in usage-others and read with the ledger, as Record.Computer; a failure is said in the sync row ("Couldn't share usage") and never fails the sync. The Requests page and the panel's Usage tab get a Computer pick (this computer, other computers when more than one, each by name) sending computer=this|others|<id>; a row from another computer says "· on <name>" beside its agent, and its details name the computer and say what was said stays there, nothing asked of this computer's session files for it. With no other computer's usage there is no pick and nothing new on either page, and an older magpie never lists or reads usage/. TestUsageSharedBetweenComputers (computers a and b over a fake WebDAV under httptest: each sees 4 calls, the totals and its own and the other's part, nothing counted twice over repeated syncs, the bundle not put again for usage, a new call reaching the other, a day past the window deleted), TestUsageSharedThroughS3 (the same through a fake S3, signed list and delete, a prefix with a space and a plus) and TestUsageOffAsksNothing are new and don't build without the change; usage-computers.test.cjs (WebKit, en and zh: no pick without other computers, the pick and computer=this, another computer's row and details with no /content asked, the panel's pick, the sync form's tick posting usage: true, no click moving the page) fails without it on all but the no-other-computers case. s3-sync, sync-other-kind, panel-usage, request-archive-usage, click-scroll and usage-ledger-chart pass in WebKit. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass (TestFXInState failed once on its TempDir cleanup, passes alone). Turning the Usage tick off, or sync off, lets the other computers' usage kept here go (DropAllShared), so the Usage page counts this computer's alone again: TestUsageSharedBetweenComputers checks both, and fails without it.
2026-10-02 17:47:36 +08:00

396 lines
13 KiB
Go

package gui
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"time"
"github.com/yetone/magpie/internal/access"
"github.com/yetone/magpie/internal/agent"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/sessions"
"github.com/yetone/magpie/internal/usage"
)
// usageGroup is a usage.Group with what the UI needs to draw it.
type usageGroup struct {
usage.Group
Name string `json:"name"`
Sub string `json:"sub,omitempty"` // models: the provider's name
Icon string `json:"icon,omitempty"` // a real logo, or "generic" for an unknown client
}
type usageJSON struct {
usage.Summary
Agents []usageGroup `json:"agents"`
Models []usageGroup `json:"models"`
CallerKeys []usageGroup `json:"callerKeys"`
Path string `json:"path"`
}
func usageState(p usage.Period) usageJSON {
s := usage.Summarize(p)
out := usageJSON{Summary: s, Agents: []usageGroup{}, Models: []usageGroup{}, Path: tilde(usage.Path())}
agents := map[string]*agent.Agent{}
for _, a := range agent.Clients() {
agents[a.ID] = a
}
for _, g := range s.Agents {
ug := usageGroup{Group: g, Name: g.ID, Icon: "generic"}
if a := agents[g.ID]; a != nil {
ug.Name, ug.Icon = a.Name, a.Icon
}
out.Agents = append(out.Agents, ug)
}
providers := map[string]provider.Provider{}
for _, p := range provider.All() {
providers[p.ID] = p
}
for _, g := range s.Models {
ug := usageGroup{Group: g, Name: g.Model, Sub: g.Provider, Icon: "generic"}
if p, ok := providers[g.Provider]; ok {
ug.Sub = p.Name
if p.Icon != "" {
ug.Icon = p.Icon
}
}
if g.Host != "" {
ug.Sub += " · " + g.Host
}
out.Models = append(out.Models, ug)
}
out.CallerKeys = callerUsageGroups(s)
return out
}
func callerUsageGroups(s usage.Summary) []usageGroup {
keys := []usageGroup{}
current, _ := access.List()
names := map[string]string{}
for _, k := range current {
names[k.ID] = k.Name
}
for _, g := range s.CallerKeys {
n := names[g.CallerKeyID]
if n == "" {
n = g.CallerKeyName
}
if n == "" {
n = g.CallerKeyID
}
keys = append(keys, usageGroup{Group: g, Name: n, Icon: "generic"})
}
return keys
}
func periodOf(s string) usage.Period {
switch p := usage.Period(s); p {
case usage.Today, usage.Week, usage.Month, usage.All:
return p
}
return usage.Month
}
func ledgerFilter(q url.Values) usage.Filter {
id, _ := strconv.ParseInt(q.Get("route"), 10, 64)
return usage.Filter{RouteID: id, Model: q.Get("model"), Agent: q.Get("agent"), Provider: q.Get("provider"), CallerKey: q.Get("callerKey"), Failed: q.Get("failed") == "1", Query: q.Get("q"), Computer: q.Get("computer")}
}
// ledgerRow is a usage.Row with the names the page shows it by.
type ledgerRow struct {
usage.Row
CallerKeyLabel string `json:"callerKeyLabel,omitempty"`
AgentName string `json:"agentName"`
Icon string `json:"icon"` // the agent's
ProviderName string `json:"providerName"`
Access string `json:"access,omitempty"` // known account/route type, independent of model maker
PricingModel string `json:"pricing_model,omitempty"`
// ComputerName is the other computer a call was made on, shared through sync (#542)
ComputerName string `json:"computerName,omitempty"`
}
type ledgerJSON struct {
CallerKeys []usageGroup `json:"callerKeys"`
Period usage.Period `json:"period"`
Rows []ledgerRow `json:"rows"`
Offset int `json:"offset"`
Total int `json:"total"` // the rows the filter keeps, on every page
// Series: those rows by hour, day or week (Bucket), for the chart
Bucket string `json:"bucket"`
Series []usage.SeriesPoint `json:"series"`
// By: the rows told apart by provider, agent and model, the most tokens
// first. The one by a dimension the filter has picked is of the rows
// without that pick, so the others are still there to switch to.
By map[string][]ledgerShare `json:"by"`
usage.Totals
// Agents and Providers: those with calls in the period, for the filters
Agents []ledgerAgent `json:"agents"`
Providers []ledgerAgent `json:"providers"`
// Computers: this one ("this", no name) and the others whose usage
// sync brought (#542), for the filter; none while there are none
Computers []ledgerShare `json:"computers,omitempty"`
}
// ledgerShare is a usage.Share with the name and logo the page shows it by.
type ledgerShare struct {
usage.Share
Name string `json:"name"`
Icon string `json:"icon,omitempty"`
}
type ledgerAgent struct {
ID string `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
}
// ledgerPage is one page of the ledger: limit rows (100 when none is
// given, 500 at most) from offset.
func ledgerPage(p usage.Period, f usage.Filter, offset, limit int) ledgerJSON {
l := usage.QueryPage(p, f, offset, limit)
offset = max(0, min(offset, l.Total))
page := l.Rows
agents := map[string]*agent.Agent{}
for _, a := range agent.Clients() {
agents[a.ID] = a
}
// A session file without route evidence is a local source, not a supplier.
names := map[string]string{usage.UnknownProvider: "Local session"}
icons := map[string]string{}
access := map[string]string{}
for _, pr := range provider.All() {
names[pr.ID], icons[pr.ID] = pr.Name, pr.Icon
if pr.Account != nil {
access[pr.ID] = "subscription"
} else if pr.Key != "" {
access[pr.ID] = "api"
}
}
who := func(id string) ledgerAgent {
if a := agents[id]; a != nil {
return ledgerAgent{ID: id, Name: a.Name, Icon: a.Icon}
}
return ledgerAgent{ID: id, Name: id, Icon: "generic"}
}
which := func(id string) ledgerAgent {
a := ledgerAgent{ID: id, Name: names[id], Icon: icons[id]}
if a.Name == "" {
a.Name = id
}
if a.Icon == "" {
a.Icon = "generic"
}
return a
}
out := ledgerJSON{Period: p, Rows: make([]ledgerRow, 0, len(page)), Offset: offset, Total: l.Total, Totals: l.Sum, Agents: []ledgerAgent{}, Providers: []ledgerAgent{}}
out.Bucket, out.Series = l.Bucket, l.Series
out.CallerKeys = callerUsageGroups(usage.Summary{CallerKeys: l.CallerKeys})
callerLabels := map[string]string{}
for _, g := range out.CallerKeys {
callerLabels[g.CallerKeyID] = g.Name
}
for _, r := range page {
a := who(r.Agent)
lr := ledgerRow{Row: r, AgentName: a.Name, Icon: a.Icon, ProviderName: names[r.Provider]}
if r.Source != "log" {
lr.Access = access[r.Provider]
}
if r.Priced {
if model := provider.PricedName(r.Model); model != r.Model {
lr.PricingModel = model
}
}
if lr.ProviderName == "" {
lr.ProviderName = r.Provider
}
lr.CallerKeyLabel = callerLabels[r.CallerKeyID]
if r.Computer != "" {
lr.ComputerName = l.Names[r.Computer]
if lr.ComputerName == "" {
lr.ComputerName = r.Computer
}
}
out.Rows = append(out.Rows, lr)
}
for _, id := range l.Agents {
out.Agents = append(out.Agents, who(id))
}
for _, id := range l.Providers {
out.Providers = append(out.Providers, which(id))
}
// what each is of: the rows of the filter, or, for the dimension the
// filter has picked, of the rows without that pick
out.By = map[string][]ledgerShare{}
for _, d := range usage.Dimensions {
shares := []ledgerShare{}
for _, s := range l.By[d] {
ls := ledgerShare{Share: s, Name: s.ID}
switch d {
case "provider":
a := which(s.ID)
ls.Name, ls.Icon = a.Name, a.Icon
case "agent":
a := who(s.ID)
ls.Name, ls.Icon = a.Name, a.Icon
}
shares = append(shares, ls)
}
out.By[d] = shares
}
for _, s := range l.Computers {
ls := ledgerShare{Share: s, Name: l.Names[s.ID]}
if s.ID != usage.ThisComputer && ls.Name == "" {
ls.Name = s.ID
}
out.Computers = append(out.Computers, ls)
}
return out
}
// contentJSON is what was said in a request, or why it can't be told.
type contentJSON struct {
Found bool `json:"found"`
// Why not: "session" (the request named none), "agent" (magpie reads the session
// files of Claude Code, Claude Desktop and Codex only), "missing" (the files have no
// such call: deleted, moved, or not written yet) or "read" (the file wouldn't read)
Why string `json:"why,omitempty"`
Model string `json:"model,omitempty"`
sessions.Content
}
// requestContent finds the call a request is in its agent's session files and reads it.
func requestContent(agent, session string, from, to, at time.Time) contentJSON {
out := contentJSON{Content: sessions.Content{Input: []sessions.Part{}, Output: []sessions.Part{}}}
switch {
case session == "":
out.Why = "session"
case agent != "claude" && agent != "claude-desktop" && agent != "codex":
out.Why = "agent"
default:
c, ok := sessions.FindCall(session, from, to, at)
if !ok {
out.Why = "missing"
break
}
content, err := sessions.ContentOf(c)
if err != nil {
out.Why = "read"
break
}
out.Found, out.Model, out.Content = true, c.Model, content
}
return out
}
func usageRoutes(mux *http.ServeMux, w Windows) {
mux.HandleFunc("GET /api/usage", func(rw http.ResponseWriter, r *http.Request) {
p := usage.Period(r.URL.Query().Get("period"))
switch p {
case usage.Today, usage.Week, usage.Month, usage.All:
default:
p = usage.Month
}
writeJSON(rw, usageState(p))
})
// the ledger: the period's calls, newest first, a page at a time
mux.HandleFunc("GET /api/usage/requests", func(rw http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
offset, _ := strconv.Atoi(q.Get("offset"))
limit, _ := strconv.Atoi(q.Get("limit"))
writeJSON(rw, ledgerPage(periodOf(q.Get("period")), ledgerFilter(q), offset, limit))
})
// what was said in one request, read from the agent's session file when the
// row is opened, between two times (the call's own, or a gateway request's
// span): magpie keeps no copy
mux.HandleFunc("GET /api/usage/requests/content", func(rw http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
from, err1 := time.Parse(time.RFC3339Nano, q.Get("from"))
to, err2 := time.Parse(time.RFC3339Nano, q.Get("to"))
if err1 != nil || err2 != nil {
http.Error(rw, "from and to are times", http.StatusBadRequest)
return
}
// at: when the call should have ended (a gateway request's start and
// its time); none is the window's end
at, err := time.Parse(time.RFC3339Nano, q.Get("at"))
if err != nil {
at = to
}
writeJSON(rw, requestContent(q.Get("agent"), q.Get("session"), from, to, at))
})
// the same CSV to a browser (magpie web), which saves it itself
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))
rw.Header().Set("Content-Type", "text/csv; charset=utf-8")
rw.Header().Set("Content-Disposition", `attachment; filename="magpie-requests-`+string(p)+"-"+time.Now().Format("2006-01-02")+`.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))
var b bytes.Buffer
if err := usage.WriteCSV(&b, rows); err != nil {
fail(rw, err)
return
}
dir := downloads()
stamp := "magpie-requests-" + string(p) + "-" + time.Now().Format("2006-01-02")
name := filepath.Join(dir, stamp+".csv")
for i := 2; ; i++ { // never over an earlier one
if _, err := os.Stat(name); err != nil {
break
}
name = filepath.Join(dir, fmt.Sprintf("%s-%d.csv", stamp, i))
}
if err := edit.WriteAtomic(name, b.Bytes()); err != nil {
fail(rw, err)
return
}
_ = w.OpenFolder(dir) // saved either way; the path is in the answer
writeJSON(rw, map[string]any{"path": tilde(name), "rows": len(rows)})
})
// The subscriptions' quotas, the plans' bought with a key, and the
// keys' balances come from the
// vendors, which can be slow or unreachable, so the page asks for them
// apart from the local log.
// ?asked=1 is the user opening or refreshing the page: Claude Code's
// own /usage is run at once (provider.AskClaudeUsage).
mux.HandleFunc("GET /api/usage/quotas", func(rw http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("asked") != "" {
provider.AskClaudeUsage()
}
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
defer cancel()
writeJSON(rw, provider.Quotas(ctx))
})
// spends one of a Codex account's rate-limit resets, which the page
// has asked the user about first; what it did comes back
mux.HandleFunc("POST /api/usage/codex-reset", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ User string }
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
out, err := provider.UseCodexReset(ctx, in.User)
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, out)
})
}