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.
127 lines
4.4 KiB
Go
127 lines
4.4 KiB
Go
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")
|
||
}
|
||
}
|