diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index c289efab..07922c97 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -335,6 +335,38 @@ func PriceOf(providerID, modelID string) (Price, bool) { return Price{}, false } +// PricedBy is the list price the first of providers (models.dev ids) +// pricing a model of this id gives it: by its own key first, then by its +// id without a path or case ("openai/GPT-6-Sol" is gpt-6-sol), a Bedrock +// profile's geography, a "(variant)" or a ":tag". +func PricedBy(providers []string, id string) (Price, bool) { + all := load() + for _, pid := range providers { + if m, ok := all[pid].Models[id]; ok && m.Cost != nil { + return *m.Cost, true + } + } + b := bareID(id) + if r, ok := unprofiled(b); ok { + b = r + } + for _, want := range []string{b, cutAt(b, '('), cutAt(b, ':')} { + for _, pid := range providers { + keys := make([]string, 0, len(all[pid].Models)) + for key, m := range all[pid].Models { + if m.Cost != nil && bareID(key) == want { + keys = append(keys, key) + } + } + if len(keys) > 0 { + slices.Sort(keys) + return *all[pid].Models[keys[0]].Cost, true + } + } + } + return Price{}, false +} + // APIOf is the API a models.dev provider's model is served on, when the // catalog says it's one of its own: "responses" or "anthropic" for a model // OpenCode serves through OpenAI's or Anthropic's SDK, not the diff --git a/internal/provider/models.go b/internal/provider/models.go index 36aec6b1..8368ed30 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -511,6 +511,25 @@ var makerCatalogs = sync.OnceValue(func() []string { return out }) +// ListPrice is a model's list price as its vendor's models.dev entry gives +// it, else as its maker's does (#224): a subscription (Codex's ChatGPT +// account, Copilot) or a relay with no models.dev id of its own is priced +// at gpt-6-astra's or gemini-3.8-flash's maker's price, as a Claude +// account is at Anthropic's. +func (p Provider) ListPrice(model string) (catalog.Price, bool) { + if pr, ok := catalog.PricedBy(p.Catalogs(), model); ok { + return pr, true + } + return MakerPrice(model) +} + +// MakerPrice is a model's list price as the first vendor among the presets +// that makes the models it serves lists it; for a call whose provider has +// gone since. +func MakerPrice(model string) (catalog.Price, bool) { + return catalog.PricedBy(makerCatalogs(), model) +} + // Chosen reports whether a model is exposed. func (p Provider) Chosen(id string) bool { for _, m := range p.Exposed() { diff --git a/internal/usage/ledger.go b/internal/usage/ledger.go index 392a357b..7a585a3b 100644 --- a/internal/usage/ledger.go +++ b/internal/usage/ledger.go @@ -103,7 +103,8 @@ func ledger(since time.Time, f Filter, recs []Record) (rows []Row, sum Totals, a return rows, sum, agents } -// pricer looks up each call's list price, once per provider and model. +// pricer looks up each call's list price, once per provider and model: its +// provider's models.dev entry's, else its maker's (a subscription's, #224). func pricer() func(Record) *catalog.Price { prices := map[string]*catalog.Price{} all := provider.All() @@ -113,17 +114,16 @@ func pricer() func(Record) *catalog.Price { return pr } var pr *catalog.Price + v, ok := provider.MakerPrice(r.Model) for _, p := range all { if p.ID == r.Provider { - for _, c := range p.Catalogs() { - if v, ok := catalog.PriceOf(c, r.Model); ok { - pr = &v - break - } - } + v, ok = p.ListPrice(r.Model) break } } + if ok { + pr = &v + } prices[k] = pr return pr } diff --git a/internal/usage/ledger_test.go b/internal/usage/ledger_test.go index e899fbd3..3cbf214f 100644 --- a/internal/usage/ledger_test.go +++ b/internal/usage/ledger_test.go @@ -1,6 +1,7 @@ package usage import ( + "math" "os" "path/filepath" "strings" @@ -89,3 +90,67 @@ func TestLedger(t *testing.T) { t.Fatalf("csv:\n%s\nwant:\n%s", b.String(), want) } } + +// A call a subscription served — Codex's ChatGPT account, Copilot — or a +// relay with no models.dev id of its own is priced at its model's maker's +// list price, as a Claude account's is at Anthropic's (#224); a model +// named with its maker's path is the same model; one no maker lists stays +// unpriced. +func TestSubscriptionListPrice(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", 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-astra":{"id":"gpt-6-astra","cost":{"input":10,"output":50,"cache_read":1,"cache_write":12.5}}, + "gpt-6-luna":{"id":"gpt-6-luna","cost":{"input":0.1,"output":0.5,"cache_read":0.01,"cache_write":0.125}}}}, + "google":{"id":"google","models":{ + "gemini-3.8-flash":{"id":"gemini-3.8-flash","cost":{"input":0.75,"output":3.75,"cache_read":0.075}}}}, + "anthropic":{"id":"anthropic","models":{ + "claude-opus-5-5":{"id":"claude-opus-5-5","cost":{"input":4,"output":20,"cache_read":0.2,"cache_write":5}}}}}`), 0o644) + catalog.Reset() + t.Cleanup(catalog.Reset) + if err := provider.Save(provider.Provider{ID: "relay", Name: "Relay", Key: "k", Chat: "https://relay.example/v1"}); err != nil { + t.Fatal(err) + } + now := time.Now() + calls := []struct { + prov, model string + want float64 + }{ + {"codex", "gpt-6-astra", (1e6*10 + 1e5*50 + 1e6*1) / 1e6}, + {"codex", "gpt-6-luna", (1e6*0.1 + 1e5*0.5 + 1e6*0.01) / 1e6}, + {"copilot", "gemini-3.8-flash", (1e6*0.75 + 1e5*3.75 + 1e6*0.075) / 1e6}, + {"copilot", "gpt-6-luna", (1e6*0.1 + 1e5*0.5 + 1e6*0.01) / 1e6}, + {"claude", "claude-opus-5-5", (1e6*4 + 1e5*20 + 1e6*0.2) / 1e6}, + {"relay", "openai/gpt-6-astra", (1e6*10 + 1e5*50 + 1e6*1) / 1e6}, + {"relay", "mystery-1", 0}, + } + for i, c := range calls { + Append(Record{Time: now.Add(-time.Duration(len(calls)-i) * time.Minute), Agent: "codex", Provider: c.prov, Model: c.model, + Input: 1_000_000, Output: 100_000, CacheRead: 1_000_000, Status: 200}) + } + rows, sum, _ := Ledger(Month, Filter{}) + if len(rows) != len(calls) { + t.Fatalf("rows %d", len(rows)) + } + total := 0.0 + for i, c := range calls { + r := rows[len(calls)-1-i] + if r.Model != c.model || r.Priced != (c.want > 0) || math.Abs(r.Cost-c.want) > 1e-9 { + t.Errorf("%s/%s: priced=%v cost=%v, want %v", c.prov, c.model, r.Priced, r.Cost, c.want) + } + total += c.want + } + s := Summarize(Month) + if s.Unpriced != 1 || math.Abs(s.Cost-total) > 1e-9 || math.Abs(sum.Cost-total) > 1e-9 { + t.Fatalf("summary cost %v unpriced %d, ledger %v; want %v and 1", s.Cost, s.Unpriced, sum.Cost, total) + } + for _, m := range s.Models { + if m.Model != "mystery-1" && (m.Unpriced != 0 || m.Cost == 0) { + t.Errorf("model %s unpriced: %+v", m.ID, m.Totals) + } + } +}