From cf6b82f70d267a00d615aafc690f8b23c59c120f Mon Sep 17 00:00:00 2001 From: yetone Date: Tue, 29 Sep 2026 22:34:40 +0800 Subject: [PATCH] =?UTF-8?q?usage:=20a=20Codex,=20Copilot=20or=20catalog-le?= =?UTF-8?q?ss=20relay=20call=20is=20priced=20at=20its=20maker's=20list=20p?= =?UTF-8?q?rice,=20as=20a=20Claude=20account's=20is=20(#224=20Koohoko:=20o?= =?UTF-8?q?n=20the=20Usage=20page=20only=20Claude=20models=20show=20a=20co?= =?UTF-8?q?st;=20gpt-6-astra=20and=20gpt-6-luna=20through=20Codex's=20Chat?= =?UTF-8?q?GPT=20account=20and=20gemini-3.8-flash=20and=20gpt-6-luna=20thr?= =?UTF-8?q?ough=20Copilot=20show=20tokens=20and=20no=20cost,=20and=20the?= =?UTF-8?q?=20Codex=20row=20reads=20=E2=89=88$153+)=20=E2=80=94=20the=20Us?= =?UTF-8?q?age=20page=20and=20the=20Requests=20list=20price=20a=20call=20b?= =?UTF-8?q?y=20the=20models.dev=20ids=20of=20the=20provider=20it=20went=20?= =?UTF-8?q?to=20(Provider.Catalog),=20and=20only=20the=20Claude=20account?= =?UTF-8?q?=20had=20one=20(anthropic):=20Codex's=20and=20Copilot's=20accou?= =?UTF-8?q?nts,=20like=20Grok's,=20WorkBuddy's=20and=20a=20relay=20added?= =?UTF-8?q?=20without=20a=20catalog,=20have=20none,=20so=20every=20call=20?= =?UTF-8?q?they=20served=20was=20counted=20unpriced=20although=20models.de?= =?UTF-8?q?v=20lists=20all=20these=20models=20(openai:=20gpt-6-astra=20$10?= =?UTF-8?q?/$50,=20gpt-6-luna=20$0.1/$0.5;=20google:=20gemini-3.8-flash=20?= =?UTF-8?q?$0.75/$3.75,=20per=20million,=20in=20the=20cached=20catalog).?= =?UTF-8?q?=20A=20call=20is=20now=20priced=20by=20its=20provider's=20catal?= =?UTF-8?q?ogs=20first,=20as=20before,=20else=20by=20the=20first=20maker?= =?UTF-8?q?=20among=20the=20vendor=20presets=20that=20lists=20the=20model?= =?UTF-8?q?=20(the=20same=20makerCatalogs=20the=20reasoning=20levels=20bor?= =?UTF-8?q?row=20from),=20and=20a=20call=20whose=20provider=20has=20gone?= =?UTF-8?q?=20since=20is=20priced=20by=20its=20maker=20too.=20The=20model?= =?UTF-8?q?=20is=20matched=20by=20its=20own=20key=20first,=20then=20withou?= =?UTF-8?q?t=20a=20path=20or=20case=20(a=20relay's=20openai/gpt-6-astra=20?= =?UTF-8?q?is=20gpt-6-astra),=20a=20Bedrock=20geography,=20a=20(variant)?= =?UTF-8?q?=20or=20:tag,=20as=20the=20reasoning=20levels=20already=20are?= =?UTF-8?q?=20(new=20catalog.PricedBy,=20Provider.ListPrice,=20provider.Ma?= =?UTF-8?q?kerPrice);=20a=20model=20no=20maker=20lists=20(a=20Cursor=20or?= =?UTF-8?q?=20Kiro=20alias,=20a=20relay's=20own=20name)=20stays=20unpriced?= =?UTF-8?q?=20and=20keeps=20the=20+=20as=20before,=20no=20price=20is=20mad?= =?UTF-8?q?e=20up.=20Both=20the=20Usage=20summary=20and=20the=20Requests?= =?UTF-8?q?=20ledger=20go=20through=20the=20one=20pricer,=20so=20they=20ag?= =?UTF-8?q?ree.=20Tested=20with=20a=20new=20TestSubscriptionListPrice:=20c?= =?UTF-8?q?alls=20to=20codex=20(gpt-6-astra,=20gpt-6-luna),=20copilot=20(g?= =?UTF-8?q?emini-3.8-flash,=20gpt-6-luna),=20claude=20(claude-opus-5-5)=20?= =?UTF-8?q?and=20a=20relay=20with=20no=20catalog=20(openai/gpt-6-astra,=20?= =?UTF-8?q?mystery-1)=20each=20get=20their=20maker's=20price=20in=20the=20?= =?UTF-8?q?ledger=20and=20the=20summary,=20mystery-1=20alone=20unpriced=20?= =?UTF-8?q?(fails=20on=20main:=20all=20seven=20unpriced);=20TestLedger=20a?= =?UTF-8?q?nd=20TestSummarize=20unchanged.=20go=20build=20-tags=20nogui=20?= =?UTF-8?q?(and=20GOOS=3Dlinux,=20GOOS=3Dwindows),=20go=20test=20-tags=20n?= =?UTF-8?q?ogui=20./...=20pass=20(TestAddSecondOfPreset=20failed=20once=20?= =?UTF-8?q?in=20the=20full=20run=20with=20a=20fourth=20provider=20and=20pa?= =?UTF-8?q?sses=20on=20its=20own=20and=20in=20the=20package=20run;=20it=20?= =?UTF-8?q?doesn't=20touch=20pricing).=20Not=20tried=20against=20the=20rep?= =?UTF-8?q?orter's=20usage=20log=20or=20in=20the=20GUI;=20the=20Sessions?= =?UTF-8?q?=20page's=20own=20pricing=20(internal/sessions)=20was=20not=20l?= =?UTF-8?q?ooked=20at.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/catalog/catalog.go | 32 +++++++++++++++++ internal/provider/models.go | 19 ++++++++++ internal/usage/ledger.go | 14 ++++---- internal/usage/ledger_test.go | 65 +++++++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 7 deletions(-) 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) + } + } +}