Files
magpie/usage_cli_test.go
T
yetone 1e9f3a46cb usage: a Requests tab lists every request of the period, one row each, and exports them as CSV, from the window and from magpie usage --csv (Discord 范不着: a per-request ledger as sub2api keeps, to set beside the upstream's own bill) — the Usage page summed calls by agent and model, and the log did not keep the model the agent asked for or the one the reply said answered, so a single request could not be checked against a vendor's bill. usage.Record now keeps Requested (req, the model id the client sent, a magpie alias or group as it came) and Served (served, the model the vendor's reply named, only when it named one); every usage.Append in the gateway fills them (the relayed and translated paths, Codex's backend, image draws, decide), and records written before load as they did, without them. The swap test the Routing page uses (a dated or pinned name is the same model) moved to usage.Swapped so the ledger marks the same rows. usage.Ledger gives the period's records newest first with each one's list-price cost, the agent it is counted under and whether the model was swapped, filtered by agent, failures alone and a search over the models, provider, account, session and effort; usage.WriteCSV writes them (time, agent, requested_model, provider, host, model, served_model, swapped, effort, input, output, cache write, cache read and reasoning tokens, cost_usd, duration_ms, ttft_ms, status, error, session, kind). The Usage page gets a third tab, Requests (请求), between Overview and Sessions, under the same period picker: an agent filter, All/Failed, a search field and Export CSV over a table of time, agent, requested model, provider · account, model sent, served model (amber with the Routing page's sentence when another model answered, plain on a dated name, a dash when the reply named none), effort, in, out, cache write, cache read, cost, duration and status, a failure's row marked by a red dot and bold code; the totals over it and 100 rows a page with Newer and Older. GET /api/usage/requests serves a page, POST /api/usage/requests/export saves all the filtered rows to Downloads (never over an earlier file) and opens the folder, GET /api/usage/requests.csv hands a browser the file in magpie web. The table scrolls sideways in its own box; at the window's narrowest the words after the head's cost give way so the three tabs fit without the page scrolling sideways. No border stripe; clicks move nothing (the pager held where it was). en and zh strings together. Tested with TestLedger (order, cost, swap, a dated name, an old record without the fields, each filter, the CSV exactly), TestServedModelRecorded and TestServedModelUnnamed (a request through a fake upstream answering with another model records Requested fake/sol, Model sol and the served name; one naming none leaves Served empty), TestUsageCSV (magpie usage --csv, a period, a stray argument refused), TestUsageLedgerRoutes (paging, totals, filters, export to Downloads twice) and a new internal/gui/tests/usage-ledger.test.cjs in Chromium and WebKit, en and zh, light and dark: the tabs and columns, the swap tag on the swapped row only, the 429 row, Older and Newer over 130 requests with the pager left where it was, the agent, Failed, search and period filters asking the server from the first page, Export CSV posting the filters with no page and saying where the file went, at 560 wide no tab scrolling the page sideways and the cost uncut, no left border. go build -tags nogui (and GOOS=linux, GOOS=windows), go test -tags nogui ./... and the whole GUI suite (122 tests) pass. Not tried in the real window through Wails or against a live gateway's log.
2026-09-29 21:26:01 +08:00

56 lines
2.1 KiB
Go

package main
import (
"encoding/csv"
"strings"
"testing"
"time"
stats "github.com/yetone/magpie/internal/usage"
)
// 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
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})
var b strings.Builder
if err := usageTo(&b, []string{"usage", "--csv", "today"}); err != nil {
t.Fatal(err)
}
recs, err := csv.NewReader(strings.NewReader(b.String())).ReadAll()
if err != nil {
t.Fatal(err)
}
if len(recs) != 3 || strings.Join(recs[0], ",") != strings.Join(stats.CSVHeader, ",") {
t.Fatalf("%q", recs)
}
col := func(row []string, name string) string {
for i, h := range recs[0] {
if h == name {
return row[i]
}
}
t.Fatalf("no column %s", name)
return ""
}
if r := recs[1]; col(r, "agent") != "claude" || col(r, "status") != "502" || col(r, "error") != "true" || col(r, "requested_model") != "b/gpt-5.5" {
t.Errorf("newest %q", r)
}
if r := recs[2]; col(r, "requested_model") != "fast" || col(r, "model") != "m" || col(r, "served_model") != "m-mini" || col(r, "swapped") != "true" ||
col(r, "input_tokens") != "10" || col(r, "output_tokens") != "2" || col(r, "duration_ms") != "700" || col(r, "error") != "false" {
t.Errorf("swapped %q", r)
}
b.Reset()
if err := usageTo(&b, []string{"usage", "all", "--csv"}); err != nil || strings.Count(b.String(), "\n") != 4 {
t.Fatalf("all: %v %q", err, b.String())
}
if err := usageTo(&b, []string{"usage", "--csv", "today", "extra"}); err == nil {
t.Fatal("took an extra argument")
}
}