usage: a Codex, Copilot or catalog-less relay call is priced at its maker's list price, as a Claude account's is (#224 Koohoko: on the Usage page only Claude models show a cost; gpt-6-astra and gpt-6-luna through Codex's ChatGPT account and gemini-3.8-flash and gpt-6-luna through Copilot show tokens and no cost, and the Codex row reads ≈$153+) — the Usage page and the Requests list price a call by the models.dev ids of the provider it went to (Provider.Catalog), and only the Claude account had one (anthropic): Codex's and Copilot's accounts, like Grok's, WorkBuddy's and a relay added without a catalog, have none, so every call they served was counted unpriced although models.dev lists all these models (openai: gpt-6-astra $10/$50, gpt-6-luna $0.1/$0.5; google: gemini-3.8-flash $0.75/$3.75, per million, in the cached catalog). A call is now priced by its provider's catalogs first, as before, else by the first maker among the vendor presets that lists the model (the same makerCatalogs the reasoning levels borrow from), and a call whose provider has gone since is priced by its maker too. The model is matched by its own key first, then without a path or case (a relay's openai/gpt-6-astra is gpt-6-astra), a Bedrock geography, a (variant) or :tag, as the reasoning levels already are (new catalog.PricedBy, Provider.ListPrice, provider.MakerPrice); a model no maker lists (a Cursor or Kiro alias, a relay's own name) stays unpriced and keeps the + as before, no price is made up. Both the Usage summary and the Requests ledger go through the one pricer, so they agree. Tested with a new TestSubscriptionListPrice: calls to codex (gpt-6-astra, gpt-6-luna), copilot (gemini-3.8-flash, gpt-6-luna), claude (claude-opus-5-5) and a relay with no catalog (openai/gpt-6-astra, mystery-1) each get their maker's price in the ledger and the summary, mystery-1 alone unpriced (fails on main: all seven unpriced); TestLedger and TestSummarize unchanged. go build -tags nogui (and GOOS=linux, GOOS=windows), go test -tags nogui ./... pass (TestAddSecondOfPreset failed once in the full run with a fourth provider and passes on its own and in the package run; it doesn't touch pricing). Not tried against the reporter's usage log or in the GUI; the Sessions page's own pricing (internal/sessions) was not looked at.
This commit is contained in:
4 files changed
+123
-7
No files matched your search
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user