Files
magpie/sessions_cli_test.go
T
Comic Chang 901ee08280 Count usage at the configured provider/model price (#278)
Magpie reports models.dev's list price for every call. That is the right
number for a vendor's own key and the wrong one for a provider that
resells or exposes a model at a different tariff, and it leaves a model the
catalogue does not list with no cost at all — a provider's own /models
gives ids, not economics.

  magpie model price relay-a/gpt-5.5 0.12,1.20,0.01,0.15
  magpie model price relay-a/* 0.12,1.20,0.01,0.15
  magpie model price relay-a/gpt-5.5 --reset
  magpie model prices

Absolute USD per million tokens. All four parts are required: a price
missing one would understate the rest of every call, and a hand-edited file
that left a part out would bill it at zero, so each part is a pointer and
the setter refuses a price that is negative, NaN or infinite, naming the
part at fault. Zero stays distinct throughout — a model served at no cost
has a price, not the absence of one. A `<provider>/*` entry covers every
model of that provider and an exact entry wins over it; --reset removes
only the exact entry and says so when a provider-wide one is still in
force.

The order a price is looked for in is the price for this model, then
`<provider>/*`, then the provider's own catalogue, then models.dev for the
maker. A price is a provider's tariff, not the model's: the same model
through two providers has two prices, and each keeps its own. A provider is
resolved by its id, or by an id it was renamed from, and never by its
display name — Find answers those too, and a record naming one was counted
at another provider's price.

provider.EffectivePrice is the one lookup every cost caller shares, so the
usage ledger and a session's totals both go through it, and a session
naming a model bare is still priced at its maker's list price, which is a
different question from what one provider charges. catalog.PriceOf and
PricedBy keep returning the models.dev price for anything wanting an
unbiased number. The background-task pickers (draw, search, vision) still
rank by catalogue price: they choose which model answers, not what a call
is reported to cost.

Unlike every other per-model preference this does not call
catalog.Touched. What a call costs is not what an agent picks a model by,
and the model lists magpie keeps in the agents' own files are not its to
rewrite over a number in a cost report. A rename moves the prices with it
through the walk #288 adds, so nothing here has to say so, and an id
recorded before a rename still resolves.

Validation happens where the entry is written, not in Save: one hand-edited
bad price in the file must not make saving a theme or a window size fail. A
price for a model the provider does not serve is refused the way
`magpie model name` refuses the same id, since it is a price that never
applies and nothing reports it as dead; a removal is exempt, so an entry
left for a model that has since gone can still be taken away.
2026-09-30 20:31:11 +08:00

127 lines
4.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/sessions"
"github.com/yetone/magpie/internal/settings"
)
// sessionsHome is a sandbox HOME with the sessions package's fixtures as
// Claude Code's and Codex's folders, their models priced, dates in UTC.
func sessionsHome(t *testing.T) time.Time {
t.Helper()
h := t.TempDir()
t.Setenv("HOME", h)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(h, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(h, ".cache"))
// OpenCode's and Pi's folders in the sandbox too (HOME isn't the home
// on Windows)
t.Setenv("XDG_DATA_HOME", filepath.Join(h, ".local", "share"))
t.Setenv("PI_CODING_AGENT_DIR", filepath.Join(h, ".pi", "agent"))
t.Setenv("OPENCODE_DB", "")
t.Setenv("PI_CODING_AGENT_SESSION_DIR", "")
for from, env := range map[string]string{"claude": "CLAUDE_CONFIG_DIR", "codex": "CODEX_HOME"} {
dir := filepath.Join(h, "."+from)
if err := os.CopyFS(dir, os.DirFS(filepath.Join("internal", "sessions", "testdata", from))); err != nil {
t.Fatal(err)
}
t.Setenv(env, dir)
}
oldZone, oldPrice := time.Local, sessions.PriceOf
time.Local = time.UTC
sessions.PriceOf = func(_ settings.Settings, m string) (catalog.Price, bool) {
switch m {
case "claude-opus-5-5":
return catalog.Price{Input: 4, Output: 20, CacheRead: 0.2, CacheWrite: 5}, true
case "gpt-6-astra":
return catalog.Price{Input: 10, Output: 50, CacheRead: 1}, true
}
return catalog.Price{}, false
}
t.Cleanup(func() {
time.Local, sessions.PriceOf = oldZone, oldPrice
sessions.Reset()
})
sessions.Reset()
return time.Date(2026, 9, 28, 12, 0, 0, 0, time.UTC)
}
func runSessions(t *testing.T, now time.Time, args ...string) string {
t.Helper()
var b bytes.Buffer
if err := sessionsTo(&b, args, now); err != nil {
t.Fatalf("%v: %v", args, err)
}
return b.String()
}
func wantAll(t *testing.T, out string, want ...string) {
t.Helper()
for _, w := range want {
if !strings.Contains(out, w) {
t.Errorf("missing %q in\n%s", w, out)
}
}
}
// magpie sessions lists the latest sessions; --days sums them day by day
// with the top models and folders; --model and --folder narrow both.
func TestSessionsCmd(t *testing.T) {
now := sessionsHome(t)
list := runSessions(t, now)
wantAll(t, list, "ago", "Codex", "it's", "Add a README", "Claude Code", "app", "Fix the login bug in auth.go", "latest 2")
if strings.Index(list, "Add a README") > strings.Index(list, "Fix the login") {
t.Error("not the latest first")
}
if got := runSessions(t, now, "--folder", "app"); strings.Contains(got, "README") || !strings.Contains(got, "login") {
t.Errorf("--folder app:\n%s", got)
}
week := runSessions(t, now, "--days", "7")
wantAll(t, week, "2.1K tokens last 7 days", "cache read 6.0K (75% hit)", "2026-09-22 – 2026-09-28",
"Tue Sep 22", "Wed Sep 23 2.1K", "Mon Sep 28", "████", "models", "gpt-6-astra", "100%", "folders", "/work/it's")
if strings.Contains(week, "Sep 21") || strings.Contains(week, "claude-opus") {
t.Errorf("outside the range:\n%s", week)
}
app := runSessions(t, now, "--days=all", "--folder", "/work/app")
wantAll(t, app, "all time · /work/app", "Sun Sep 20", "active 5m on 1 day", "claude-opus-5-5", "claude-haiku-4-5-20251001", "not priced: claude-haiku-4-5-20251001")
if strings.Contains(app, "folders") {
t.Errorf("folders under a folder:\n%s", app)
}
opus := runSessions(t, now, "stats", "--days", "all", "--model", "opus")
wantAll(t, opus, "all time · claude-opus-5-5", "active —", "folders", "/work/app")
var r sessions.Rollup
if err := json.Unmarshal([]byte(runSessions(t, now, "--days", "today", "--json")), &r); err != nil {
t.Fatal(err)
}
if r.From != "2026-09-28" || len(r.Days) != 1 || r.Spent() != 0 {
t.Errorf("today: %+v", r)
}
var ss []sessions.Session
if err := json.Unmarshal([]byte(runSessions(t, now, "--json", "--model", "gpt-6-astra")), &ss); err != nil {
t.Fatal(err)
}
if len(ss) != 1 || ss[0].Agent != "codex" || ss[0].Resume == "" {
t.Errorf("--json --model: %+v", ss)
}
var b bytes.Buffer
if err := sessionsTo(&b, []string{"--days", "7", "--model", "nope"}, now); err == nil || !strings.Contains(err.Error(), "gpt-6-astra") {
t.Errorf("an unknown model: %v", err)
}
if err := sessionsTo(&b, []string{"--days", "week"}, now); err == nil {
t.Error("--days week taken")
}
}