diff --git a/docs/reference.md b/docs/reference.md index 4842da32..bad5776d 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -168,6 +168,11 @@ deleted keys keep their historical identity. Older records appear as **key not recorded**, never inferred from today's configured key. These are upstream credentials, not keys clients use to call Magpie. +The CSV's `cache_write_tokens` is still every cache write; the last two +columns, `cache_write_5m_tokens` and `cache_write_1h_tokens`, split it by how +long Anthropic keeps it (a record from before the split is all 5-minute). +The app shows the split on a request's Cache and the usage tiles' tooltips. + It lists **accounts** too: each Codex, Claude or other subscription account's tokens and cost, by the account that actually answered — the one that took over after a failover, the one `X-Magpie-Account` pinned. The account is named @@ -443,7 +448,37 @@ magpie model prices # every model you pric The four numbers are USD per million tokens. All four are asked for, because a price missing one would understate the rest of every call; `0` is a model -served at no cost, which is a price, not the absence of one. +served at no cost, which is a price, not the absence of one. Decimals take a +point or, in the app's boxes, a comma (`0,25`). + +A **fifth number** is a 1-hour cache write's price. Anthropic bills a cache +write kept for 5 minutes at 1.25× input and one kept for an hour at 2× input, +and its usage says which were which (`cache_creation.ephemeral_5m_input_tokens` +/ `ephemeral_1h_input_tokens`, from the API and from Claude Code's +transcripts). The fourth number is the 5-minute price; the fifth, when not +given, is 2× input — Anthropic's rule, and only Anthropic reports 1-hour +writes, so for any other model it never comes into play. A call recorded +before magpie kept the split counts all its writes at the 5-minute price, as +it did. + +A **long-context price** is what the whole request costs once its prompt is +over a size, as OpenAI bills gpt-6-astra, gpt-6.1-sol and gpt-6-luna over +272K input (2× input and cache, 1.5× output): + +```sh +magpie model price openai/gpt-6-astra 10,50,1,12.5 --tier 272k 20,75,2,25 +``` + +The size counts the prompt the way OpenAI does: uncached input + cache reads ++ cache writes, and the tier applies when that is **over** the size (272,000 +is not, 272,001 is). Then every part of the call — input, output, cache — is +at the tier's price, not only the tokens past the size. `--tier` may be given +again for another size. The built-in prices carry a tier only where +models.dev lists one (`cost.tiers` of type `context`), as it does for those +three. Session and day totals sum many calls, so they are counted at the base +price; the ledger and each request are counted at the tier they reached. In +the app, a model's price row has a quiet **Long-context price** row under it, +shown with the list's tier greyed in, or behind a link where there is none. The order a price is looked for in is: **the price for this model → the price for `/*`, which covers every model of that provider → what the diff --git a/internal/agent/route.go b/internal/agent/route.go index 80c4e272..1f5345b4 100644 --- a/internal/agent/route.go +++ b/internal/agent/route.go @@ -139,7 +139,7 @@ func entryPrice(st settings.Settings, find func(string) (provider.Group, []provi var first catalog.Price for i, m := range ms { pr, ok := provider.EffectivePriceIn(st, m.Provider.ID, m.Model) - if !ok || m.Fast || i > 0 && pr != first { + if !ok || m.Fast || i > 0 && !pr.Same(first) { return catalog.Price{}, false } first = pr diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 3623d0b5..8c09fb53 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -102,18 +102,150 @@ type Price struct { Output float64 `json:"output"` CacheRead float64 `json:"cache_read"` CacheWrite float64 `json:"cache_write"` + // CacheWrite1h is what a cache write kept for an hour costs, Anthropic's + // 1-hour TTL, which it bills at 2× input where a 5-minute write (the + // CacheWrite above) is 1.25×. 0 is not given: such a write is then + // counted at 2× input, Anthropic's rule for every model, as only + // Anthropic's usage says which writes were for an hour. + CacheWrite1h float64 `json:"cache_write_1h,omitempty"` + // Tiers are the prices a request is billed at, whole, once its input + // goes over a size (OpenAI's gpt-6-astra: 2× input and cache, 1.5× + // output above 272K), lowest first. models.dev lists them as "tiers" of + // type "context". + Tiers []Tier `json:"tiers,omitempty"` +} + +// Tier is a price from a request's input size on: what the whole request +// costs once its input, cached and cache-written tokens included, is over +// Above tokens. +type Tier struct { + Above int `json:"above"` + Input float64 `json:"input"` + Output float64 `json:"output"` + CacheRead float64 `json:"cache_read"` + CacheWrite float64 `json:"cache_write"` + CacheWrite1h float64 `json:"cache_write_1h,omitempty"` +} + +// UnmarshalJSON reads a price as magpie writes it, and as models.dev does, +// whose tiers name their size as {"tier": {"type": "context", "size": N}} +// and may leave a part out, which is then the base price's. A tier of +// another type, or of no size, is left out. +func (p *Price) UnmarshalJSON(b []byte) error { + type flat Price + var raw struct { + flat + Tiers []struct { + Above int `json:"above"` + Input *float64 `json:"input"` + Output *float64 `json:"output"` + CacheRead *float64 `json:"cache_read"` + CacheWrite *float64 `json:"cache_write"` + CacheWrite1h *float64 `json:"cache_write_1h"` + Tier *struct { + Type string `json:"type"` + Size int `json:"size"` + } `json:"tier"` + } `json:"tiers"` + } + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + *p = Price(raw.flat) + p.Tiers = nil + or := func(v *float64, base float64) float64 { + if v != nil { + return *v + } + return base + } + for _, t := range raw.Tiers { + above := t.Above + if t.Tier != nil { + if t.Tier.Type != "" && t.Tier.Type != "context" { + continue + } + above = t.Tier.Size + } + if above <= 0 { + continue + } + p.Tiers = append(p.Tiers, Tier{Above: above, Input: or(t.Input, p.Input), Output: or(t.Output, p.Output), + CacheRead: or(t.CacheRead, p.CacheRead), CacheWrite: or(t.CacheWrite, p.CacheWrite), CacheWrite1h: or(t.CacheWrite1h, 0)}) + } + slices.SortFunc(p.Tiers, func(a, b Tier) int { return a.Above - b.Above }) + return nil +} + +// claudeOneHour gives a Claude model's list price its 1-hour cache write, +// which models.dev doesn't list: 2× input, as Anthropic bills it for every +// model, wherever the model has a (5-minute) cache write price at all. +func claudeOneHour(id string, p *Price) { + if p == nil || p.CacheWrite <= 0 || p.CacheWrite1h > 0 || !strings.Contains(strings.ToLower(id), "claude") { + return + } + p.CacheWrite1h = 2 * p.Input + for i := range p.Tiers { + if p.Tiers[i].CacheWrite1h <= 0 { + p.Tiers[i].CacheWrite1h = 2 * p.Tiers[i].Input + } + } } // Times is the price at r times each of its parts. func (p Price) Times(r float64) Price { - return Price{Input: p.Input * r, Output: p.Output * r, CacheRead: p.CacheRead * r, CacheWrite: p.CacheWrite * r} + out := Price{Input: p.Input * r, Output: p.Output * r, CacheRead: p.CacheRead * r, CacheWrite: p.CacheWrite * r, CacheWrite1h: p.CacheWrite1h * r} + for _, t := range p.Tiers { + out.Tiers = append(out.Tiers, Tier{Above: t.Above, Input: t.Input * r, Output: t.Output * r, CacheRead: t.CacheRead * r, CacheWrite: t.CacheWrite * r, CacheWrite1h: t.CacheWrite1h * r}) + } + return out } -// Cost of a call at this price. Reasoning tokens are billed as output by -// every vendor, and are already inside the output count. +// Same is whether two prices are the same in every part and tier. +func (p Price) Same(q Price) bool { + return p.Input == q.Input && p.Output == q.Output && p.CacheRead == q.CacheRead && p.CacheWrite == q.CacheWrite && + p.CacheWrite1h == q.CacheWrite1h && slices.Equal(p.Tiers, q.Tiers) +} + +// At is the flat price a request whose input, cached and cache-written +// tokens included, comes to prompt tokens is billed at: that of the highest +// tier it is over, else the base price. OpenAI counts a request's input +// with its cached tokens in it, and bills the whole request at the tier. +func (p Price) At(prompt int) Price { + flat := Price{Input: p.Input, Output: p.Output, CacheRead: p.CacheRead, CacheWrite: p.CacheWrite, CacheWrite1h: p.CacheWrite1h} + for _, t := range p.Tiers { + if prompt > t.Above { + flat = Price{Input: t.Input, Output: t.Output, CacheRead: t.CacheRead, CacheWrite: t.CacheWrite, CacheWrite1h: t.CacheWrite1h} + } + } + return flat +} + +// OneHour is what a 1-hour cache write costs at this flat price: the price +// given, else 2× input, Anthropic's rule. +func (p Price) OneHour() float64 { + if p.CacheWrite1h > 0 { + return p.CacheWrite1h + } + return 2 * p.Input +} + +// Cost of a call at this price, every cache write a 5-minute one. +// Reasoning tokens are billed as output by every vendor, and are already +// inside the output count. func (p Price) Cost(input, output, cacheRead, cacheWrite int) float64 { - return (float64(input)*p.Input + float64(output)*p.Output + - float64(cacheRead)*p.CacheRead + float64(cacheWrite)*p.CacheWrite) / 1e6 + return p.CostSplit(input, output, cacheRead, cacheWrite, 0) +} + +// CostSplit is Cost where cacheWrite1h of the cacheWrite tokens were +// written for an hour, billed at that price, and the rest for 5 minutes. +// The whole call is billed at the tier its input reaches. +func (p Price) CostSplit(input, output, cacheRead, cacheWrite, cacheWrite1h int) float64 { + cacheWrite1h = min(max(cacheWrite1h, 0), max(cacheWrite, 0)) + f := p.At(input + cacheRead + cacheWrite) + return (float64(input)*f.Input + float64(output)*f.Output + + float64(cacheRead)*f.CacheRead + float64(cacheWrite-cacheWrite1h)*f.CacheWrite + + float64(cacheWrite1h)*f.OneHour()) / 1e6 } type mdProvider struct { @@ -238,6 +370,7 @@ func load() map[string]mdProvider { voted := map[string]bool{} for pid, p := range m { for id, x := range p.Models { + claudeOneHour(id, x.Cost) if e := x.efforts(); len(e) > 0 { l := strings.Join(e, ",") if levels[bareID(id)] == nil { diff --git a/internal/catalog/price_tiers_test.go b/internal/catalog/price_tiers_test.go new file mode 100644 index 00000000..49a8aad6 --- /dev/null +++ b/internal/catalog/price_tiers_test.go @@ -0,0 +1,103 @@ +package catalog + +import ( + "encoding/json" + "math" + "testing" +) + +func near(a, b float64) bool { return math.Abs(a-b) < 1e-9 } + +// PAMI on Discord: OpenAI bills gpt-6-astra over 272K input at 2× input and +// cache, 1.5× output, the whole request; models.dev lists it as a tier of +// type "context". The tier is read, the whole call billed at it once its +// input with the cached tokens in it is over the size, and not at it +// otherwise. +func TestPriceTiers(t *testing.T) { + writeCatalog(t, `{"openai":{"id":"openai","models":{"gpt-6-astra":{"id":"gpt-6-astra","cost":{ + "input":10,"output":50,"cache_read":1,"cache_write":12.5, + "tiers":[{"input":20,"output":75,"cache_read":2,"cache_write":25,"tier":{"type":"context","size":272000}}, + {"input":99,"tier":{"type":"time","size":5}}]}}}}, + "anthropic":{"id":"anthropic","models":{"claude-opus-5-5":{"id":"claude-opus-5-5","cost":{"input":5,"output":25,"cache_read":0.5,"cache_write":6.25}}}}}`) + p, ok := PriceOf("openai", "gpt-6-astra") + if !ok { + t.Fatal("no price") + } + if want := []Tier{{Above: 272000, Input: 20, Output: 75, CacheRead: 2, CacheWrite: 25}}; len(p.Tiers) != 1 || p.Tiers[0] != want[0] { + t.Fatalf("tiers = %+v, want %+v (a tier of another type left out)", p.Tiers, want) + } + // 200K input, 72K of it cached: exactly 272K is not over it + if got, want := p.Cost(200_000, 1000, 72_000, 0), (200_000*10.0+1000*50+72_000*1)/1e6; !near(got, want) { + t.Errorf("at 272K: %v, want the base %v", got, want) + } + // one more cached token: the whole call at the tier + if got, want := p.Cost(200_000, 1000, 72_001, 0), (200_000*20.0+1000*75+72_001*2)/1e6; !near(got, want) { + t.Errorf("over 272K: %v, want the tier's %v", got, want) + } + // cache writes count toward the size too + if got, want := p.Cost(100, 10, 0, 300_000), (100*20.0+10*75+300_000*25)/1e6; !near(got, want) { + t.Errorf("written over 272K: %v, want %v", got, want) + } + if b := p.At(0); len(b.Tiers) != 0 || b.Input != 10 { + t.Errorf("At(0) = %+v, want the base price without tiers", b) + } + if r := p.Times(0.5); r.Tiers[0].Input != 10 || r.Tiers[0].Above != 272000 { + t.Errorf("Times = %+v, the tier halved too", r) + } + + // a Claude list price is given its 1-hour cache write, 2× input + c, _ := PriceOf("anthropic", "claude-opus-5-5") + if c.CacheWrite1h != 10 { + t.Errorf("claude 1h = %v, want 10", c.CacheWrite1h) + } + if p.CacheWrite1h != 0 { + t.Errorf("gpt 1h = %v, want none", p.CacheWrite1h) + } +} + +// Anthropic bills a 1-hour cache write at 2× input and a 5-minute one at +// 1.25×: a call's 1-hour writes at theirs, the price's own else 2× input, +// and a call from before the split was kept (none for 1 hour) as before. +func TestCostSplit(t *testing.T) { + p := Price{Input: 5, Output: 25, CacheRead: 0.5, CacheWrite: 6.25} + if got, want := p.CostSplit(1000, 100, 0, 3000, 1000), (1000*5.0+100*25+2000*6.25+1000*10)/1e6; !near(got, want) { + t.Errorf("unset 1h: %v, want %v", got, want) + } + p.CacheWrite1h = 8 + if got, want := p.CostSplit(1000, 100, 0, 3000, 1000), (1000*5.0+100*25+2000*6.25+1000*8)/1e6; !near(got, want) { + t.Errorf("1h given: %v, want %v", got, want) + } + if got, want := p.CostSplit(1000, 100, 0, 3000, 0), p.Cost(1000, 100, 0, 3000); got != want { + t.Errorf("no 1h: %v, want Cost's %v", got, want) + } + // more 1h than written is held to what was written + if got, want := p.CostSplit(0, 0, 0, 10, 50), 10*8/1e6; !near(got, want) { + t.Errorf("clamped: %v, want %v", got, want) + } +} + +// A price magpie saved before tiers and the 1-hour write reads as it did, +// and one with them reads back the same. +func TestPriceJSON(t *testing.T) { + var old Price + if err := json.Unmarshal([]byte(`{"input":1,"output":2,"cache_read":0.1,"cache_write":0}`), &old); err != nil { + t.Fatal(err) + } + if !old.Same(Price{Input: 1, Output: 2, CacheRead: 0.1}) { + t.Errorf("old = %+v", old) + } + p := Price{Input: 10, Output: 50, CacheRead: 1, CacheWrite: 12.5, CacheWrite1h: 20, Tiers: []Tier{{Above: 272000, Input: 20, Output: 75, CacheRead: 2, CacheWrite: 25}}} + b, _ := json.Marshal(p) + var back Price + if err := json.Unmarshal(b, &back); err != nil || !back.Same(p) { + t.Errorf("round trip %s = %+v, %v", b, back, err) + } + // a part a tier leaves out is the base price's + var part Price + if err := json.Unmarshal([]byte(`{"input":1,"output":2,"cache_read":0.1,"cache_write":0.5,"tiers":[{"above":1000,"input":3}]}`), &part); err != nil { + t.Fatal(err) + } + if want := (Tier{Above: 1000, Input: 3, Output: 2, CacheRead: 0.1, CacheWrite: 0.5}); len(part.Tiers) != 1 || part.Tiers[0] != want { + t.Errorf("part = %+v, want %+v", part.Tiers, want) + } +} diff --git a/internal/gateway/anthropic.go b/internal/gateway/anthropic.go index dc451607..3e7c4821 100644 --- a/internal/gateway/anthropic.go +++ b/internal/gateway/anthropic.go @@ -603,14 +603,34 @@ type aUsage struct { OutputTokens int `json:"output_tokens"` CacheReadInputTokens int `json:"cache_read_input_tokens"` CacheCreationInputTokens int `json:"cache_creation_input_tokens"` + // CacheCreation splits the cache writes by how long they are kept, + // which Anthropic bills apart: 1.25× input for 5 minutes, 2× for an + // hour + CacheCreation *aCacheCreation `json:"cache_creation,omitempty"` +} + +type aCacheCreation struct { + Ephemeral5m int `json:"ephemeral_5m_input_tokens"` + Ephemeral1h int `json:"ephemeral_1h_input_tokens"` } func (u aUsage) usage() Usage { - return Usage{Input: u.InputTokens, Output: u.OutputTokens, CacheRead: u.CacheReadInputTokens, CacheWrite: u.CacheCreationInputTokens} + out := Usage{Input: u.InputTokens, Output: u.OutputTokens, CacheRead: u.CacheReadInputTokens, CacheWrite: u.CacheCreationInputTokens} + if c := u.CacheCreation; c != nil { + out.CacheWrite = max(out.CacheWrite, c.Ephemeral5m+c.Ephemeral1h) + out.CacheWrite1h = c.Ephemeral1h + } + return out } +// anthropic is the usage as Anthropic says it, the cache writes split by +// how long they are kept where some were for an hour. func (u Usage) anthropic() aUsage { - return aUsage{InputTokens: u.Input, OutputTokens: u.Output, CacheReadInputTokens: u.CacheRead, CacheCreationInputTokens: u.CacheWrite} + out := aUsage{InputTokens: u.Input, OutputTokens: u.Output, CacheReadInputTokens: u.CacheRead, CacheCreationInputTokens: u.CacheWrite} + if h := min(u.CacheWrite1h, u.CacheWrite); h > 0 { + out.CacheCreation = &aCacheCreation{Ephemeral5m: u.CacheWrite - h, Ephemeral1h: h} + } + return out } func stopFromAnthropic(s string) string { diff --git a/internal/gateway/anthropic_cache_ttl_test.go b/internal/gateway/anthropic_cache_ttl_test.go new file mode 100644 index 00000000..cc5b1b66 --- /dev/null +++ b/internal/gateway/anthropic_cache_ttl_test.go @@ -0,0 +1,52 @@ +package gateway + +import ( + "encoding/json" + "strings" + "testing" +) + +// PAMI on Discord: Anthropic bills a 1-hour cache write at 2× input and a +// 5-minute one at 1.25×, and its usage says which were which +// (cache_creation's ephemeral_5m/1h_input_tokens). Both the API's usage and +// the Claude subscription's carry the split, which goes back out to an +// Anthropic client as it came; a usage without it reads as before. +func TestAnthropicCacheWriteSplit(t *testing.T) { + var a aUsage + if err := json.Unmarshal([]byte(`{"input_tokens":10,"output_tokens":5,"cache_read_input_tokens":100,"cache_creation_input_tokens":3000, + "cache_creation":{"ephemeral_5m_input_tokens":1000,"ephemeral_1h_input_tokens":2000}}`), &a); err != nil { + t.Fatal(err) + } + u := a.usage() + if u.CacheWrite != 3000 || u.CacheWrite1h != 2000 { + t.Fatalf("usage = %+v, want 3000 written, 2000 of them for an hour", u) + } + b, _ := json.Marshal(u.anthropic()) + if !strings.Contains(string(b), `"ephemeral_5m_input_tokens":1000`) || !strings.Contains(string(b), `"ephemeral_1h_input_tokens":2000`) { + t.Errorf("back out as %s, want the split", b) + } + + var old aUsage + if err := json.Unmarshal([]byte(`{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":300}`), &old); err != nil { + t.Fatal(err) + } + if u := old.usage(); u.CacheWrite != 300 || u.CacheWrite1h != 0 { + t.Errorf("no split = %+v", u) + } + if b, _ := json.Marshal(old.usage().anthropic()); strings.Contains(string(b), "cache_creation\"") { + t.Errorf("no split back out as %s, want no cache_creation", b) + } + + var c cliUsage + if err := json.Unmarshal([]byte(`{"input_tokens":1,"output_tokens":2,"cache_creation_input_tokens":0, + "cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":4000}}`), &c); err != nil { + t.Fatal(err) + } + if u := c.gateway(); u.CacheWrite != 4000 || u.CacheWrite1h != 4000 { + t.Errorf("subscription usage = %+v, want 4000 written for an hour", u) + } + // a stream's usage counted again keeps the split + if s := (Usage{CacheWrite: 10, CacheWrite1h: 4}).plus(Usage{CacheWrite: 5, CacheWrite1h: 5}, false); s.CacheWrite1h != 9 { + t.Errorf("plus = %+v", s) + } +} diff --git a/internal/gateway/claude_subscription.go b/internal/gateway/claude_subscription.go index 7d2bb018..9f43affb 100644 --- a/internal/gateway/claude_subscription.go +++ b/internal/gateway/claude_subscription.go @@ -1871,10 +1871,11 @@ func (r *subscriptionRun) readOutput(rd io.Reader) { var unstreamedQuiet = 300 * time.Millisecond type cliUsage struct { - Input int `json:"input_tokens"` - Output int `json:"output_tokens"` - CacheRead int `json:"cache_read_input_tokens"` - CacheWrite int `json:"cache_creation_input_tokens"` + Input int `json:"input_tokens"` + Output int `json:"output_tokens"` + CacheRead int `json:"cache_read_input_tokens"` + CacheWrite int `json:"cache_creation_input_tokens"` + CacheCreation *aCacheCreation `json:"cache_creation"` OutputDetails struct { Thinking int `json:"thinking_tokens"` } `json:"output_tokens_details"` @@ -1916,7 +1917,7 @@ func claudeLimits(raw json.RawMessage) []provider.ClaudeLimit { } func (u Usage) plus(v Usage, only bool) Usage { - for _, f := range []struct{ a, b *int }{{&u.Input, &v.Input}, {&u.Output, &v.Output}, {&u.CacheRead, &v.CacheRead}, {&u.CacheWrite, &v.CacheWrite}, {&u.Reasoning, &v.Reasoning}} { + for _, f := range []struct{ a, b *int }{{&u.Input, &v.Input}, {&u.Output, &v.Output}, {&u.CacheRead, &v.CacheRead}, {&u.CacheWrite, &v.CacheWrite}, {&u.CacheWrite1h, &v.CacheWrite1h}, {&u.Reasoning, &v.Reasoning}} { if *f.a > 0 || !only { *f.a += *f.b } @@ -1925,7 +1926,12 @@ func (u Usage) plus(v Usage, only bool) Usage { } func (u cliUsage) gateway() Usage { - return Usage{Input: u.Input, Output: u.Output, CacheRead: u.CacheRead, CacheWrite: u.CacheWrite, Reasoning: u.OutputDetails.Thinking} + out := Usage{Input: u.Input, Output: u.Output, CacheRead: u.CacheRead, CacheWrite: u.CacheWrite, Reasoning: u.OutputDetails.Thinking} + if c := u.CacheCreation; c != nil { + out.CacheWrite = max(out.CacheWrite, c.Ephemeral5m+c.Ephemeral1h) + out.CacheWrite1h = c.Ephemeral1h + } + return out } func renderClaudePrompt(req *Request) ([]map[string]any, error) { diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 54e73d5f..acb1a26c 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -1836,7 +1836,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro ProviderKeyID: providerKeyID, ProviderKeyName: providerKeyName, ProviderAccount: providerAccount, Requested: call.Model, Served: call.Usage.Served, Upstream: call.Usage.Upstream, Input: call.Usage.Input, Output: call.Usage.Output, CacheRead: call.Usage.CacheRead, - CacheWrite: call.Usage.CacheWrite, Reasoning: call.Usage.Reasoning, Effort: sent, Millis: time.Since(began).Milliseconds(), Status: call.Status, + CacheWrite: call.Usage.CacheWrite, CacheWrite1h: call.Usage.CacheWrite1h, Reasoning: call.Usage.Reasoning, Effort: sent, Millis: time.Since(began).Milliseconds(), Status: call.Status, TTFT: try.TTFT, FirstText: try.FirstText, Session: sessionOf(r.Header), NativeSession: nativeSessionOf(r.Header), Kind: call.Kind, RequestID: call.Usage.RequestID, Endpoint: endpointOf(r, from, call.To), Archive: call.archiveName()} failedWith(&rec, call.Status, call.Error, call.Usage.ErrType) @@ -1873,7 +1873,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro ProviderKeyID: providerKeyID, ProviderKeyName: providerKeyName, ProviderAccount: providerAccount, Requested: call.Model, Served: call.Usage.Served, Input: call.Usage.Input, Output: call.Usage.Output, CacheRead: call.Usage.CacheRead, - CacheWrite: call.Usage.CacheWrite, Reasoning: call.Usage.Reasoning, Effort: sent, Millis: time.Since(began).Milliseconds(), Status: call.Status, + CacheWrite: call.Usage.CacheWrite, CacheWrite1h: call.Usage.CacheWrite1h, Reasoning: call.Usage.Reasoning, Effort: sent, Millis: time.Since(began).Milliseconds(), Status: call.Status, TTFT: try.TTFT, FirstText: try.FirstText, Session: sessionOf(r.Header), NativeSession: nativeSessionOf(r.Header), Kind: call.Kind, RequestID: call.Usage.RequestID, Endpoint: endpointOf(r, from, call.To), Archive: call.archiveName()} failedWith(&rec, call.Status, call.Error, call.Usage.ErrType) @@ -2146,7 +2146,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro ProviderKeyID: providerKeyID, ProviderKeyName: providerKeyName, ProviderAccount: providerAccount, Requested: call.Model, Served: call.Usage.Served, Upstream: call.Usage.Upstream, Input: call.Usage.Input, Output: call.Usage.Output, CacheRead: call.Usage.CacheRead, - CacheWrite: call.Usage.CacheWrite, Reasoning: call.Usage.Reasoning, Effort: sent, Millis: call.Millis, Status: call.Status, + CacheWrite: call.Usage.CacheWrite, CacheWrite1h: call.Usage.CacheWrite1h, Reasoning: call.Usage.Reasoning, Effort: sent, Millis: call.Millis, Status: call.Status, TTFT: call.TTFT, FirstText: call.FirstText, Sent: sentMs, Session: sessionOf(r.Header), NativeSession: nativeSessionOf(r.Header), Kind: call.Kind, RequestID: call.Usage.RequestID, ResponseID: call.Usage.ResponseID, Endpoint: endpointOf(r, from, call.To), Archive: call.archiveName()} failedWith(&rec, call.Status, call.Error, call.Usage.ErrType) diff --git a/internal/gateway/ir.go b/internal/gateway/ir.go index 272da209..ea0b0aa8 100644 --- a/internal/gateway/ir.go +++ b/internal/gateway/ir.go @@ -201,7 +201,12 @@ type Usage struct { Output int `json:"output"` CacheRead int `json:"cache_read"` CacheWrite int `json:"cache_write"` - Reasoning int `json:"reasoning"` + // CacheWrite1h is how many of the CacheWrite tokens were written to + // be kept for an hour, which Anthropic bills at 2× input where a + // 5-minute write is 1.25×, when its usage says so (cache_creation's + // ephemeral_1h_input_tokens) + CacheWrite1h int `json:"cache_write_1h,omitempty"` + Reasoning int `json:"reasoning"` // Served: the model the vendor's reply says answered, when it named // one — which may not be the one it was asked for Served string `json:"served,omitempty"` @@ -237,6 +242,9 @@ func (u *Usage) add(v Usage) { if v.CacheWrite > 0 { u.CacheWrite = v.CacheWrite } + if v.CacheWrite1h > 0 { + u.CacheWrite1h = v.CacheWrite1h + } if v.Reasoning > 0 { u.Reasoning = v.Reasoning } diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 0bb40e7c..6540932d 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -2343,6 +2343,9 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; } .mname .mprice > span { flex: none; } .mname .mpart { display: flex; align-items: center; gap: 4px; flex: none; } .mname .mpart input { width: 72px; height: 25px; font-size: 11.5px; } +.mname .mtier { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; flex: 1 1 100%; min-width: 0; } +.mname .mtier[hidden], .mname .mtier-add[hidden] { display: none; } +.mname .mtier-add { font-size: 11.5px; padding: 0; } .mname .mlevels { display: flex; flex-wrap: wrap; gap: 2px 10px; font-size: 11.5px; } .editor .mname label.tick { gap: 4px; } .mname .text { flex: none; padding: 2px 7px; font-size: 11.5px; } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 7f4b1b69..168169af 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -6230,6 +6230,33 @@ function field(label, control, hint) { if (hint) wrap.append(el("div", "hint", hint)); return [l, wrap]; } +// decimalOf is a number as typed in a price box, a comma taken for the +// decimal point as some keyboards give it (0,25); NaN for anything else. +// The boxes are text ones: a number box drops what its locale doesn't +// take, and "0,25" became 25. +function decimalOf(s) { + s = String(s ?? "").trim(); + if (/^\d*,\d+$/.test(s)) s = s.replace(",", "."); + return /^(\d+\.?\d*|\.\d+)$/.test(s) ? Number(s) : NaN; +} +// tokensTyped is a number of tokens as typed, 272K, 1M or 200000; NaN for +// anything else +function tokensTyped(s) { + const x = /^(\d+(?:[.,]\d+)?)\s*([km]?)$/i.exec(String(s ?? "").trim()); + if (!x) return NaN; + const n = Math.round(Number(x[1].replace(",", ".")) * ({ k: 1e3, m: 1e6 }[x[2].toLowerCase()] || 1)); + return n > 0 ? n : NaN; +} +function tokenSize(n) { + return n >= 1e6 && n % 1e6 === 0 ? n / 1e6 + "M" : n >= 1e3 && n % 1e3 === 0 ? n / 1e3 + "K" : String(n); +} +// samePrice: two prices alike in every part, their 1-hour cache writes and +// long-context prices included +function samePrice(a, b) { + const norm = (p) => JSON.stringify([p.input, p.output, p.cache_read || 0, p.cache_write || 0, p.cache_write_1h || 0, + (p.tiers || []).map((x) => [x.above, x.input, x.output, x.cache_read || 0, x.cache_write || 0, x.cache_write_1h || 0])]); + return norm(a) === norm(b); +} function input(value, placeholder, type = "text") { const i = el("input"); i.type = type; @@ -6337,11 +6364,8 @@ function concurrencyField(p) { // costs are its list price, else its maker's, times this. Empty is the // price as listed; steps of 0.001. function priceRateField() { - const box = input(draft.priceRate ?? "", t("1, the official price"), "number"); + const box = input(draft.priceRate ?? "", t("1, the official price")); box.classList.add("price-rate"); - box.min = "0"; - box.max = "1000"; - box.step = "0.001"; box.inputMode = "decimal"; box.oninput = () => { draft.priceRate = box.value; }; return field(t("Price rate"), box, t("Costs are counted at the official price times this, as a relay bills, like 0.8 or 1.5; a price you set for a model stays as set")); @@ -6351,8 +6375,8 @@ function priceRateField() { function priceRateOfDraft() { const v = String(draft.priceRate ?? "").trim(); if (!v) return null; - const n = Number(v); - if (!/^\d*\.?\d+$|^\d+\.$/.test(v) || !(n >= 0 && n <= 1000) || Math.abs(n * 1000 - Math.round(n * 1000)) > 1e-6) return undefined; + const n = decimalOf(v); + if (!(n >= 0 && n <= 1000) || Math.abs(n * 1000 - Math.round(n * 1000)) > 1e-6) return undefined; return n; } function priceRateError(ed) { @@ -8564,60 +8588,122 @@ function renderModels(p) { // Usage page counts it (#819: only `magpie model price` set it): // each box shows its list price until a price is given, a part left // empty is the list's, and every part empty is its list price again - const parts = [["input", "Input"], ["output", "Output"], ["cache_read", "Cache read"], ["cache_write", "Cache write"]]; + // the 1-hour cache write, empty, is 2× input as Anthropic bills it; + // a long-context price (OpenAI's over 272K) is what the whole request + // costs when its input is over its size, empty parts its list's + const parts = [["input", "Input"], ["output", "Output"], ["cache_read", "Cache read"], ["cache_write", "Cache write"], ["cache_write_1h", "Cache write 1h"]]; + const tierParts = parts.slice(0, 4); const priceNow = () => prefs[id]?.ownPrice ? null : prefs[id]?.price ?? m.price ?? null; const shown = (n) => String(Math.round(n * 1e6) / 1e6); + const listTier = m.list?.tiers?.[0]; const priceBox = el("div", "mprice"); priceBox.title = t("What {id} costs, in US dollars per million tokens, as the Usage page counts it; empty: its list price, shown greyed. A price set here isn't multiplied by the provider's price rate", { id: m.id }); priceBox.append(el("span", "", t("Price, $ / 1M tokens"))); - const cells = parts.map(([k, l]) => { - const box = el("label", "mpart"); - const typed = draft.priceTyped?.[id]; - const i = input(typed ? typed[parts.findIndex(([x]) => x === k)] : priceNow() ? shown(priceNow()[k]) : "", m.list ? shown(m.list[k]) : "", "number"); + const typed = draft.priceTyped?.[id]; + const cell = (value, placeholder, k, label) => { + const i = input(value, placeholder); i.inputMode = "decimal"; - i.min = "0"; - i.step = "any"; i.dataset.part = k; - i.setAttribute("aria-label", t(l)); + i.setAttribute("aria-label", label); i.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") i.blur(); }; i.onchange = () => takePrice(); + return i; + }; + const oneHour = (pr) => pr ? pr.cache_write_1h || 2 * pr.input : 0; + const cells = parts.map(([k, l], n) => { + const box = el("label", "mpart"); + const pr = priceNow(); + const value = typed ? typed[n] : !pr ? "" : k === "cache_write_1h" ? pr.cache_write_1h ? shown(pr.cache_write_1h) : "" : shown(pr[k]); + const placeholder = !m.list ? "" : k === "cache_write_1h" ? shown(oneHour(m.list)) : shown(m.list[k]); + const i = cell(value, placeholder, k, t(l)); + if (k === "cache_write_1h") box.title = t("A cache write kept for an hour, as Anthropic bills it; empty: 2× input"); box.append(el("span", "", t(l)), i); priceBox.append(box); return i; }); - const showPrice = () => { const pr = priceNow(); cells.forEach((c, i) => { c.value = pr ? shown(pr[parts[i][0]]) : ""; }); }; + // the long-context price, a quiet row of its own: shown where there is + // one, else behind a link + const tierRow = el("div", "mtier"); + tierRow.title = t("What the whole request costs when its input — cached tokens and cache writes included — is over this many tokens, as OpenAI bills gpt-6-astra over 272K; empty: the list's"); + const tierNow = () => priceNow()?.tiers?.[0]; + const above = cell(typed ? typed[parts.length] : tierNow() ? tokenSize(tierNow().above) : "", listTier ? tokenSize(listTier.above) : "272K", "above", t("Over input tokens")); + above.classList.add("mabove"); + const overBox = el("label", "mpart"); + overBox.append(el("span", "", t("Long-context price, over")), above); + tierRow.append(overBox); + const tierCells = tierParts.map(([k, l], n) => { + const box = el("label", "mpart"); + const i = cell(typed ? typed[parts.length + 1 + n] : tierNow() ? shown(tierNow()[k]) : "", listTier ? shown(listTier[k]) : "", "tier_" + k, t("Long-context price") + " · " + t(l)); + box.append(el("span", "", t(l)), i); + tierRow.append(box); + return i; + }); + const moreTier = el("button", "text action mtier-add", t("Long-context price")); + moreTier.title = tierRow.title; + let tierOpen = false; + moreTier.onclick = (e) => { e.preventDefault(); tierOpen = true; drawTier(); above.focus({ preventScroll: true }); }; + const drawTier = () => { + tierRow.hidden = !tierOpen && !tierNow() && !listTier; + moreTier.hidden = !tierRow.hidden; + }; + priceBox.append(moreTier, tierRow); + drawTier(); + const showPrice = () => { + const pr = priceNow(); + cells.forEach((c, i) => { const k = parts[i][0]; c.value = !pr ? "" : k === "cache_write_1h" ? pr.cache_write_1h ? shown(pr.cache_write_1h) : "" : shown(pr[k]); }); + const tr = tierNow(); + above.value = tr ? tokenSize(tr.above) : ""; + tierCells.forEach((c, i) => { c.value = tr ? shown(tr[tierParts[i][0]]) : ""; }); + drawTier(); + }; const takePrice = () => { const v = cells.map((c) => c.value.trim()); + const tv = [above, ...tierCells].map((c) => c.value.trim()); const x = pref(); delete x.price; delete x.ownPrice; if (draft.priceTyped) delete draft.priceTyped[id]; - if (v.every((s) => s === "")) { + if (v.every((s) => s === "") && tv.every((s) => s === "")) { if (m.price) x.ownPrice = true; drawReset(); return; } + const bad = (msg) => { status(msg, "err"); showPrice(); drawReset(); }; const price = {}; - for (const [i, [k]] of parts.entries()) { - const n = v[i] === "" ? m.list?.[k] ?? (k.startsWith("cache") ? 0 : NaN) : Number(v[i]); + for (const [i, [k]] of tierParts.entries()) { + const n = v[i] === "" ? m.list?.[k] ?? (k.startsWith("cache") ? 0 : NaN) : decimalOf(v[i]); // a model with no list price is given its input and output one // box at a time: what is typed stays, waiting for the other, and // the Save asks for it (PAMI on Discord: each box typed was // emptied again, so a price could never be set) - if (Number.isNaN(n) && v[i] === "" && v.every((s) => s === "" || (Number.isFinite(Number(s)) && Number(s) >= 0))) { - (draft.priceTyped = draft.priceTyped || {})[id] = v; + if (Number.isNaN(n) && v[i] === "" && v.every((s) => s === "" || decimalOf(s) >= 0)) { + (draft.priceTyped = draft.priceTyped || {})[id] = [...v, ...tv]; status(t("{id} has no list price: give its input and output prices", { id: m.id }), "warn"); drawReset(); return; } - if (!Number.isFinite(n) || n < 0) { - status(Number.isNaN(n) && v[i] === "" ? t("{id} has no list price: give its input and output prices", { id: m.id }) : t("A price is a number of dollars, 0 or more"), "err"); - showPrice(); - drawReset(); - return; - } + if (!Number.isFinite(n) || n < 0) return bad(Number.isNaN(n) && v[i] === "" ? t("{id} has no list price: give its input and output prices", { id: m.id }) : t("A price is a number of dollars, 0 or more")); price[k] = n; } - if (!m.price || parts.some(([k]) => m.price[k] !== price[k])) x.price = price; + if (v[4] !== "") { + const n = decimalOf(v[4]); + if (!(n >= 0)) return bad(t("A price is a number of dollars, 0 or more")); + if (n > 0) price.cache_write_1h = n; + } + // the long-context price: its parts typed, else the list's, else the + // price's own; none typed, the list's as it is + const from = tierNow() || listTier; + if (tv.some((s) => s !== "")) { + const size = tv[0] === "" ? from?.above : tokensTyped(tv[0]); + if (!(size > 0)) return bad(t("A long-context price starts over a number of input tokens, like 272K")); + const tier = { above: size }; + for (const [i, [k]] of tierParts.entries()) { + const n = tv[i + 1] === "" ? from?.[k] ?? price[k] : decimalOf(tv[i + 1]); + if (!(n >= 0)) return bad(t("A price is a number of dollars, 0 or more")); + tier[k] = n; + } + price.tiers = [tier, ...((priceNow() || m.list)?.tiers || []).slice(1).filter((x) => x.above > size)]; + } else if (m.list?.tiers?.length) price.tiers = m.list.tiers; + if (!m.price || !samePrice(m.price, price)) x.price = price; showPrice(); drawReset(); }; @@ -13323,7 +13409,7 @@ function renderUsage() { // written count is secondary and only fits in the tooltip. const promptTokens = u.input + u.cache_read + u.cache_write; const hit = u.cache_read && promptTokens ? t("hit rate {p}", { p: Math.round(100 * u.cache_read / promptTokens) + "%" }) : ""; - tile(fmtN(u.cache_read), t("cache read"), hit, u.cache_write ? t("{n} written", { n: fmtN(u.cache_write) }) : ""); + tile(fmtN(u.cache_read), t("cache read"), hit, u.cache_write ? t("{n} written", { n: fmtN(u.cache_write) }) + cacheTTLs(u, fmtN) : ""); tile(fmtN(u.reasoning), t("reasoning"), t("inside output")); tile(String(u.calls), t(u.calls === 1 ? "call" : "calls"), u.errors ? t("{n} failed", { n: u.errors }) : ""); @@ -13552,7 +13638,7 @@ function ledDetail(r, cols) { }; opt("Sent", r.model); opt("Effort", r.effort); - if (r.cache_write || r.cache_read) opt("Cache", t("{w} written · {r} read", { w: ledNum(r.cache_write || 0), r: ledNum(r.cache_read || 0) })); + if (r.cache_write || r.cache_read) opt("Cache", t("{w} written · {r} read", { w: ledNum(r.cache_write || 0), r: ledNum(r.cache_read || 0) }) + cacheTTLs(r, ledNum)); if (ledRowSpeed(r)) add("Speed", t("{n} tok/s", { n: ledNum(Math.round(ledRowSpeed(r))) })); add("Request ID", r.rid); add("Endpoint", r.ep); @@ -13716,6 +13802,13 @@ const LED_TREND = [...LED_METRICS, ["speed", "Speed"]]; const LED_SPLITS = [["model", "Model"], ["modelAt", "Model · provider"], ["provider", "Provider"], ["agent", "Agent"]]; const LED_SHOWN = 7; // told apart in a chart; the rest are "Other" const allTokens = (x) => x.input + x.output + x.cache_read + x.cache_write; +// cacheTTLs: a call's cache writes split by how long they are kept, where +// some were for an hour (Anthropic bills those at 2× input, 5 minutes' +// at 1.25×); nothing when none were +function cacheTTLs(x, fmt) { + const h = Math.min(x.cache_write_1h || 0, x.cache_write || 0); + return h ? " · " + t("{m} for 5 minutes, {h} for an hour", { m: fmt(x.cache_write - h), h: fmt(h) }) : ""; +} // how fast the timed replies of a point, a share or a part wrote, in tokens // a second after their first (usage.Totals.Speed): 0 for none timed const ledSpeed = (x) => x?.decode_ms > 0 ? (1000 * x.decode_out) / x.decode_ms : 0; @@ -14317,7 +14410,7 @@ function renderLedger() { td(r.effort || "—", r.effort ? "" : "faint"); td(ledNum(r.in), "n"); td(ledNum(r.out), "n", r.reasoning ? t("{n} reasoning, inside output", { n: ledNum(r.reasoning) }) : ""); - td(ledNum(r.cache_write), "n" + (r.cache_write ? "" : " faint")); + td(ledNum(r.cache_write), "n" + (r.cache_write ? "" : " faint"), cacheTTLs(r, ledNum).replace(/^ · /, "")); td(ledNum(r.cache_read), "n" + (r.cache_read ? "" : " faint")); const cost = td(r.priced ? "≈" + fmtCost({ cost: r.cost, unpriced: 0 }) : "—", "n cost" + (r.priced ? "" : " faint"), r.priced ? (r.pricing_model ? t("API price reference") + ": " + r.pricing_model : "") : t("No known price for this model")); if (r.priced && r.pricing_model) { diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index bb67c1eb..3a45f322 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -1747,6 +1747,14 @@ const I18N = { "In": "输入", "Out": "输出", "Cache write": "缓存写入", + "Cache write 1h": "1 小时缓存写入", + "A cache write kept for an hour, as Anthropic bills it; empty: 2× input": "保留 1 小时的缓存写入,按 Anthropic 的计费方式;留空:输入价的 2 倍", + "What the whole request costs when its input — cached tokens and cache writes included — is over this many tokens, as OpenAI bills gpt-6-astra over 272K; empty: the list's": "请求的输入(含缓存读取和缓存写入的 token)超过这个数时,整个请求按这个价格计,就像 OpenAI 对 gpt-6-astra 超过 272K 的计费;留空:用列表价", + "Over input tokens": "超过的输入 token 数", + "Long-context price, over": "长上下文价格,超过", + "Long-context price": "长上下文价格", + "A long-context price starts over a number of input tokens, like 272K": "长上下文价格要写从多少输入 token 起算,比如 272K", + "{m} for 5 minutes, {h} for an hour": "{m} 保留 5 分钟,{h} 保留 1 小时", "Cache read": "缓存读取", "Duration": "耗时", "Status": "状态", @@ -5112,6 +5120,14 @@ const I18N = { "In": "入力", "Out": "出力", "Cache write": "キャッシュ書き込み", + "Cache write 1h": "1 時間キャッシュ書き込み", + "A cache write kept for an hour, as Anthropic bills it; empty: 2× input": "1 時間保持されるキャッシュ書き込み(Anthropic の課金どおり)。空欄:入力の 2 倍", + "What the whole request costs when its input — cached tokens and cache writes included — is over this many tokens, as OpenAI bills gpt-6-astra over 272K; empty: the list's": "入力(キャッシュ読み取り・書き込みのトークンを含む)がこのトークン数を超えたとき、リクエスト全体にかかる価格。OpenAI が gpt-6-astra の 272K 超に課金するのと同じです。空欄:一覧の価格", + "Over input tokens": "超える入力トークン数", + "Long-context price, over": "長コンテキスト価格、超過", + "Long-context price": "長コンテキスト価格", + "A long-context price starts over a number of input tokens, like 272K": "長コンテキスト価格には、何トークンを超えたら適用するかを書いてください(例:272K)", + "{m} for 5 minutes, {h} for an hour": "{m} は 5 分間、{h} は 1 時間保持", "Cache read": "キャッシュ読み取り", "Duration": "所要時間", "Status": "ステータス", @@ -8457,6 +8473,14 @@ const I18N = { "In": "Ein", "Out": "Aus", "Cache write": "Cache-Schreiben", + "Cache write 1h": "Cache-Schreiben 1 h", + "A cache write kept for an hour, as Anthropic bills it; empty: 2× input": "Ein Cache-Schreiben, das eine Stunde gehalten wird, wie Anthropic es abrechnet; leer: 2× Eingabe", + "What the whole request costs when its input — cached tokens and cache writes included — is over this many tokens, as OpenAI bills gpt-6-astra over 272K; empty: the list's": "Was die ganze Anfrage kostet, wenn ihre Eingabe – gecachte und in den Cache geschriebene Tokens eingeschlossen – über so vielen Tokens liegt, wie OpenAI gpt-6-astra über 272K abrechnet; leer: der Listenpreis", + "Over input tokens": "Über Eingabe-Tokens", + "Long-context price, over": "Preis für langen Kontext, über", + "Long-context price": "Preis für langen Kontext", + "A long-context price starts over a number of input tokens, like 272K": "Ein Preis für langen Kontext gilt ab einer Zahl von Eingabe-Tokens, etwa 272K", + "{m} for 5 minutes, {h} for an hour": "{m} für 5 Minuten, {h} für eine Stunde", "Cache read": "Cache-Lesen", "Duration": "Dauer", "Status": "Status", diff --git a/internal/gui/tests/model-price-edit.test.cjs b/internal/gui/tests/model-price-edit.test.cjs index 31881d0a..457e0de3 100644 --- a/internal/gui/tests/model-price-edit.test.cjs +++ b/internal/gui/tests/model-price-edit.test.cjs @@ -64,38 +64,38 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await page.locator(".row.provider").click(); if (!(await page.locator(".mnames:not([hidden])").count())) await page.getByRole("button", { name: L.names, exact: true }).click(); const row = (id) => page.locator(".mname", { has: page.locator("code", { hasText: id }) }); - const boxes = (id) => row(id).locator(".mprice input"); + const boxes = (id) => row(id).locator(".mprice > .mpart input"); // the list price shows in the boxes, a price set in them assert.equal((await row("model-1").locator(".mprice > span").textContent()), L.price); - assert.deepEqual(await boxes("model-1").evaluateAll((is) => is.map((i) => [i.value, i.placeholder])), [["", "1.25"], ["", "10"], ["", "0.125"], ["", "0"]]); - assert.deepEqual(await boxes("model-2").evaluateAll((is) => is.map((i) => i.value)), ["0.5", "2", "0.05", "0"]); - assert.equal(await row("model-1").getByRole("spinbutton", { name: L.input, exact: true }).count(), 1); + assert.deepEqual(await boxes("model-1").evaluateAll((is) => is.map((i) => [i.value, i.placeholder])), [["", "1.25"], ["", "10"], ["", "0.125"], ["", "0"], ["", "2.5"]]); + assert.deepEqual(await boxes("model-2").evaluateAll((is) => is.map((i) => i.value)), ["0.5", "2", "0.05", "0", ""]); + assert.equal(await row("model-1").getByRole("textbox", { name: L.input, exact: true }).count(), 1); assert.equal(await row("model-1").locator("select").count(), 0); // a part typed, the others the list's; Restore default drops a price const y = await page.evaluate(() => scrollY); - await row("model-1").getByRole("spinbutton", { name: L.output, exact: true }).fill("8"); - await row("model-1").getByRole("spinbutton", { name: L.output, exact: true }).press("Enter"); + await row("model-1").getByRole("textbox", { name: L.output, exact: true }).fill("8"); + await row("model-1").getByRole("textbox", { name: L.output, exact: true }).press("Enter"); assert(await row("model-1").getByText(L.unsaved, { exact: true }).isVisible()); - assert.deepEqual(await boxes("model-1").evaluateAll((is) => is.map((i) => i.value)), ["1.25", "8", "0.125", "0"]); + assert.deepEqual(await boxes("model-1").evaluateAll((is) => is.map((i) => i.value)), ["1.25", "8", "0.125", "0", ""]); await row("model-2").getByRole("button", { name: L.reset }).click(); - assert.deepEqual(await boxes("model-2").evaluateAll((is) => is.map((i) => i.value)), ["", "", "", ""]); + assert.deepEqual(await boxes("model-2").evaluateAll((is) => is.map((i) => i.value)), ["", "", "", "", ""]); // no list price: input and output must be given, one box at a time — // the part typed stays (PAMI on Discord: it was emptied, so no price // could be set), and a Save before the other part asks for it - await row("model-3").getByRole("spinbutton", { name: L.input, exact: true }).fill("3"); - await row("model-3").getByRole("spinbutton", { name: L.input, exact: true }).press("Enter"); + await row("model-3").getByRole("textbox", { name: L.input, exact: true }).fill("3"); + await row("model-3").getByRole("textbox", { name: L.input, exact: true }).press("Enter"); await page.locator("#status", { hasText: L.nolist }).waitFor(); - assert.deepEqual(await boxes("model-3").evaluateAll((is) => is.map((i) => i.value)), ["3", "", "", ""]); + assert.deepEqual(await boxes("model-3").evaluateAll((is) => is.map((i) => i.value)), ["3", "", "", "", ""]); assert(await row("model-3").getByText(L.unsaved, { exact: true }).isVisible()); await page.getByRole("button", { name: L.save, exact: true }).click(); await page.locator(".editor-error", { hasText: L.nolist }).waitFor(); assert.equal(await page.evaluate(() => document.activeElement?.getAttribute("aria-label")), L.output); assert.deepEqual(posts, [], "a price half given isn't sent"); - await row("model-3").getByRole("spinbutton", { name: L.output, exact: true }).fill("15"); - await row("model-3").getByRole("spinbutton", { name: L.output, exact: true }).press("Enter"); - assert.deepEqual(await boxes("model-3").evaluateAll((is) => is.map((i) => i.value)), ["3", "15", "0", "0"]); + await row("model-3").getByRole("textbox", { name: L.output, exact: true }).fill("15"); + await row("model-3").getByRole("textbox", { name: L.output, exact: true }).press("Enter"); + assert.deepEqual(await boxes("model-3").evaluateAll((is) => is.map((i) => i.value)), ["3", "15", "0", "0", ""]); assert.equal(await page.evaluate(() => scrollY), y, "nothing scrolled the page"); assert.deepEqual(posts, [], "nothing is sent before the Save"); if (process.env.ARTIFACT_DIR) await page.locator(".mnames").screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-model-price-rows.png`) }); diff --git a/internal/gui/tests/model-price-tiers.test.cjs b/internal/gui/tests/model-price-tiers.test.cjs new file mode 100644 index 00000000..4a999b40 --- /dev/null +++ b/internal/gui/tests/model-price-tiers.test.cjs @@ -0,0 +1,130 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// PAMI on Discord: a price box took no decimals as typed (a number box +// drops what its locale doesn't take, "0,25" became 25), OpenAI bills +// gpt-6-astra over 272K input at another price, and Anthropic a 1-hour cache +// write at 2× input. The boxes are text ones that take "0.25" and "0,25"; a +// model's 1-hour cache write has a box of its own, empty for 2× input; and a +// quiet Long-context price row, shown where the list has one and behind a +// link where it hasn't, gives what the whole request costs over a size. +// English and Chinese. +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { test } = require("node:test"); +const { chromium, webkit } = require("playwright"); + +const assets = path.resolve(__dirname, "../assets"); +const astra = { input: 10, output: 50, cache_read: 1, cache_write: 12.5, tiers: [{ above: 272000, input: 20, output: 75, cache_read: 2, cache_write: 25 }] }; +const opus = { input: 5, output: 25, cache_read: 0.5, cache_write: 6.25, cache_write_1h: 10 }; +const plain = { input: 1, output: 4, cache_read: 0.1, cache_write: 0 }; + +function serve(lang, posts) { + const state = { agents: [], profiles: [], settings: { lang, theme: "light" } }; + const models = [ + { id: "gpt-6-astra", name: "GPT-6 Astra", on: true, efforts: [], images: false, list: astra }, + { id: "claude-opus-5-5", name: "Claude Opus 5.5", on: true, efforts: [], images: false, list: opus }, + { id: "plain-1", name: "Plain 1", on: true, efforts: [], images: false, list: plain }, + ]; + const provider = { id: "relay", name: "Relay", icon: "generic", chat: "https://relay.example/v1", responses: "", anthropic: "", models, agents: [], key: { set: true, masked: "sk-…1234" }, ready: true }; + const providers = { providers: [provider], presets: [], excluded: [], gateway: { running: true, window: true } }; + return async (route) => { + const url = new URL(route.request().url()); + const json = (data) => route.fulfill({ json: data }); + if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:${JSON.stringify(lang)},theme:"light",web:true};` }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json(state); + if (url.pathname === "/api/providers") return json(providers); + if (url.pathname === "/api/groups") return json({ groups: [] }); + if (url.pathname.startsWith("/api/provider/")) { + posts.push({ path: url.pathname, body: route.request().postDataJSON() }); + return json(providers); + } + if (url.pathname.startsWith("/api/")) return json({}); + const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname); + await route.fulfill({ body: await fs.readFile(file), contentType: { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)] }); + }; +} + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh"]) { + test(`${engine} ${lang}: decimals, a 1-hour cache write and a long-context price`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || "chromium" })); + const page = await (await browser.newContext({ viewport: { width: 900, height: 900 }, reducedMotion: "reduce" })).newPage(); + t.after(async () => { + if (process.env.ARTIFACT_DIR) { + await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true }); + await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-model-price-tiers.png`) }); + } + await browser.close(); + }); + page.setDefaultTimeout(5000); + const errors = [], posts = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", serve(lang, posts)); + await page.goto("http://magpie.test/?view=providers"); + const zh = lang === "zh"; + const L = { names: zh ? "名称与推理档位" : "Names & levels", input: zh ? "输入" : "Input", output: zh ? "输出" : "Output", cacheRead: zh ? "缓存读取" : "Cache read", + oneHour: zh ? "1 小时缓存写入" : "Cache write 1h", long: zh ? "长上下文价格" : "Long-context price", over: zh ? "超过的输入 token 数" : "Over input tokens", + save: zh ? "保存" : "Save", badSize: zh ? "长上下文价格要写从多少输入 token 起算,比如 272K" : "A long-context price starts over a number of input tokens, like 272K" }; + await page.locator(".row.provider").click(); + if (!(await page.locator(".mnames:not([hidden])").count())) await page.getByRole("button", { name: L.names, exact: true }).click(); + const row = (id) => page.locator(".mname", { has: page.locator("code", { hasText: id }) }); + const base = (id) => row(id).locator(".mprice > .mpart input"); + const tier = (id) => row(id).locator(".mtier input"); + const typeIn = async (box, v) => { await box.fill(v); await box.press("Enter"); }; + const y = await page.evaluate(() => scrollY); + + // a list with a long-context price shows it in its quiet row, greyed + assert(await row("gpt-6-astra").locator(".mtier").isVisible()); + assert(!(await row("gpt-6-astra").locator(".mtier-add").isVisible())); + assert.deepEqual(await tier("gpt-6-astra").evaluateAll((is) => is.map((i) => [i.value, i.placeholder])), [["", "272K"], ["", "20"], ["", "75"], ["", "2"], ["", "25"]]); + // the 1-hour box shows the list's, else 2× its input + assert.deepEqual(await base("claude-opus-5-5").evaluateAll((is) => is.map((i) => i.placeholder)), ["5", "25", "0.5", "6.25", "10"]); + assert.deepEqual(await base("gpt-6-astra").evaluateAll((is) => is.at(-1).placeholder), "20"); + // where there is none, the row waits behind a link + assert(!(await row("plain-1").locator(".mtier").isVisible())); + assert(await row("plain-1").locator(".mtier-add").isVisible()); + assert.equal(await row("plain-1").locator("input[type=number]").count(), 0, "no number boxes"); + + // decimals, with a point and with a comma, each kept as typed + await typeIn(row("gpt-6-astra").getByRole("textbox", { name: L.cacheRead, exact: true }), "0,25"); + await typeIn(row("gpt-6-astra").getByRole("textbox", { name: L.output, exact: true }), "1.5"); + assert.deepEqual(await base("gpt-6-astra").evaluateAll((is) => is.map((i) => i.value)), ["10", "1.5", "0.25", "12.5", ""]); + // the 1-hour cache write, its own price + await typeIn(row("claude-opus-5-5").getByRole("textbox", { name: L.oneHour, exact: true }), "7,5"); + assert.equal(await base("claude-opus-5-5").evaluateAll((is) => is.at(-1).value), "7.5"); + + // a long-context price given where the list has none: a size that + // isn't one is refused, then 200k and its input + await row("plain-1").locator(".mtier-add").click(); + assert(await row("plain-1").locator(".mtier").isVisible()); + assert.equal(await page.evaluate(() => document.activeElement?.getAttribute("aria-label")), L.over); + await typeIn(row("plain-1").getByRole("textbox", { name: L.over, exact: true }), "lots"); + await page.locator("#status", { hasText: L.badSize }).waitFor(); + await row("plain-1").getByRole("textbox", { name: L.over, exact: true }).fill("200k"); + await typeIn(row("plain-1").getByRole("textbox", { name: L.long + " · " + L.input, exact: true }), "2,5"); + assert.deepEqual(await tier("plain-1").evaluateAll((is) => is.map((i) => i.value)), ["200K", "2.5", "4", "0.1", "0"]); + assert.equal(await page.evaluate(() => scrollY), y, "nothing scrolled the page"); + assert.equal(await page.locator(".mname select").count(), 0); + if (process.env.ARTIFACT_DIR) await page.locator(".mnames").screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-model-price-tier-rows.png`) }); + + await page.getByRole("button", { name: L.save, exact: true }).click(); + await page.waitForFunction(() => !document.querySelector(".mnames")); + assert.deepEqual(posts.map((p) => p.path), ["/api/provider/save"]); + assert.deepEqual(posts[0].body.modelPrefs, { + // its list's long-context price goes with the price set + "gpt-6-astra": { price: { input: 10, output: 1.5, cache_read: 0.25, cache_write: 12.5, tiers: astra.tiers } }, + "claude-opus-5-5": { price: { input: 5, output: 25, cache_read: 0.5, cache_write: 6.25, cache_write_1h: 7.5 } }, + "plain-1": { price: { input: 1, output: 4, cache_read: 0.1, cache_write: 0, tiers: [{ above: 200000, input: 2.5, output: 4, cache_read: 0.1, cache_write: 0 }] } }, + }); + const missing = await page.evaluate(() => ["Cache write 1h", "Long-context price", "Long-context price, over", "Over input tokens", + "A long-context price starts over a number of input tokens, like 272K", "A cache write kept for an hour, as Anthropic bills it; empty: 2× input", + "What the whole request costs when its input — cached tokens and cache writes included — is over this many tokens, as OpenAI bills gpt-6-astra over 272K; empty: the list's", + "{m} for 5 minutes, {h} for an hour"].filter((k) => !I18N.zh[k] || !I18N.ja[k] || !I18N.de[k])); + assert.deepEqual(missing, []); + // no left-border accents on the new row + assert.deepEqual(await page.evaluate(() => [...document.querySelectorAll(".mtier, .mtier *")].filter((e) => parseFloat(getComputedStyle(e).borderLeftWidth) > 0 && getComputedStyle(e).borderLeftColor !== getComputedStyle(e).borderTopColor).length), 0); + assert.deepEqual(errors, []); + }); + } +} diff --git a/internal/gui/tests/provider-price-rate.test.cjs b/internal/gui/tests/provider-price-rate.test.cjs index fc515519..aa1943c2 100644 --- a/internal/gui/tests/provider-price-rate.test.cjs +++ b/internal/gui/tests/provider-price-rate.test.cjs @@ -114,7 +114,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", test(`${engine} ${lang}: a signed-in account's rate opens as it is and is saved`, async (t) => { const { page, errors, posts } = await open(t, "Codex"); assert.equal(await box(page).inputValue(), "0.8"); - await box(page).fill("1.5"); + // a comma for the decimal point, as some keyboards type it + await box(page).fill("1,5"); const saved = await save(page, posts); assert.equal(saved.body.id, "codex"); assert.equal(saved.body.priceRate, 1.5); diff --git a/internal/provider/clinepass_test.go b/internal/provider/clinepass_test.go index 464ace30..2c18741a 100644 --- a/internal/provider/clinepass_test.go +++ b/internal/provider/clinepass_test.go @@ -38,7 +38,7 @@ func TestPlanModels(t *testing.T) { t.Fatalf("free: %v", free) } // a free model costs nothing, not its paid model's price - if pr, ok := p.ListPrice("cline-free/deepseek-v4.1-flash"); !ok || pr != (catalog.Price{}) { + if pr, ok := p.ListPrice("cline-free/deepseek-v4.1-flash"); !ok || !pr.Same(catalog.Price{}) { t.Fatalf("free price: %+v %v", pr, ok) } // another provider's list is left as it is @@ -100,7 +100,7 @@ func TestClineFeed(t *testing.T) { // a free model the feed named and no id gives away costs nothing too, // a plan's model is not called free for _, id := range []string{"stealth/new-free-one", "stealth/space-bunny-alpha"} { - if pr, ok := p.ListPrice(id); !ok || pr != (catalog.Price{}) { + if pr, ok := p.ListPrice(id); !ok || !pr.Same(catalog.Price{}) { t.Fatalf("%s: %+v %v", id, pr, ok) } } diff --git a/internal/provider/kilo_test.go b/internal/provider/kilo_test.go index 1dd0bcc0..37f2d8cf 100644 --- a/internal/provider/kilo_test.go +++ b/internal/provider/kilo_test.go @@ -81,7 +81,7 @@ func TestKiloModels(t *testing.T) { t.Fatalf("model: %+v", m) } // a free model by its flag alone costs nothing, not its maker's price - if pr, ok := p.ListPrice("stealth/space-bunny-alpha"); !ok || pr != (catalog.Price{}) { + if pr, ok := p.ListPrice("stealth/space-bunny-alpha"); !ok || !pr.Same(catalog.Price{}) { t.Fatalf("free price: %+v %v", pr, ok) } diff --git a/internal/provider/modelprefs.go b/internal/provider/modelprefs.go index 6232613e..97db279b 100644 --- a/internal/provider/modelprefs.go +++ b/internal/provider/modelprefs.go @@ -144,10 +144,7 @@ func SetModelPrice(id string, p *catalog.Price) error { key = pr.ID + "/" + model } s := settings.Load() - m := settings.ModelPrice{ - Input: new(p.Input), Output: new(p.Output), - CacheRead: new(p.CacheRead), CacheWrite: new(p.CacheWrite), - } + m := settings.StatedPrice(*p) if err := settings.CheckModelPrice(key, m); err != nil { return err } diff --git a/internal/provider/modelprefs_price_test.go b/internal/provider/modelprefs_price_test.go index 7d98056d..a5bdb859 100644 --- a/internal/provider/modelprefs_price_test.go +++ b/internal/provider/modelprefs_price_test.go @@ -20,7 +20,7 @@ func TestSetModelPrefsPrice(t *testing.T) { if err := SetModelPrefs("a", map[string]ModelPref{"sol": {Price: &want}}); err != nil { t.Fatal(err) } - if got, ok := EffectivePrice("a", "sol"); !ok || got != want { + if got, ok := EffectivePrice("a", "sol"); !ok || !got.Same(want) { t.Fatalf("a/sol costs %+v (%v), want %+v", got, ok, want) } if _, ok := settings.Load().ModelPrices["b/sol"]; ok { diff --git a/internal/provider/price_override_test.go b/internal/provider/price_override_test.go index 5c4f967e..b017a14a 100644 --- a/internal/provider/price_override_test.go +++ b/internal/provider/price_override_test.go @@ -352,11 +352,11 @@ func TestSetModelPriceKeysByIdNotByTheNameUsedToAsk(t *testing.T) { if !ok { t.Fatalf("the price is not keyed by the provider's id either: %v", s.ModelPrices) } - if p, bad := got.Price(); bad != "" || p != pr { + if p, bad := got.Price(); bad != "" || !p.Same(pr) { t.Errorf("the price stored under the id reads back as %+v (bad part %q), want %+v", p, bad, pr) } // and it is the price the model is counted at, which was the point - if eff, ok := EffectivePrice("relay", "sol"); !ok || eff != pr { + if eff, ok := EffectivePrice("relay", "sol"); !ok || !eff.Same(pr) { t.Errorf("the model is counted at %+v, %v; want the price just set %+v", eff, ok, pr) } // taking it away by either name clears the one entry diff --git a/internal/sessions/agents_test.go b/internal/sessions/agents_test.go index 24e71731..d7a110e2 100644 --- a/internal/sessions/agents_test.go +++ b/internal/sessions/agents_test.go @@ -93,10 +93,10 @@ func TestZCode(t *testing.T) { t.Fatalf("zcode: %+v", z) } // input without the cache, output with its reasoning already - if m := model(z, "glm-5.1"); m.Tokens != (Tokens{300, 40, 1000, 200}) { + if m := model(z, "glm-5.1"); m.Tokens != (Tokens{300, 40, 1000, 200, 0}) { t.Fatalf("glm-5.1: %+v", m) } - if m := model(z, "glm-5.1-air"); m.Tokens != (Tokens{50, 5, 0, 0}) { + if m := model(z, "glm-5.1-air"); m.Tokens != (Tokens{50, 5, 0, 0, 0}) { t.Fatalf("the subagent's: %+v", m) } if !z.Start.Equal(at("2026-09-27T08:00:00Z")) || !z.Last.Equal(at("2026-09-27T08:00:30Z")) { @@ -121,10 +121,10 @@ func checkDsh(t *testing.T, ss []Session) { t.Fatalf("dsh: %+v", a) } // the format 3 file, not the one left behind - if m := model(a, "deepseek-v4-pro"); m.Tokens != (Tokens{100, 20, 1000, 0}) { + if m := model(a, "deepseek-v4-pro"); m.Tokens != (Tokens{100, 20, 1000, 0, 0}) { t.Fatalf("pro: %+v", m) } - if m := model(a, "deepseek-v4-flash"); m.Tokens != (Tokens{17, 8, 200, 0}) { + if m := model(a, "deepseek-v4-flash"); m.Tokens != (Tokens{17, 8, 200, 0, 0}) { t.Fatalf("flash, the subagent's in: %+v", m) } if !a.Start.Equal(at("2026-09-27T10:00:00Z")) || !a.Last.Equal(at("2026-09-27T10:01:00Z")) { @@ -132,17 +132,17 @@ func checkDsh(t *testing.T, ss []Session) { } // the forks: what they were seeded with counted where it came from c := find(t, ss, "dsh", "dsh-c") - if c.Tokens != (Tokens{50, 10, 0, 0}) || c.Title != "Tidy the build script" { + if c.Tokens != (Tokens{50, 10, 0, 0, 0}) || c.Title != "Tidy the build script" { t.Fatalf("format 3 fork: %+v", c) } if !c.Start.Equal(at("2026-09-27T11:00:00Z")) || !c.Last.Equal(at("2026-09-27T11:00:20Z")) { t.Fatalf("fork times %s %s", c.Start, c.Last) } - if d := find(t, ss, "dsh", "dsh-d"); d.Tokens != (Tokens{1, 2, 0, 0}) || !d.Start.Equal(at("2026-09-27T12:00:00Z")) { + if d := find(t, ss, "dsh", "dsh-d"); d.Tokens != (Tokens{1, 2, 0, 0, 0}) || !d.Start.Equal(at("2026-09-27T12:00:00Z")) { t.Fatalf("format 0 fork: %+v", d) } s := statsAt(0, statsNow) - if got, _ := usageOn(s, "2026-09-27", "/work/dsh"); got != (Tokens{168, 40, 1200, 0}) { + if got, _ := usageOn(s, "2026-09-27", "/work/dsh"); got != (Tokens{168, 40, 1200, 0, 0}) { t.Fatalf("dsh on the 27th: %+v", got) } } @@ -158,7 +158,7 @@ func TestDsh(t *testing.T) { b, _ := os.ReadFile(p) os.WriteFile(p, append(b, more...), 0o644) a := find(t, List(0), "dsh", "dsh-a") - if m := model(a, "deepseek-v4-flash"); m.Tokens != (Tokens{18, 9, 200, 0}) || !a.Last.Equal(at("2026-09-27T10:01:30Z")) { + if m := model(a, "deepseek-v4-flash"); m.Tokens != (Tokens{18, 9, 200, 0, 0}) || !a.Last.Equal(at("2026-09-27T10:01:30Z")) { t.Fatalf("after a new reply: %+v %s", m, a.Last) } os.WriteFile(p, b, 0o644) @@ -203,10 +203,10 @@ func TestCline(t *testing.T) { if c.Cwd != "/work/cline" || c.Title != "Speed up the importer" { t.Fatalf("cline: %+v", c) } - if m := model(c, "claude-opus-5-5"); m.Tokens != (Tokens{100, 50, 1000, 500}) || !m.Priced { + if m := model(c, "claude-opus-5-5"); m.Tokens != (Tokens{100, 50, 1000, 500, 0}) || !m.Priced { t.Fatalf("opus: %+v", m) } - if m := model(c, "claude-haiku-4-5"); m.Tokens != (Tokens{130, 30, 100, 0}) { + if m := model(c, "claude-haiku-4-5"); m.Tokens != (Tokens{130, 30, 100, 0, 0}) { t.Fatalf("haiku, the subagent's in: %+v", m) } if !c.Start.Equal(at("2026-09-27T10:00:00Z")) || !c.Last.Equal(at("2026-09-27T10:02:00Z")) { @@ -219,7 +219,7 @@ func TestCline(t *testing.T) { } // the fork: what it copied counted in the session it came from f := find(t, ss, "cline", clineFork) - if f.Tokens != (Tokens{100, 30, 300, 0}) || f.Title != "Speed up the importer" { + if f.Tokens != (Tokens{100, 30, 300, 0, 0}) || f.Title != "Speed up the importer" { t.Fatalf("fork: %+v", f) } if !f.Start.Equal(at("2026-09-27T12:00:00Z")) || !f.Last.Equal(at("2026-09-27T12:01:00Z")) { @@ -253,7 +253,7 @@ func TestQoder(t *testing.T) { if q.Cwd != "/work/q" || q.Title != "Explain the cache" { t.Fatalf("%s: %+v", c.agent, q) } - if m := model(q, c.model); m.Tokens != (Tokens{45, 30, 300, 0}) { + if m := model(q, c.model); m.Tokens != (Tokens{45, 30, 300, 0, 0}) { t.Fatalf("%s, the subagent's in: %+v", c.agent, m) } if runtime.GOOS != "windows" { diff --git a/internal/sessions/call_cache.go b/internal/sessions/call_cache.go index 365d479d..910922c1 100644 --- a/internal/sessions/call_cache.go +++ b/internal/sessions/call_cache.go @@ -24,7 +24,8 @@ import ( // v5: an OpenCode call keeps the effort its prompt asked for (#680). // v6: Codex response records and compact disk-only parser state. // v7: reconcile recent Claude message revisions. -const callCacheVersion = "calls-v7" +// v8: a Claude Code call's 1-hour cache writes. +const callCacheVersion = "calls-v8" const maxKeptCalls = 131072 const maxKeptFiles = 64 diff --git a/internal/sessions/calls.go b/internal/sessions/calls.go index b143865e..f8b9448e 100644 --- a/internal/sessions/calls.go +++ b/internal/sessions/calls.go @@ -392,10 +392,11 @@ type ccCall struct { ID string `json:"id"` Model string `json:"model"` Usage *struct { - Input int `json:"input_tokens"` - Output int `json:"output_tokens"` - CacheRead int `json:"cache_read_input_tokens"` - CacheWrite int `json:"cache_creation_input_tokens"` + Input int `json:"input_tokens"` + Output int `json:"output_tokens"` + CacheRead int `json:"cache_read_input_tokens"` + CacheWrite int `json:"cache_creation_input_tokens"` + Creation *ccCacheCreation `json:"cache_creation"` } `json:"usage"` } `json:"message"` } @@ -516,7 +517,7 @@ func claudeCallLine(st *callFile, b []byte) { return } c.Model = st.str(model) - c.Tokens = Tokens{Input: u.Input, Output: u.Output, CacheRead: u.CacheRead, CacheWrite: u.CacheWrite} + c.Tokens = ccTokens(u.Input, u.Output, u.CacheRead, u.CacheWrite, u.Creation) if c.Tokens.zero() && m.Usage == nil { return } diff --git a/internal/sessions/calls_opencode_test.go b/internal/sessions/calls_opencode_test.go index c778905d..83f8060e 100644 --- a/internal/sessions/calls_opencode_test.go +++ b/internal/sessions/calls_opencode_test.go @@ -34,7 +34,7 @@ func checkOCCalls(t *testing.T, cs map[string]Call) { t.Fatalf("no call for OpenCode's own reply: %+v", cs) } if a.Model != "gpt-6-astra" || a.Upstream != "openai" || a.Session != ocMain || a.Cwd != "/work/oc" || - a.Tokens != (Tokens{1000, 120, 5000, 0}) || a.Reasoning != 20 || a.Millis != 24000 || + a.Tokens != (Tokens{1000, 120, 5000, 0, 0}) || a.Reasoning != 20 || a.Millis != 24000 || !a.Time.Equal(at("2026-09-26T10:00:30Z")) { t.Fatalf("astra: %+v", a) } @@ -44,7 +44,7 @@ func checkOCCalls(t *testing.T, cs map[string]Call) { } o, ok := cs["msg_0004"] if !ok || o.Model != "claude-opus-5-5" || o.Upstream != "anthropic" || o.Session != ocMain || - o.Tokens != (Tokens{40, 60, 700, 300}) || o.Millis != 30000 || o.Effort != "" { + o.Tokens != (Tokens{40, 60, 700, 300, 0}) || o.Millis != 30000 || o.Effort != "" { t.Fatalf("the subagent's opus: %+v", o) } if len(cs) != 2 { @@ -118,11 +118,11 @@ func TestOpenCodeCallsV2(t *testing.T) { if _, ok := cs["msg_0005"]; ok { t.Fatalf("a compaction through magpie counted: %+v", cs["msg_0005"]) } - if c := cs["msg_0007"]; c.Model != "big-pickle" || c.Upstream != "opencode" || c.Tokens != (Tokens{7, 3, 0, 0}) || c.Effort != "xhigh" { + if c := cs["msg_0007"]; c.Model != "big-pickle" || c.Upstream != "opencode" || c.Tokens != (Tokens{7, 3, 0, 0, 0}) || c.Effort != "xhigh" { t.Fatalf("an OpenCode Zen reply: %+v", c) } // a compaction is made with the model and effort last replied with - if c := cs["msg_0008"]; c.Model != "big-pickle" || c.Upstream != "opencode" || c.Tokens != (Tokens{11, 2, 0, 0}) || c.Effort != "xhigh" { + if c := cs["msg_0008"]; c.Model != "big-pickle" || c.Upstream != "opencode" || c.Tokens != (Tokens{11, 2, 0, 0, 0}) || c.Effort != "xhigh" { t.Fatalf("its compaction: %+v", c) } } diff --git a/internal/sessions/calls_test.go b/internal/sessions/calls_test.go index 2e120f11..0581aa36 100644 --- a/internal/sessions/calls_test.go +++ b/internal/sessions/calls_test.go @@ -107,12 +107,12 @@ func TestCallsClaude(t *testing.T) { cs := Calls(time.Time{}) want := []Call{ - {Time: callT0.Add(11 * time.Second), Agent: "claude", Session: "sess1", Model: "claude-haiku-4-5-20251001", Tokens: Tokens{11, 12, 13, 14}, RequestID: "req_s1", Cwd: "/work/app"}, - {Time: callT0.Add(10 * time.Second), Agent: "claude-desktop", Session: "sess-d", Model: "claude-sonnet-5-5", Tokens: Tokens{1, 2, 3, 4}, RequestID: "req_m4", Cwd: "/work/app", Millis: 1000}, + {Time: callT0.Add(11 * time.Second), Agent: "claude", Session: "sess1", Model: "claude-haiku-4-5-20251001", Tokens: Tokens{11, 12, 13, 14, 0}, RequestID: "req_s1", Cwd: "/work/app"}, + {Time: callT0.Add(10 * time.Second), Agent: "claude-desktop", Session: "sess-d", Model: "claude-sonnet-5-5", Tokens: Tokens{1, 2, 3, 4, 0}, RequestID: "req_m4", Cwd: "/work/app", Millis: 1000}, {Time: callT0.Add(9 * time.Second), Agent: "claude", Session: "sess1", Error: "unknown", ErrorText: "API Error: " + strings.Repeat("é", 389), Cwd: "/work/app"}, {Time: callT0.Add(8 * time.Second), Agent: "claude", Session: "sess1", Error: "rate_limit", ErrorText: "You've hit your session limit · resets 3:40am", RequestID: "req_err", Cwd: "/work/app"}, - {Time: callT0.Add(5 * time.Second), Agent: "claude", Session: "sess1", Model: "claude-opus-5-5", Tokens: Tokens{100, 55, 5000, 1000}, RequestID: "req_m1", Cwd: "/work/app", Millis: 5000}, - {Time: callT0.Add(4 * time.Second), Agent: "claude", Session: "sess1", Model: "claude-haiku-4-5-20251001", Tokens: Tokens{7, 8, 0, 0}, RequestID: "req_m2", Cwd: "/work/app", Millis: 1000}, + {Time: callT0.Add(5 * time.Second), Agent: "claude", Session: "sess1", Model: "claude-opus-5-5", Tokens: Tokens{100, 55, 5000, 1000, 0}, RequestID: "req_m1", Cwd: "/work/app", Millis: 5000}, + {Time: callT0.Add(4 * time.Second), Agent: "claude", Session: "sess1", Model: "claude-haiku-4-5-20251001", Tokens: Tokens{7, 8, 0, 0, 0}, RequestID: "req_m2", Cwd: "/work/app", Millis: 1000}, } if len(cs) != len(want) { t.Fatalf("want %d calls, got %d: %+v", len(want), len(cs), cs) @@ -140,7 +140,7 @@ func TestCallsCowork(t *testing.T) { t.Fatalf("want 3 calls, got %d: %+v", len(cs), cs) } if cs[0].Agent != "claude-desktop" || cs[0].Model != "claude-haiku-4-5-20251001" || cs[0].Session != "cw1" || - cs[1].Agent != "claude-desktop" || cs[1].Tokens != (Tokens{5, 6, 7, 8}) || + cs[1].Agent != "claude-desktop" || cs[1].Tokens != (Tokens{5, 6, 7, 8, 0}) || cs[2].Agent != "claude" { t.Fatalf("calls: %+v", cs) } @@ -211,13 +211,13 @@ func TestCallsCodex(t *testing.T) { cs := Calls(time.Time{}) want := []Call{ - {Time: callT0.Add(200 * time.Second), Agent: "codex", Session: "0190cccc-1111-7222-8333-444455556666", Tokens: Tokens{6, 3, 4, 0}}, - {Time: callT0.Add(103 * time.Second), Agent: "codex", Session: "thread-2", Model: "gpt-6-astra", Tokens: Tokens{500, 20, 0, 0}, Reasoning: 5, Effort: "medium", Cwd: "/work/it", Millis: 1000}, - {Time: callT0.Add(102 * time.Second), Agent: "codex", Session: "thread-2", Model: "gpt-6-astra", Tokens: Tokens{100, 100, 3900, 0}, Reasoning: 30, Effort: "medium", Cwd: "/work/it", Millis: 1000}, + {Time: callT0.Add(200 * time.Second), Agent: "codex", Session: "0190cccc-1111-7222-8333-444455556666", Tokens: Tokens{6, 3, 4, 0, 0}}, + {Time: callT0.Add(103 * time.Second), Agent: "codex", Session: "thread-2", Model: "gpt-6-astra", Tokens: Tokens{500, 20, 0, 0, 0}, Reasoning: 5, Effort: "medium", Cwd: "/work/it", Millis: 1000}, + {Time: callT0.Add(102 * time.Second), Agent: "codex", Session: "thread-2", Model: "gpt-6-astra", Tokens: Tokens{100, 100, 3900, 0, 0}, Reasoning: 30, Effort: "medium", Cwd: "/work/it", Millis: 1000}, // input_tokens holds the cache write as well as the read (#589): 2500 - 2000 - 300 - {Time: callT0.Add(12 * time.Second), Agent: "codex", Session: "sess-x", Model: "gpt-6-luna", Tokens: Tokens{200, 150, 2000, 300}, Reasoning: 40, Effort: "low", Cwd: "/work/it", Millis: 1000}, - {Time: callT0.Add(11 * time.Second), Agent: "codex", Session: "sess-x", Model: "gpt-6-luna", Tokens: Tokens{1000, 100, 1000, 0}, Reasoning: 40, Effort: "low", Cwd: "/work/it", Millis: 1000}, - {Time: callT0.Add(2 * time.Second), Agent: "codex", Session: "sess-x", Model: "gpt-6-astra", Tokens: Tokens{400, 50, 600, 0}, Reasoning: 20, Effort: "high", Cwd: "/work/it", TTFT: 700, Millis: 5000}, + {Time: callT0.Add(12 * time.Second), Agent: "codex", Session: "sess-x", Model: "gpt-6-luna", Tokens: Tokens{200, 150, 2000, 300, 0}, Reasoning: 40, Effort: "low", Cwd: "/work/it", Millis: 1000}, + {Time: callT0.Add(11 * time.Second), Agent: "codex", Session: "sess-x", Model: "gpt-6-luna", Tokens: Tokens{1000, 100, 1000, 0, 0}, Reasoning: 40, Effort: "low", Cwd: "/work/it", Millis: 1000}, + {Time: callT0.Add(2 * time.Second), Agent: "codex", Session: "sess-x", Model: "gpt-6-astra", Tokens: Tokens{400, 50, 600, 0, 0}, Reasoning: 20, Effort: "high", Cwd: "/work/it", TTFT: 700, Millis: 5000}, } if len(cs) != len(want) { t.Fatalf("want %d calls, got %d: %+v", len(want), len(cs), cs) @@ -310,7 +310,7 @@ func TestCallsIncremental(t *testing.T) { if len(claude) != 3 || claude[0].Tokens.Input != 3 || claude[1].Tokens != (Tokens{Input: 2, Output: 9}) || claude[2].Tokens.Input != 1 { t.Fatalf("claude: %+v", claude) } - if len(codex) != 2 || codex[0].Tokens != (Tokens{100, 30, 100, 0}) || codex[1].Tokens != (Tokens{50, 10, 50, 0}) { + if len(codex) != 2 || codex[0].Tokens != (Tokens{100, 30, 100, 0, 0}) || codex[1].Tokens != (Tokens{50, 10, 50, 0, 0}) { t.Fatalf("codex: %+v", codex) } diff --git a/internal/sessions/claude.go b/internal/sessions/claude.go index fd82e037..33adda85 100644 --- a/internal/sessions/claude.go +++ b/internal/sessions/claude.go @@ -28,10 +28,11 @@ type ccLine struct { Model string `json:"model"` Content json.RawMessage `json:"content"` Usage *struct { - Input int `json:"input_tokens"` - Output int `json:"output_tokens"` - CacheRead int `json:"cache_read_input_tokens"` - CacheWrite int `json:"cache_creation_input_tokens"` + Input int `json:"input_tokens"` + Output int `json:"output_tokens"` + CacheRead int `json:"cache_read_input_tokens"` + CacheWrite int `json:"cache_creation_input_tokens"` + Creation *ccCacheCreation `json:"cache_creation"` } `json:"usage"` } `json:"message"` } @@ -204,15 +205,16 @@ func ccReply(s *state, at time.Time, msg, b []byte, main bool) { return } var u struct { - Input int `json:"input_tokens"` - Output int `json:"output_tokens"` - CacheRead int `json:"cache_read_input_tokens"` - CacheWrite int `json:"cache_creation_input_tokens"` + Input int `json:"input_tokens"` + Output int `json:"output_tokens"` + CacheRead int `json:"cache_read_input_tokens"` + CacheWrite int `json:"cache_creation_input_tokens"` + Creation *ccCacheCreation `json:"cache_creation"` } if json.NewDecoder(bytes.NewReader(b[i+len(ccUsage)-1:])).Decode(&u) != nil { return } - ccCount(s, m, at, model, Tokens{Input: u.Input, Output: u.Output, CacheRead: u.CacheRead, CacheWrite: u.CacheWrite}, main) + ccCount(s, m, at, model, ccTokens(u.Input, u.Output, u.CacheRead, u.CacheWrite, u.Creation), main) } // claudeFull reads a line whole. @@ -280,7 +282,7 @@ func claudeFull(s *state, b []byte, main bool) { if u == nil || l.Message.Model == "" { return } - ccCount(s, m, at, l.Message.Model, Tokens{Input: u.Input, Output: u.Output, CacheRead: u.CacheRead, CacheWrite: u.CacheWrite}, main) + ccCount(s, m, at, l.Message.Model, ccTokens(u.Input, u.Output, u.CacheRead, u.CacheWrite, u.Creation), main) } } diff --git a/internal/sessions/claude_cache_ttl_test.go b/internal/sessions/claude_cache_ttl_test.go new file mode 100644 index 00000000..2d1f9c82 --- /dev/null +++ b/internal/sessions/claude_cache_ttl_test.go @@ -0,0 +1,33 @@ +package sessions + +import ( + "fmt" + "testing" + "time" +) + +// PAMI on Discord: Claude Code's transcript says which cache writes were +// kept for an hour (cache_creation's ephemeral_1h_input_tokens), which +// Anthropic bills at 2× input, not 1.25×. Both readers keep them apart; a +// line without the split reads as before. +func TestClaudeCacheWriteSplit(t *testing.T) { + for _, fast := range []bool{false, true} { + t.Run(fmt.Sprint("fast=", fast), func(t *testing.T) { + s := &state{} + at := time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC) + id := `"id":"a","model":"m"` + if fast { + id = `"model":"m","id":"a"` + } + claudeLine(s, []byte(fmt.Sprintf(`{"type":"assistant","timestamp":%q,"requestId":"req-a","message":{%s,"role":"assistant","content":[],"usage":{"input_tokens":10,"output_tokens":2,"cache_read_input_tokens":30,"cache_creation_input_tokens":3000,"cache_creation":{"ephemeral_5m_input_tokens":1000,"ephemeral_1h_input_tokens":2000}}}}`, + at.Format(time.RFC3339Nano), id)), true) + claudeLine(s, claudeUsageLine(at.Add(time.Second), "b", "req-b", "m", Tokens{Input: 1, CacheWrite: 500}, "", fast), true) + if want := (Tokens{Input: 11, Output: 2, CacheRead: 30, CacheWrite: 3500, CacheWrite1h: 2000}); s.Models["m"] != want { + t.Fatalf("tokens = %+v, want %+v", s.Models["m"], want) + } + }) + } + if got := ccTokens(1, 2, 3, 0, &ccCacheCreation{Ephemeral1h: 40}); got.CacheWrite != 40 || got.CacheWrite1h != 40 { + t.Errorf("a split with no total = %+v", got) + } +} diff --git a/internal/sessions/claude_native_review_test.go b/internal/sessions/claude_native_review_test.go index ae3fe912..ccf74ec7 100644 --- a/internal/sessions/claude_native_review_test.go +++ b/internal/sessions/claude_native_review_test.go @@ -17,7 +17,7 @@ func TestClaudeNativeReplayAcrossReadersAndRestart(t *testing.T) { t.Fatal(err) } lines := strings.Split(strings.TrimSpace(string(data)), "\n") - want := Tokens{10338, 37363, 281066, 63398} + want := Tokens{10338, 37363, 281066, 63398, 0} for split := 0; split <= len(lines); split++ { t.Run(string(rune('0'+split)), func(t *testing.T) { d := setupCalls(t) @@ -114,7 +114,7 @@ func TestEvictedClaudeRevisionsReleasedAndRebuilt(t *testing.T) { t.Fatal("evicted Claude retained continuation") } appendText(t, path, lines[2]+"\n") - want := Tokens{10338, 37363, 281066, 63398} + want := Tokens{10338, 37363, 281066, 63398, 0} List(0) var got Tokens for _, v := range cache[path].Models { diff --git a/internal/sessions/claude_usage_test.go b/internal/sessions/claude_usage_test.go index 2418db4b..a8e24812 100644 --- a/internal/sessions/claude_usage_test.go +++ b/internal/sessions/claude_usage_test.go @@ -34,7 +34,7 @@ func TestClaudeUsageNonAdjacentReplay(t *testing.T) { t.Run(fmt.Sprint("fast=", fast), func(t *testing.T) { s := &state{} at := time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC) - a, b := Tokens{10, 2, 30, 4}, Tokens{20, 4, 60, 8} + a, b := Tokens{10, 2, 30, 4, 0}, Tokens{20, 4, 60, 8, 0} claudeLine(s, claudeUsageLine(at, "a", "req-a", "m", a, "", fast), true) claudeLine(s, claudeUsageLine(at.Add(time.Second), "b", "req-b", "m", b, "", fast), true) claudeLine(s, claudeUsageLine(at.Add(48*time.Hour), "a", "req-a", "m", a, "", fast), true) @@ -55,7 +55,7 @@ func TestClaudeUsageRevisionAndTools(t *testing.T) { t.Run(fmt.Sprint("fast=", fast), func(t *testing.T) { s := &state{} at := time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC) - old, updated := Tokens{10, 1, 30, 4}, Tokens{10, 7, 30, 4} + old, updated := Tokens{10, 1, 30, 4, 0}, Tokens{10, 7, 30, 4, 0} tool := `[{"type":"tool_use","id":"tool-1","name":"Read","input":{"requestId":"not-the-response"}}]` newTool := `[{"type":"tool_use","id":"tool-2","name":"Skill","input":{"skill":"review"}}]` line := func(d time.Duration, model string, tok Tokens, tools string) []byte { @@ -64,7 +64,7 @@ func TestClaudeUsageRevisionAndTools(t *testing.T) { claudeLine(s, line(0, "m", old, tool), true) claudeLine(s, line(time.Second, "m", updated, newTool), true) claudeLine(s, line(2*time.Second, "m", old, tool), true) - claudeLine(s, line(time.Millisecond, "m", Tokens{10, 99, 30, 4}, tool), true) + claudeLine(s, line(time.Millisecond, "m", Tokens{10, 99, 30, 4, 0}, tool), true) if s.Models["m"] != updated || claudeUsageReplies(s) != 1 { t.Fatalf("old replay undid newer usage: %+v", s) } @@ -73,7 +73,7 @@ func TestClaudeUsageRevisionAndTools(t *testing.T) { t.Fatalf("tools=%v skills=%v", d.Tools, d.Skills) } // A genuinely newer correction can reduce tokens and change model. - correction := Tokens{8, 5, 20, 0} + correction := Tokens{8, 5, 20, 0, 0} claudeLine(s, line(48*time.Hour, "m2", correction, ""), true) if !s.Models["m"].zero() || s.Models["m2"] != correction || d.Replies != 1 { t.Fatalf("correction failed: %+v", s) @@ -91,7 +91,7 @@ func TestClaudeUsageRevisionAndTools(t *testing.T) { func TestClaudeUsageIdentity(t *testing.T) { at := time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC) - tok := Tokens{3, 4, 5, 6} + tok := Tokens{3, 4, 5, 6, 0} cases := []struct { name string identities [][2]string @@ -130,7 +130,7 @@ func TestClaudeUsageIdentity(t *testing.T) { func TestClaudeUsageMissingToolIdentityAndSubagent(t *testing.T) { s := &state{} at := time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC) - tok := Tokens{3, 4, 5, 6} + tok := Tokens{3, 4, 5, 6, 0} content := `[{"type":"tool_use","name":"Read","input":{}}]` for i := range 2 { claudeLine(s, claudeUsageLine(at.Add(time.Duration(i)*time.Second), "a", "r", "m", tok, content, true), false) @@ -156,7 +156,7 @@ func TestClaudeToolRevisionRejectsReplayedName(t *testing.T) { func TestClaudeCallReplayKeepsCompletionWithoutUUID(t *testing.T) { st := &callFile{Agent: "claude"} at := time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC) - tok := Tokens{3, 4, 5, 6} + tok := Tokens{3, 4, 5, 6, 0} content := `[{"type":"text","text":"original block"}]` claudeCallLine(st, claudeUsageLine(at, "a", "ra", "m", tok, content, true)) claudeCallLine(st, claudeUsageLine(at.Add(time.Second), "b", "rb", "m", tok, "", true)) @@ -171,7 +171,7 @@ func TestClaudeRepeatedBlockRejectsZeroedHistoricalSnapshot(t *testing.T) { t.Run(fmt.Sprint("fast=", fast), func(t *testing.T) { s, calls := &state{}, &callFile{Agent: "claude"} at := time.Date(2026, 9, 25, 3, 0, 0, 0, time.UTC) - tokens := Tokens{69, 31852, 185103, 998} + tokens := Tokens{69, 31852, 185103, 998, 0} // Two blocks report the same whole-message usage. Historical // copies later retain both original timestamps but zero usage. for i, usage := range []Tokens{tokens, tokens, {}, {}} { @@ -188,7 +188,7 @@ func TestClaudeRepeatedBlockRejectsZeroedHistoricalSnapshot(t *testing.T) { func TestClaudeUsageResumeEquivalence(t *testing.T) { at := time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC) - old, updated := Tokens{10, 1, 30, 4}, Tokens{10, 7, 30, 4} + old, updated := Tokens{10, 1, 30, 4, 0}, Tokens{10, 7, 30, 4, 0} tool := `[{"type":"tool_use","id":"tool-1","name":"Read","input":{}}]` lines := [][]byte{ claudeUsageLine(at, "a", "", "m", old, tool, true), diff --git a/internal/sessions/codex_native_review_test.go b/internal/sessions/codex_native_review_test.go index d4d38f08..d124a97e 100644 --- a/internal/sessions/codex_native_review_test.go +++ b/internal/sessions/codex_native_review_test.go @@ -18,8 +18,8 @@ func TestCodexNativeReadersAgree(t *testing.T) { calls int tokens Tokens }{ - {"codex-native-paginated.jsonl", 3, Tokens{128290, 293, 254720, 0}}, - {"codex-native-compaction.jsonl", 4, Tokens{22857, 8001, 520320, 0}}, + {"codex-native-paginated.jsonl", 3, Tokens{128290, 293, 254720, 0, 0}}, + {"codex-native-compaction.jsonl", 4, Tokens{22857, 8001, 520320, 0, 0}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/sessions/codex_runtime_reset_test.go b/internal/sessions/codex_runtime_reset_test.go index 85401a96..40425b60 100644 --- a/internal/sessions/codex_runtime_reset_test.go +++ b/internal/sessions/codex_runtime_reset_test.go @@ -26,7 +26,7 @@ func TestCodexUsageBothCountersRestartAfterLegacyHistory(t *testing.T) { if cs := Calls(time.Time{}); len(cs) != 5 { t.Fatalf("calls=%+v", cs) } - if ss := List(0); len(ss) != 1 || ss[0].Tokens != (Tokens{400, 50, 100, 0}) { + if ss := List(0); len(ss) != 1 || ss[0].Tokens != (Tokens{400, 50, 100, 0, 0}) { t.Fatalf("summary=%+v", ss) } }) @@ -43,7 +43,7 @@ func TestCodexUsageLargerFirstRequestAfterLegacyReset(t *testing.T) { if cs := Calls(time.Time{}); len(cs) != 2 { t.Fatalf("new runtime's first request lost or replay counted: %+v", cs) } - if ss := List(0); len(ss) != 1 || ss[0].Tokens != (Tokens{46055, 1281, 137984, 0}) { + if ss := List(0); len(ss) != 1 || ss[0].Tokens != (Tokens{46055, 1281, 137984, 0, 0}) { t.Fatalf("summary=%+v", ss) } } diff --git a/internal/sessions/codex_snapshot_test.go b/internal/sessions/codex_snapshot_test.go index 15f166eb..c54039eb 100644 --- a/internal/sessions/codex_snapshot_test.go +++ b/internal/sessions/codex_snapshot_test.go @@ -46,7 +46,7 @@ func TestCodexUsagePaginatedSnapshotBoundary(t *testing.T) { for _, c := range cs { total.add(c.Tokens) } - if total != (Tokens{340, 52, 190, 0}) { + if total != (Tokens{340, 52, 190, 0, 0}) { t.Fatalf("tokens=%+v", total) } if ss := List(0); len(ss) != 1 || ss[0].Tokens != total { diff --git a/internal/sessions/codex_usage_test.go b/internal/sessions/codex_usage_test.go index b5ad5bb0..18ce2419 100644 --- a/internal/sessions/codex_usage_test.go +++ b/internal/sessions/codex_usage_test.go @@ -33,7 +33,7 @@ func TestCodexUsageCompactionCounterDomains(t *testing.T) { } total.add(c.Tokens) } - if len(cs) != 4 || total != (Tokens{11157, 6856, 496640, 0}) { + if len(cs) != 4 || total != (Tokens{11157, 6856, 496640, 0, 0}) { t.Fatalf("calls=%d tokens=%+v", len(cs), total) } if ss := List(0); len(ss) != 1 || ss[0].Tokens != total { @@ -90,7 +90,7 @@ func TestCodexUsageCounterDomainOffsetChangesAndReload(t *testing.T) { if epoch := callCache[path].CX.Usage.Epoch; epoch != 0 { t.Fatalf("cross-domain reset: %d", epoch) } - if ss := List(0); len(ss) != 1 || ss[0].Tokens != (Tokens{1080, 180, 720, 0}) { + if ss := List(0); len(ss) != 1 || ss[0].Tokens != (Tokens{1080, 180, 720, 0, 0}) { t.Fatalf("summary: %+v", ss) } resetCalls() @@ -152,7 +152,7 @@ func TestCodexUsageResumeCounterDomainsAndReload(t *testing.T) { if len(cs) != 9 { t.Fatalf("reset doubled/lost calls: %+v", cs) } - if ss := List(0); len(ss) != 1 || ss[0].Tokens != (Tokens{600, 100, 400, 0}) { + if ss := List(0); len(ss) != 1 || ss[0].Tokens != (Tokens{600, 100, 400, 0, 0}) { t.Fatalf("summary: %+v", ss) } Reset() @@ -199,7 +199,7 @@ func TestCodexUsageEmbeddedCompactionBeforeTopLevelRecord(t *testing.T) { if cs := Calls(time.Time{}); len(cs) != 2 { t.Fatalf("compaction replay duplicated usage: %+v", cs) } - if ss := List(0); len(ss) != 1 || ss[0].Tokens != (Tokens{120, 20, 80, 0}) { + if ss := List(0); len(ss) != 1 || ss[0].Tokens != (Tokens{120, 20, 80, 0, 0}) { t.Fatalf("summary: %+v", ss) } } @@ -265,7 +265,7 @@ func TestCodexUsageRealCheckpointPairs(t *testing.T) { t.Fatalf("identity: %+v", c) } } - if total != (Tokens{4515, 1084, 299776, 0}) { + if total != (Tokens{4515, 1084, 299776, 0, 0}) { t.Fatal(total) } if cs[0].Time.Format(time.RFC3339Nano) != "2026-10-03T08:49:56.408Z" || cs[1].Time.Format(time.RFC3339Nano) != "2026-10-03T08:49:31.433Z" { diff --git a/internal/sessions/droid_test.go b/internal/sessions/droid_test.go index 6e3b5e24..7f3ac80a 100644 --- a/internal/sessions/droid_test.go +++ b/internal/sessions/droid_test.go @@ -70,10 +70,10 @@ func TestDroid(t *testing.T) { t.Fatalf("droid: %+v", d) } // magpie's custom model by the model it asks for, the subagent's in - if m := model(d, "anthropic/claude-sonnet-4-5"); m.Tokens != (Tokens{100, 50, 1000, 10}) { + if m := model(d, "anthropic/claude-sonnet-4-5"); m.Tokens != (Tokens{100, 50, 1000, 10, 0}) { t.Fatalf("sonnet: %+v", d.Models) } - if m := model(d, "claude-haiku-4-5"); m.Tokens != (Tokens{5, 2, 0, 0}) { + if m := model(d, "claude-haiku-4-5"); m.Tokens != (Tokens{5, 2, 0, 0, 0}) { t.Fatalf("haiku, the subagent's in: %+v", d.Models) } if !d.Start.Equal(at("2026-09-27T08:00:00Z")) || !d.Last.Equal(at("2026-09-27T08:02:00Z")) { @@ -90,7 +90,7 @@ func TestDroid(t *testing.T) { if f.Title != "Try another way" || !f.Start.Equal(at("2026-09-27T09:00:00Z")) || !f.Last.Equal(at("2026-09-27T09:00:30Z")) { t.Fatalf("fork: %+v", f) } - if m := model(f, "Kimi-K2"); m.Tokens != (Tokens{7, 3, 0, 0}) { + if m := model(f, "Kimi-K2"); m.Tokens != (Tokens{7, 3, 0, 0, 0}) { t.Fatalf("fork models: %+v", f.Models) } if o := find(t, ss, "droid", droidOld); o.Title != "hi there" || !o.Tokens.zero() { diff --git a/internal/sessions/grok_workbuddy_test.go b/internal/sessions/grok_workbuddy_test.go index 3bc462e1..004e1f95 100644 --- a/internal/sessions/grok_workbuddy_test.go +++ b/internal/sessions/grok_workbuddy_test.go @@ -28,10 +28,10 @@ func TestGrok(t *testing.T) { t.Fatalf("grok: %+v", g) } // the cache out of the input, the subagent's turn in once - if m := model(g, "grok-4.7-build"); m.Tokens != (Tokens{700, 100, 2800, 0}) { + if m := model(g, "grok-4.7-build"); m.Tokens != (Tokens{700, 100, 2800, 0, 0}) { t.Fatalf("build: %+v", m) } - if m := model(g, "grok-4.7-mini"); m.Tokens != (Tokens{300, 30, 100, 0}) { + if m := model(g, "grok-4.7-mini"); m.Tokens != (Tokens{300, 30, 100, 0, 0}) { t.Fatalf("mini, the subagent's in: %+v", m) } // the hook that ran as it was closed, a day on, isn't when it was at work @@ -45,7 +45,7 @@ func TestGrok(t *testing.T) { } // the fork: what it copied counted in the session it came from f := find(t, ss, "grok", grokFork) - if f.Tokens != (Tokens{100, 30, 300, 0}) || f.Title != "Try another way" { + if f.Tokens != (Tokens{100, 30, 300, 0, 0}) || f.Title != "Try another way" { t.Fatalf("fork: %+v", f) } if !f.Start.Equal(at("2026-09-27T09:00:00.25Z")) || !f.Last.Equal(at("2026-09-27T09:00:30Z")) { @@ -85,7 +85,7 @@ func TestWorkBuddy(t *testing.T) { t.Fatalf("workbuddy: %+v", w) } // a reply's usage counted once, however many of its lines carry it - if m := model(w, "hy3"); m.Tokens != (Tokens{400, 120, 2200, 0}) { + if m := model(w, "hy3"); m.Tokens != (Tokens{400, 120, 2200, 0, 0}) { t.Fatalf("hy3: %+v", m) } if !w.Start.Equal(at("2026-09-27T10:00:00Z")) || !w.Last.Equal(at("2026-09-27T10:00:10Z")) { @@ -96,7 +96,7 @@ func TestWorkBuddy(t *testing.T) { if o.Cwd != "/work/wb-old" || o.Title != "Quarterly report" { t.Fatalf("old: %+v", o) } - if m := model(o, "auto"); m.Tokens != (Tokens{5000, 100, 0, 0}) { + if m := model(o, "auto"); m.Tokens != (Tokens{5000, 100, 0, 0, 0}) { t.Fatalf("auto: %+v", m) } diff --git a/internal/sessions/hermes_test.go b/internal/sessions/hermes_test.go index 05a8a8a0..ce1dbff6 100644 --- a/internal/sessions/hermes_test.go +++ b/internal/sessions/hermes_test.go @@ -117,7 +117,7 @@ func TestHermesListStatsCallsAndContent(t *testing.T) { if !s.ReadOnly || s.Resume != "" || s.Title != "A Hermes task" || s.Cwd != "/work/hermes" { t.Errorf("metadata/read-only state: %+v", s) } - if m := model(s, "actual-model"); m.Tokens != (Tokens{100, 50, 25, 15}) { + if m := model(s, "actual-model"); m.Tokens != (Tokens{100, 50, 25, 15, 0}) { t.Errorf("usage aggregate: %+v", m) } if model(s, "fallback-model").Model != "" { @@ -141,7 +141,7 @@ func TestHermesListStatsCallsAndContent(t *testing.T) { } } } - if total != (Tokens{200, 100, 50, 30}) { + if total != (Tokens{200, 100, 50, 30, 0}) { t.Errorf("Hermes stats total = %+v", total) } @@ -162,7 +162,7 @@ func TestHermesUsageFallbackAndLiveRefresh(t *testing.T) { path := hermesFixture(t, root, "", "fallback", false) first := hermesSessionByID(t, hermesID(path, "fallback")) - if got := model(first, "fallback-model").Tokens; got != (Tokens{90, 30, 40, 10}) { + if got := model(first, "fallback-model").Tokens; got != (Tokens{90, 30, 40, 10, 0}) { t.Fatalf("session aggregate fallback = %+v", got) } @@ -192,7 +192,7 @@ func TestHermesUsageFallbackAndLiveRefresh(t *testing.T) { if updated.Title != "Updated title" { t.Errorf("title not refreshed: %q", updated.Title) } - if got := model(updated, "new-model").Tokens; got != (Tokens{11, 7, 3, 2}) { + if got := model(updated, "new-model").Tokens; got != (Tokens{11, 7, 3, 2, 0}) { t.Errorf("WAL usage not refreshed: %+v", got) } // The bucket timestamp intentionally stays fixed. Newly observed usage @@ -208,7 +208,7 @@ func TestHermesUsageFallbackAndLiveRefresh(t *testing.T) { t.Fatal(err) } updated = hermesSessionByID(t, hermesID(path, "fallback")) - if updated.Title != "Changed again" || model(updated, "new-model").Tokens != (Tokens{13, 9, 4, 3}) { + if updated.Title != "Changed again" || model(updated, "new-model").Tokens != (Tokens{13, 9, 4, 3, 0}) { t.Errorf("same-mtime usage/title refresh: %+v", updated) } } diff --git a/internal/sessions/hermes_usage_residual_test.go b/internal/sessions/hermes_usage_residual_test.go index fed3f72a..56eaabc2 100644 --- a/internal/sessions/hermes_usage_residual_test.go +++ b/internal/sessions/hermes_usage_residual_test.go @@ -52,10 +52,10 @@ func TestHermesUsageAddsMainResidualWhenOnlyAuxiliaryRowsExist(t *testing.T) { setup(t) path := hermesTaskUsageFixture(t, "aux-only", nil) s := hermesSessionByID(t, hermesID(path, "aux-only")) - if got := model(s, "fallback-model").Tokens; got != (Tokens{90, 30, 40, 10}) { + if got := model(s, "fallback-model").Tokens; got != (Tokens{90, 30, 40, 10, 0}) { t.Fatalf("main-loop residual = %+v, want sessions aggregate", got) } - if got := model(s, "actual-model").Tokens; got != (Tokens{100, 50, 25, 15}) { + if got := model(s, "actual-model").Tokens; got != (Tokens{100, 50, 25, 15, 0}) { t.Fatalf("auxiliary usage = %+v", got) } // Hermes does not emit Calls; verify no leakage. @@ -114,13 +114,13 @@ func TestHermesUsageResidualSubtractsMainAttributionOnly(t *testing.T) { setup(t) path := hermesTaskUsageFixture(t, "mixed", &Tokens{Input: 60, Output: 20, CacheRead: 10, CacheWrite: 5}) s := hermesSessionByID(t, hermesID(path, "mixed")) - if got := model(s, "main-model").Tokens; got != (Tokens{60, 20, 10, 5}) { + if got := model(s, "main-model").Tokens; got != (Tokens{60, 20, 10, 5, 0}) { t.Fatalf("main attribution = %+v", got) } - if got := model(s, "fallback-model").Tokens; got != (Tokens{30, 10, 30, 5}) { + if got := model(s, "fallback-model").Tokens; got != (Tokens{30, 10, 30, 5, 0}) { t.Fatalf("main residual = %+v, want remaining sessions aggregate", got) } - if got := model(s, "actual-model").Tokens; got != (Tokens{100, 50, 25, 15}) { + if got := model(s, "actual-model").Tokens; got != (Tokens{100, 50, 25, 15, 0}) { t.Fatalf("auxiliary usage changed = %+v", got) } } diff --git a/internal/sessions/omp_test.go b/internal/sessions/omp_test.go index 1c6ac2fd..5a85286b 100644 --- a/internal/sessions/omp_test.go +++ b/internal/sessions/omp_test.go @@ -31,10 +31,10 @@ func TestOmp(t *testing.T) { model string want Tokens }{ - {"claude-opus-5-5", Tokens{110, 55, 1500, 200}}, - {"claude-haiku-5", Tokens{20, 5, 0, 0}}, // a model_usage entry - {"claude-sonnet-5", Tokens{340, 34, 100, 0}}, // the subagent and its own - {"gpt-6-astra", Tokens{50, 10, 0, 0}}, // the advisor + {"claude-opus-5-5", Tokens{110, 55, 1500, 200, 0}}, + {"claude-haiku-5", Tokens{20, 5, 0, 0, 0}}, // a model_usage entry + {"claude-sonnet-5", Tokens{340, 34, 100, 0, 0}}, // the subagent and its own + {"gpt-6-astra", Tokens{50, 10, 0, 0, 0}}, // the advisor } { if got := model(m, c.model); got.Tokens != c.want { t.Fatalf("%s: %+v, want %+v", c.model, got, c.want) @@ -54,7 +54,7 @@ func TestOmp(t *testing.T) { // the fork: what it copied counted in the session it came from f := find(t, ss, "omp", ompFork) - if f.Tokens != (Tokens{7, 3, 0, 0}) || f.Title != "Port the parser" { + if f.Tokens != (Tokens{7, 3, 0, 0, 0}) || f.Title != "Port the parser" { t.Fatalf("fork: %+v", f) } if !f.Start.Equal(at("2026-09-29T09:00:00Z")) || !f.Last.Equal(at("2026-09-29T09:00:20Z")) { diff --git a/internal/sessions/opencode_pi_test.go b/internal/sessions/opencode_pi_test.go index f8a8334f..c3bd7af1 100644 --- a/internal/sessions/opencode_pi_test.go +++ b/internal/sessions/opencode_pi_test.go @@ -64,13 +64,13 @@ func checkOpenCode(t *testing.T, ss []Session) { if oc.Cwd != "/work/oc" || oc.Title != "Why does the login test flake?" { t.Fatalf("opencode: %+v", oc) } - if a := model(oc, "gpt-6-astra"); a.Tokens != (Tokens{1000, 120, 5000, 0}) || !a.Priced { + if a := model(oc, "gpt-6-astra"); a.Tokens != (Tokens{1000, 120, 5000, 0, 0}) || !a.Priced { t.Fatalf("astra: %+v", a) } - if l := model(oc, "codex/gpt-6-luna"); l.Tokens != (Tokens{300, 60, 2000, 0}) || l.Priced { + if l := model(oc, "codex/gpt-6-luna"); l.Tokens != (Tokens{300, 60, 2000, 0, 0}) || l.Priced { t.Fatalf("luna: %+v", l) } - if o := model(oc, "claude-opus-5-5"); o.Tokens != (Tokens{40, 60, 700, 300}) { + if o := model(oc, "claude-opus-5-5"); o.Tokens != (Tokens{40, 60, 700, 300, 0}) { t.Fatalf("the subagent's opus: %+v", o) } if !oc.Start.Equal(at("2026-09-26T10:00:00Z")) || !oc.Last.Equal(at("2026-09-26T10:03:00Z")) { @@ -90,7 +90,7 @@ func TestOpenCodeFiles(t *testing.T) { // the work of the main session only, 10:00:00–10:03:00, every pause short s := statsAt(0, statsNow) - if got, sec := usageOn(s, "2026-09-26", "/work/oc"); got != (Tokens{1340, 240, 7700, 300}) || sec != 180 { + if got, sec := usageOn(s, "2026-09-26", "/work/oc"); got != (Tokens{1340, 240, 7700, 300, 0}) || sec != 180 { t.Fatalf("opencode on the 26th: %+v, %ds active", got, sec) } } @@ -179,7 +179,7 @@ func TestOpenCodeDB(t *testing.T) { t.Fatalf("path %s", oc.Path) } s := statsAt(0, statsNow) - if got, sec := usageOn(s, "2026-09-26", "/work/oc"); got != (Tokens{1340, 240, 7700, 300}) || sec != 180 { + if got, sec := usageOn(s, "2026-09-26", "/work/oc"); got != (Tokens{1340, 240, 7700, 300, 0}) || sec != 180 { t.Fatalf("opencode on the 26th: %+v, %ds active", got, sec) } @@ -187,7 +187,7 @@ func TestOpenCodeDB(t *testing.T) { ocInsert(t, db, ocChild, "msg_0005", 1790416990000, 1790417000000, `{"id":"msg_0005","role":"assistant","modelID":"claude-opus-5-5","tokens":{"input":1,"output":2,"reasoning":0,"cache":{"read":3,"write":4}},"time":{"created":1790416990000,"completed":1790417000000}}`) oc = find(t, List(0), "opencode", ocMain) - if o := model(oc, "claude-opus-5-5"); o.Tokens != (Tokens{41, 62, 703, 304}) { + if o := model(oc, "claude-opus-5-5"); o.Tokens != (Tokens{41, 62, 703, 304, 0}) { t.Fatalf("after a new reply: %+v", o) } if !oc.Last.Equal(at("2026-09-26T10:03:20Z")) { @@ -215,10 +215,10 @@ func TestPi(t *testing.T) { } // a tool's model work and a compaction on the model in use, a usage // entry on its own - if o := model(p, "claude-opus-5-5"); o.Tokens != (Tokens{110, 55, 1300, 200}) || !o.Priced { + if o := model(p, "claude-opus-5-5"); o.Tokens != (Tokens{110, 55, 1300, 200, 0}) || !o.Priced { t.Fatalf("opus: %+v", o) } - if o := model(p, "claude/claude-opus-5-5"); o.Tokens != (Tokens{60, 40, 500, 0}) { + if o := model(p, "claude/claude-opus-5-5"); o.Tokens != (Tokens{60, 40, 500, 0, 0}) { t.Fatalf("magpie's opus: %+v", o) } if !p.Start.Equal(at("2026-09-24T08:00:00Z")) || !p.Last.Equal(at("2026-09-24T08:04:00Z")) { @@ -232,7 +232,7 @@ func TestPi(t *testing.T) { // the fork: what it copied counted in the session it came from f := find(t, ss, "pi", piFork) - if f.Tokens != (Tokens{7, 3, 0, 0}) || f.Title != "Refactor the parser" { + if f.Tokens != (Tokens{7, 3, 0, 0, 0}) || f.Title != "Refactor the parser" { t.Fatalf("fork: %+v", f) } if !f.Start.Equal(at("2026-09-25T09:00:00Z")) || !f.Last.Equal(at("2026-09-25T09:00:20Z")) { @@ -240,10 +240,10 @@ func TestPi(t *testing.T) { } s := statsAt(0, statsNow) - if got, sec := usageOn(s, "2026-09-24", "/work/pi"); got != (Tokens{170, 95, 1800, 200}) || sec != 240 { + if got, sec := usageOn(s, "2026-09-24", "/work/pi"); got != (Tokens{170, 95, 1800, 200, 0}) || sec != 240 { t.Fatalf("pi on the 24th: %+v, %ds active", got, sec) } - if got, sec := usageOn(s, "2026-09-25", "/work/pi"); got != (Tokens{7, 3, 0, 0}) || sec != 20 { + if got, sec := usageOn(s, "2026-09-25", "/work/pi"); got != (Tokens{7, 3, 0, 0, 0}) || sec != 20 { t.Fatalf("pi on the 25th: %+v, %ds active", got, sec) } } @@ -350,7 +350,7 @@ func TestOpenCodeV2(t *testing.T) { t.Fatalf("v2 session: %+v", oc) } s := statsAt(0, statsNow) - if got, sec := usageOn(s, "2026-09-26", "/work/oc"); got != (Tokens{1340, 240, 7700, 300}) || sec != 180 { + if got, sec := usageOn(s, "2026-09-26", "/work/oc"); got != (Tokens{1340, 240, 7700, 300, 0}) || sec != 180 { t.Fatalf("opencode 2 on the 26th: %+v, %ds active", got, sec) } @@ -360,7 +360,7 @@ func TestOpenCodeV2(t *testing.T) { t.Fatal(err) } oc = find(t, List(0), "opencode", ocMain) - if l := model(oc, "codex/gpt-6-luna"); l.Tokens != (Tokens{305, 61, 2000, 0}) { + if l := model(oc, "codex/gpt-6-luna"); l.Tokens != (Tokens{305, 61, 2000, 0, 0}) { t.Fatalf("luna after a compaction: %+v", l) } diff --git a/internal/sessions/sessions.go b/internal/sessions/sessions.go index ee038c2a..662c362c 100644 --- a/internal/sessions/sessions.go +++ b/internal/sessions/sessions.go @@ -42,6 +42,9 @@ type Tokens struct { Output int `json:"output"` CacheRead int `json:"cache_read"` CacheWrite int `json:"cache_write"` + // CacheWrite1h is how many of CacheWrite were written to be kept for an + // hour, where the agent says (Claude Code's cache_creation) + CacheWrite1h int `json:"cache_write_1h,omitempty"` } func (t *Tokens) add(u Tokens) { @@ -49,6 +52,7 @@ func (t *Tokens) add(u Tokens) { t.Output += u.Output t.CacheRead += u.CacheRead t.CacheWrite += u.CacheWrite + t.CacheWrite1h += u.CacheWrite1h } func (t *Tokens) sub(u Tokens) { @@ -56,6 +60,24 @@ func (t *Tokens) sub(u Tokens) { t.Output -= u.Output t.CacheRead -= u.CacheRead t.CacheWrite -= u.CacheWrite + t.CacheWrite1h -= u.CacheWrite1h +} + +// ccCacheCreation is Claude Code's cache writes split by how long they are +// kept. +type ccCacheCreation struct { + Ephemeral5m int `json:"ephemeral_5m_input_tokens"` + Ephemeral1h int `json:"ephemeral_1h_input_tokens"` +} + +// ccTokens is a Claude Code message's usage, its 1-hour cache writes apart. +func ccTokens(in, out, read, write int, c *ccCacheCreation) Tokens { + t := Tokens{Input: in, Output: out, CacheRead: read, CacheWrite: write} + if c != nil { + t.CacheWrite = max(t.CacheWrite, c.Ephemeral5m+c.Ephemeral1h) + t.CacheWrite1h = c.Ephemeral1h + } + return t } func (t Tokens) zero() bool { return t == Tokens{} } @@ -507,7 +529,8 @@ func CachePath() string { return filepath.Join(filepath.Dir(catalog.CachePath()) // 11: Codex's input without what it wrote to the cache (#589). // 16: count Codex response records and compaction usage. // 17: reconcile recent Claude message revisions. -const cacheVersion = 17 +// 18: Claude Code's 1-hour cache writes apart from its 5-minute ones. +const cacheVersion = 18 type cacheFile struct { Version int `json:"version"` @@ -927,7 +950,9 @@ func assemble(fs []file, price func(string) *catalog.Price) (Session, bool) { } for _, m := range models { if p := price(m.Model); p != nil { - m.Cost, m.Priced = p.Cost(m.Input, m.Output, m.CacheRead, m.CacheWrite), true + // a sum of the session's calls of the model, at its base price: + // which call went over a long-context tier isn't known here + m.Cost, m.Priced = p.At(0).CostSplit(m.Input, m.Output, m.CacheRead, m.CacheWrite, m.CacheWrite1h), true s.Cost += m.Cost } else { s.Unpriced++ diff --git a/internal/sessions/sessions_test.go b/internal/sessions/sessions_test.go index 021d13d4..c371091a 100644 --- a/internal/sessions/sessions_test.go +++ b/internal/sessions/sessions_test.go @@ -99,10 +99,10 @@ func TestList(t *testing.T) { if cc.ID != "11111111-2222-3333-4444-555555555555" || cc.Cwd != "/work/app" || cc.Title != "Fix the login bug in auth.go" { t.Fatalf("claude: %+v", cc) } - if o := model(cc, "claude-opus-5-5"); o.Tokens != (Tokens{100, 50, 5000, 1000}) || !o.Priced { + if o := model(cc, "claude-opus-5-5"); o.Tokens != (Tokens{100, 50, 5000, 1000, 0}) || !o.Priced { t.Fatalf("opus: %+v", o) } - if h := model(cc, "claude-haiku-4-5-20251001"); h.Tokens != (Tokens{1010, 120, 200, 0}) || h.Priced { + if h := model(cc, "claude-haiku-4-5-20251001"); h.Tokens != (Tokens{1010, 120, 200, 0, 0}) || h.Priced { t.Fatalf("haiku: %+v", h) } if want := (100*4 + 50*20 + 5000*0.2 + 1000*5) / 1e6; !near(cc.Cost, want) || cc.Unpriced != 1 { @@ -117,10 +117,10 @@ func TestList(t *testing.T) { if cx.ID != "01a0bdc9-b5fd-7e63-9658-68bc8dce5ecd" || cx.Cwd != "/work/it's" || cx.Title != "Add a README" { t.Fatalf("codex: %+v", cx) } - if a := model(cx, "gpt-6-astra"); a.Tokens != (Tokens{8000, 400, 10000, 0}) { + if a := model(cx, "gpt-6-astra"); a.Tokens != (Tokens{8000, 400, 10000, 0, 0}) { t.Fatalf("astra: %+v", a) } - if l := model(cx, "gpt-6-luna"); l.Tokens != (Tokens{2000, 200, 10000, 0}) { + if l := model(cx, "gpt-6-luna"); l.Tokens != (Tokens{2000, 200, 10000, 0, 0}) { t.Fatalf("luna: %+v", l) } if cx.Input != 10000 || cx.Output != 600 || cx.CacheRead != 20000 || cx.Unpriced != 1 { diff --git a/internal/sessions/stats.go b/internal/sessions/stats.go index a8192f29..10a9f524 100644 --- a/internal/sessions/stats.go +++ b/internal/sessions/stats.go @@ -236,7 +236,7 @@ func statsAt(days int, now time.Time) Stats { for k, t := range use { u := Usage{Agent: k.agent, Cwd: k.cwd, Model: k.model, Tokens: t} if p := price(k.model); p != nil { - u.Cost, u.Priced = p.Cost(t.Input, t.Output, t.CacheRead, t.CacheWrite), true + u.Cost, u.Priced = p.At(0).CostSplit(t.Input, t.Output, t.CacheRead, t.CacheWrite, t.CacheWrite1h), true } byDate[k.date].Usage = append(byDate[k.date].Usage, u) } @@ -309,7 +309,7 @@ func statsAt(days int, now time.Time) Stats { sum.Tokens.add(t) sum.Models = append(sum.Models, model) if p := price(model); p != nil { - sum.Cost += p.Cost(t.Input, t.Output, t.CacheRead, t.CacheWrite) + sum.Cost += p.At(0).CostSplit(t.Input, t.Output, t.CacheRead, t.CacheWrite, t.CacheWrite1h) } else { sum.Priced = false } diff --git a/internal/sessions/stats_test.go b/internal/sessions/stats_test.go index 2dc995f5..08f14621 100644 --- a/internal/sessions/stats_test.go +++ b/internal/sessions/stats_test.go @@ -56,13 +56,13 @@ func TestStatsByDay(t *testing.T) { // Claude Code on the 20th: the session and its subagent, in its folder; // Codex on the 20th and, picked up again, on the 23rd. The session's own // file was at work 10:00:00–10:05:00, every pause under five minutes. - if got, sec := usageOn(s, "2026-09-20", "/work/app"); got != (Tokens{1110, 170, 5200, 1000}) || sec != 300 { + if got, sec := usageOn(s, "2026-09-20", "/work/app"); got != (Tokens{1110, 170, 5200, 1000, 0}) || sec != 300 { t.Fatalf("claude on the 20th: %+v, %ds active", got, sec) } - if got, _ := usageOn(s, "2026-09-20", "/work/it's"); got != (Tokens{8000, 500, 14000, 0}) { + if got, _ := usageOn(s, "2026-09-20", "/work/it's"); got != (Tokens{8000, 500, 14000, 0, 0}) { t.Fatalf("codex on the 20th: %+v", got) } - if got, _ := usageOn(s, "2026-09-23", "/work/it's"); got != (Tokens{2000, 100, 6000, 0}) { + if got, _ := usageOn(s, "2026-09-23", "/work/it's"); got != (Tokens{2000, 100, 6000, 0, 0}) { t.Fatalf("codex on the 23rd: %+v", got) } // the days add up to the sessions' totals @@ -114,13 +114,13 @@ func TestStatsMidnight(t *testing.T) { t.Fatalf("%s, the 21st: %+v %ds, want %+v %ds", when, got, sec, d21, a21) } } - check("before midnight", Tokens{300, 30, 20, 0}, Tokens{}, 180, 0) + check("before midnight", Tokens{300, 30, 20, 0, 0}, Tokens{}, 180, 0) // read on from where it was left: m2 moves to the 21st, counted once f, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) f.WriteString(rest) f.Close() - after20, after21 := Tokens{100, 10, 10, 0}, Tokens{1400, 145, 30, 0} + after20, after21 := Tokens{100, 10, 10, 0, 0}, Tokens{1400, 145, 30, 0, 0} check("read on", after20, after21, 180, 240) // the same from the start, and from the parse kept on disk @@ -145,7 +145,7 @@ func TestSum(t *testing.T) { t.Fatalf("days %+v", r.Days) } // only Codex's pick-up on the 23rd is in the last 7 days - if r.Tokens != (Tokens{2000, 100, 6000, 0}) || r.DaysUsed != 1 || r.Days[1].Spent() != 2100 { + if r.Tokens != (Tokens{2000, 100, 6000, 0, 0}) || r.DaysUsed != 1 || r.Days[1].Spent() != 2100 { t.Fatalf("%+v", r) } if len(r.Models) != 1 || r.Models[0].Name != "gpt-6-astra" || len(r.Folders) != 1 || r.Folders[0].Name != "/work/it's" { @@ -154,7 +154,7 @@ func TestSum(t *testing.T) { all := statsAt(0, statsNow) app := all.Sum("", "/work/app") - if app.Tokens != (Tokens{1110, 170, 5200, 1000}) || app.Active != 300 || len(app.Folders) != 2 { + if app.Tokens != (Tokens{1110, 170, 5200, 1000, 0}) || app.Active != 300 || len(app.Folders) != 2 { t.Fatalf("app: %+v", app) } opus := all.Sum("claude-opus-5-5", "") @@ -212,7 +212,7 @@ func TestStatsHoursAndSessions(t *testing.T) { } x := find(s) if x == nil || x.Agent != "claude" || x.Cwd != "/work/night" || x.Active != 420 || - x.Tokens != (Tokens{700, 70, 30, 0}) || !x.Priced || x.Cost <= 0 || + x.Tokens != (Tokens{700, 70, 30, 0, 0}) || !x.Priced || x.Cost <= 0 || len(x.Models) != 1 || len(x.Days) != 2 || x.Days[0] != 0 || x.Days[1] != 1 { t.Fatalf("summary %+v", x) } @@ -221,7 +221,7 @@ func TestStatsHoursAndSessions(t *testing.T) { } // a range counts only its own days day := statsAt(1, time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC)) - if x := find(day); x == nil || x.Tokens != (Tokens{400, 40, 10, 0}) || x.Active != 240 || len(x.Days) != 1 || x.Days[0] != 0 { + if x := find(day); x == nil || x.Tokens != (Tokens{400, 40, 10, 0, 0}) || x.Active != 240 || len(x.Days) != 1 || x.Days[0] != 0 { t.Fatalf("the 21st alone: %+v", x) } if len(day.Sessions) != 1 { @@ -229,7 +229,7 @@ func TestStatsHoursAndSessions(t *testing.T) { } // Get reads a session by its key, as Find does past the listed ones - if g, ok := Get(key); !ok || g.Cwd != "/work/night" || g.Tokens != (Tokens{700, 70, 30, 0}) { + if g, ok := Get(key); !ok || g.Cwd != "/work/night" || g.Tokens != (Tokens{700, 70, 30, 0, 0}) { t.Fatalf("get %+v %v", g, ok) } if _, ok := Get("claude:nope"); ok { diff --git a/internal/sessions/traces.go b/internal/sessions/traces.go index 9e1a9e4c..92fdae92 100644 --- a/internal/sessions/traces.go +++ b/internal/sessions/traces.go @@ -588,7 +588,7 @@ func (c *traceCursor) pi(line []byte, bodies bool) []TraceSpan { if json.Unmarshal(line, &usage) == nil { s := c.span(id, o.ID, "model "+usage.Model+" ("+usage.Purpose+")", "generation", o.Timestamp, o.Timestamp) s.Model, s.Provider, s.Inferred = usage.Model, usage.Provider, true - s.Tokens = Tokens{usage.Usage.Input, usage.Usage.Output, usage.Usage.CacheRead, usage.Usage.CacheWrite} + s.Tokens = Tokens{Input: usage.Usage.Input, Output: usage.Usage.Output, CacheRead: usage.Usage.CacheRead, CacheWrite: usage.Usage.CacheWrite} return []TraceSpan{s} } } diff --git a/internal/sessions/traces_claude.go b/internal/sessions/traces_claude.go index 24332ff5..9b18f21d 100644 --- a/internal/sessions/traces_claude.go +++ b/internal/sessions/traces_claude.go @@ -189,7 +189,7 @@ func (c *traceCursor) claude(line []byte, bodies bool) []TraceSpan { } m := c.claudeMessage m.span.End = o.Timestamp - m.span.Tokens = Tokens{o.Message.Usage.Input, o.Message.Usage.Output, o.Message.Usage.CacheRead, o.Message.Usage.CacheWrite} + m.span.Tokens = Tokens{Input: o.Message.Usage.Input, Output: o.Message.Usage.Output, CacheRead: o.Message.Usage.CacheRead, CacheWrite: o.Message.Usage.CacheWrite} m.span.Error = o.IsApiErrorMessage m.stop = o.Message.StopReason if bodies { diff --git a/internal/sessions/traces_opencode.go b/internal/sessions/traces_opencode.go index 8d783980..549b106c 100644 --- a/internal/sessions/traces_opencode.go +++ b/internal/sessions/traces_opencode.go @@ -211,7 +211,7 @@ func traceOpenCode(sid string, messages []ocTraceMessage, bodies bool) []TraceSp s := c.span(t.id, m.ID, "model "+m.ModelID, "generation", ms(m.Time.Created), end) s.Model, s.Provider, s.Error = m.ModelID, m.ProviderID, failed if u := m.Tokens; u != nil { - s.Tokens = Tokens{u.Input, u.Output + u.Reasoning, u.Cache.Read, u.Cache.Write} + s.Tokens = Tokens{Input: u.Input, Output: u.Output + u.Reasoning, CacheRead: u.Cache.Read, CacheWrite: u.Cache.Write} s.Reasoning = u.Reasoning } if bodies { diff --git a/internal/settings/price_tiers_test.go b/internal/settings/price_tiers_test.go new file mode 100644 index 00000000..be714531 --- /dev/null +++ b/internal/settings/price_tiers_test.go @@ -0,0 +1,56 @@ +package settings + +import ( + "encoding/json" + "math" + "strings" + "testing" + + "github.com/yetone/magpie/internal/catalog" +) + +// A price with a long-context tier and a 1-hour cache write is kept and read +// back whole; one written before either reads as before, its 1-hour write +// unset (2× input); a tier not above the one before it, or with a bad part, +// is refused by name. +func TestModelPriceTiers(t *testing.T) { + p := catalog.Price{Input: 10, Output: 50, CacheRead: 1, CacheWrite: 12.5, CacheWrite1h: 20, + Tiers: []catalog.Tier{{Above: 272000, Input: 20, Output: 75, CacheRead: 2, CacheWrite: 25}}} + b, err := json.Marshal(StatedPrice(p)) + if err != nil { + t.Fatal(err) + } + var back ModelPrice + if err := json.Unmarshal(b, &back); err != nil { + t.Fatal(err) + } + if got, bad := back.Price(); bad != "" || !got.Same(p) { + t.Errorf("read back %s as %+v, %q; want %+v", b, got, bad, p) + } + + var old ModelPrice + if err := json.Unmarshal([]byte(`{"input":1,"output":2,"cache_read":0.1,"cache_write":0}`), &old); err != nil { + t.Fatal(err) + } + if got, bad := old.Price(); bad != "" || !got.Same(catalog.Price{Input: 1, Output: 2, CacheRead: 0.1}) || got.OneHour() != 2 { + t.Errorf("an old price = %+v, %q", got, bad) + } + if b, _ := json.Marshal(StatedPrice(catalog.Price{Input: 1, Output: 2})); strings.Contains(string(b), "cache_write_1h") || strings.Contains(string(b), "tiers") { + t.Errorf("a price without either is written %s, as before", b) + } + + for name, m := range map[string]ModelPrice{ + "tier 2": {Tiers: []catalog.Tier{{Above: 272000, Input: 1}, {Above: 272000, Input: 2}}}, + "tier 1": {Tiers: []catalog.Tier{{Above: 0, Input: 1}}}, + "tier 1 ": {Tiers: []catalog.Tier{{Above: 1000, Input: math.NaN()}}}, + "1-hour cache write": {CacheWrite1h: new(-1.0)}, + } { + m.Input, m.Output, m.CacheRead, m.CacheWrite = new(1.0), new(2.0), new(0.0), new(0.0) + if _, bad := m.Price(); bad != strings.TrimSpace(name) { + t.Errorf("%s: bad = %q", name, bad) + } + if err := CheckModelPrice("relay/x", m); err == nil { + t.Errorf("%s: no error", name) + } + } +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 83578a04..cc0f8e62 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -372,6 +372,12 @@ type ModelPrice struct { Output *float64 `json:"output,omitempty"` CacheRead *float64 `json:"cache_read,omitempty"` CacheWrite *float64 `json:"cache_write,omitempty"` + // CacheWrite1h is a 1-hour cache write's price, absent where none is + // given: such a write is then counted at 2× input (catalog.Price). + CacheWrite1h *float64 `json:"cache_write_1h,omitempty"` + // Tiers are the prices of a request whose input is over a size, each + // whole: "magpie model price … --tier 272k …" (PAMI on Discord). + Tiers []catalog.Tier `json:"tiers,omitempty"` } // priceParts are the parts of a price, in the order they are asked for and @@ -391,10 +397,35 @@ func (m ModelPrice) Price() (catalog.Price, string) { return catalog.Price{}, priceParts[i] } } - return catalog.Price{ + bad := func(v float64) bool { return math.IsNaN(v) || math.IsInf(v, 0) || v < 0 } + if m.CacheWrite1h != nil && bad(*m.CacheWrite1h) { + return catalog.Price{}, "1-hour cache write" + } + for i, t := range m.Tiers { + if t.Above <= 0 || i > 0 && t.Above <= m.Tiers[i-1].Above || + bad(t.Input) || bad(t.Output) || bad(t.CacheRead) || bad(t.CacheWrite) || bad(t.CacheWrite1h) { + return catalog.Price{}, fmt.Sprintf("tier %d", i+1) + } + } + p := catalog.Price{ Input: *m.Input, Output: *m.Output, CacheRead: *m.CacheRead, CacheWrite: *m.CacheWrite, - }, "" + Tiers: slices.Clone(m.Tiers), + } + if m.CacheWrite1h != nil { + p.CacheWrite1h = *m.CacheWrite1h + } + return p, "" +} + +// StatedPrice is a price as ModelPrices keeps it: every part given, the +// 1-hour cache write only when it is. +func StatedPrice(p catalog.Price) ModelPrice { + m := ModelPrice{Input: new(p.Input), Output: new(p.Output), CacheRead: new(p.CacheRead), CacheWrite: new(p.CacheWrite), Tiers: slices.Clone(p.Tiers)} + if p.CacheWrite1h > 0 { + m.CacheWrite1h = new(p.CacheWrite1h) + } + return m } // CheckModelPrice is whether a price the user gives is one magpie will bill a @@ -414,7 +445,9 @@ func CheckModelPrice(key string, m ModelPrice) error { } else if err := CheckModelKey("price", key); err != nil { return err } - if _, bad := m.Price(); bad != "" { + if _, bad := m.Price(); strings.HasPrefix(bad, "tier ") { + return fmt.Errorf("the price of %q has a %s that is not over a size above 0 and above the tier before it, with every part finite and not negative", key, bad) + } else if bad != "" { return fmt.Errorf("the price of %q needs %s %s price that is present, finite and not negative", key, anArticle(bad), bad) } return nil diff --git a/internal/usage/ledger.go b/internal/usage/ledger.go index c3c208ab..40bc6b24 100644 --- a/internal/usage/ledger.go +++ b/internal/usage/ledger.go @@ -233,7 +233,7 @@ const UnknownProvider = "session-unknown" // model it was sent to, never the one the response named) (#680). func logRecord(c sessions.Call) Record { r := Record{Time: c.Time, Agent: c.Agent, Provider: UnknownProvider, Model: c.Model, Served: c.Model, Requested: c.Requested, - Input: c.Input, Output: c.Output, CacheRead: c.CacheRead, CacheWrite: c.CacheWrite, Reasoning: c.Reasoning, Effort: c.Effort, + Input: c.Input, Output: c.Output, CacheRead: c.CacheRead, CacheWrite: c.CacheWrite, CacheWrite1h: c.CacheWrite1h, Reasoning: c.Reasoning, Effort: c.Effort, Millis: c.Millis, TTFT: c.TTFT, Session: c.Session, RequestID: c.RequestID, Error: c.ErrorText, ErrType: c.Error} if c.Agent == "codex" || c.Agent == "opencode" { r.Requested, r.Served = c.Model, "" @@ -302,7 +302,7 @@ func ledgerWithShared(since time.Time, f Filter, recs []Record, logs []sessions. } row := Row{Record: r, Swapped: r.Served != "" && Swapped(sent, r.Served), Routed: GroupRouted(sent, r.Served), Source: source} if pr != nil && r.Input+r.Output > 0 { - row.Cost, row.Priced = pr.Cost(r.Input, r.Output, r.CacheRead, r.CacheWrite), true + row.Cost, row.Priced = r.CostAt(*pr), true } row.Agent = AgentOf(r.Agent) rows = append(rows, row) @@ -390,9 +390,12 @@ func NewPricer() func([]Record) Totals { var CSVHeader = []string{"time", "agent", "requested_model", "provider", "host", "model", "served_model", "swapped", "effort", "input_tokens", "output_tokens", "cache_write_tokens", "cache_read_tokens", "reasoning_tokens", "cost_usd", "duration_ms", "ttft_ms", "status", "error", "session", "kind", "provider_key_id", "provider_key_name", "provider_account", "route_id", - "request_id", "endpoint", "error_message", "error_type", "source", "rejected", "session_provider", "session_account", "session_official_login", "caller_key_id", "caller_key_name", "response_id", "upstream"} + "request_id", "endpoint", "error_message", "error_type", "source", "rejected", "session_provider", "session_account", "session_official_login", "caller_key_id", "caller_key_name", "response_id", "upstream", + "cache_write_5m_tokens", "cache_write_1h_tokens"} -// WriteCSV writes rows as CSV, a header first: times in RFC 3339 with +// WriteCSV writes rows as CSV, a header first (cache_write_tokens is every +// cache write, as it always was, and the last two columns split it into +// 5-minute and 1-hour writes): times in RFC 3339 with // their offset, the cost in USD at the effective price (empty when unknown), error // "true" for a call that failed: answered with a status of 400 or more, or // ended by an error a session file tells. @@ -416,7 +419,8 @@ func WriteCSV(w io.Writer, rows []Row) error { cw.Write([]string{r.Time.Format(time.RFC3339), r.Agent, r.Requested, r.Provider, r.Host, r.Model, r.Served, strconv.FormatBool(r.Swapped), r.Effort, n(r.Input), n(r.Output), n(r.CacheWrite), n(r.CacheRead), n(r.Reasoning), cost, strconv.FormatInt(r.Millis, 10), ttft, n(r.Status), strconv.FormatBool(r.Failed()), r.Session, r.Kind, r.ProviderKeyID, r.ProviderKeyName, r.Account(), routeID, - r.RequestID, r.Endpoint, r.Error, r.ErrType, r.Source, strconv.FormatBool(r.IsRejected()), r.SessionProvider, r.SessionAccount, strconv.FormatBool(r.SessionOfficialLogin), r.CallerKeyID, r.CallerKeyName, r.ResponseID, r.Upstream}) + r.RequestID, r.Endpoint, r.Error, r.ErrType, r.Source, strconv.FormatBool(r.IsRejected()), r.SessionProvider, r.SessionAccount, strconv.FormatBool(r.SessionOfficialLogin), r.CallerKeyID, r.CallerKeyName, r.ResponseID, r.Upstream, + n(r.CacheWrite - min(r.CacheWrite1h, r.CacheWrite)), n(min(r.CacheWrite1h, r.CacheWrite))}) } cw.Flush() return cw.Error() @@ -506,6 +510,7 @@ func (t *Totals) addRow(r Row) { t.Output += r.Output t.CacheRead += r.CacheRead t.CacheWrite += r.CacheWrite + t.CacheWrite1h += r.CacheWrite1h t.Reasoning += r.Reasoning switch { case r.Priced: diff --git a/internal/usage/ledger_test.go b/internal/usage/ledger_test.go index bd345df3..2c5417be 100644 --- a/internal/usage/ledger_test.go +++ b/internal/usage/ledger_test.go @@ -90,8 +90,8 @@ func TestLedger(t *testing.T) { t.Fatal(err) } want := strings.Join(CSVHeader, ",") + "\n" + - rows[1].Time.Format(time.RFC3339) + ",codex,relay/sol,relay,relay.example,sol,sol-2026-01-01,false,,10,1,0,0,0,0.000028,100,,200,false,,,,,,,,,,,,false,,,false,,,,\n" + - rows[2].Time.Format(time.RFC3339) + ",codex,fast,relay,relay.example,sol,luna,true,high,2000,500,1000,4000,0,0.012500,3200,400,200,false,s1,,,,,123,,,,,,false,,,false,,,,\n" + rows[1].Time.Format(time.RFC3339) + ",codex,relay/sol,relay,relay.example,sol,sol-2026-01-01,false,,10,1,0,0,0,0.000028,100,,200,false,,,,,,,,,,,,false,,,false,,,,,0,0\n" + + rows[2].Time.Format(time.RFC3339) + ",codex,fast,relay,relay.example,sol,luna,true,high,2000,500,1000,4000,0,0.012500,3200,400,200,false,s1,,,,,123,,,,,,false,,,false,,,,,1000,0\n" if b.String() != want { t.Fatalf("csv:\n%s\nwant:\n%s", b.String(), want) } @@ -260,7 +260,7 @@ func TestLedgerWithSessionLogCalls(t *testing.T) { if err := WriteCSV(&b, rows[2:4]); err != nil { t.Fatal(err) } - for _, want := range []string{",req_log,,,,log,false,,,false,,,,\n", ",true,s2,,,,,,req_lim,,You've hit your limit,rate_limit,log,false,,,false,,,,\n"} { + for _, want := range []string{",req_log,,,,log,false,,,false,,,,,500,0\n", ",true,s2,,,,,,req_lim,,You've hit your limit,rate_limit,log,false,,,false,,,,,0,0\n"} { if !strings.Contains(b.String(), want) { t.Fatalf("csv lacks %q:\n%s", want, b.String()) } diff --git a/internal/usage/otel_sessions.go b/internal/usage/otel_sessions.go index 5c9feabc..a951d3e8 100644 --- a/internal/usage/otel_sessions.go +++ b/internal/usage/otel_sessions.go @@ -91,7 +91,7 @@ func sessionRecord(s sessions.TraceSpan, config settings.OTel) Record { } typ := s.Kind return Record{Agent: s.Agent, Model: s.Model, Served: s.Served, Provider: s.Provider, Time: s.Start, Millis: s.End.Sub(s.Start).Milliseconds(), Status: status, - Input: s.Tokens.Input, Output: s.Tokens.Output, CacheRead: s.Tokens.CacheRead, CacheWrite: s.Tokens.CacheWrite, Reasoning: s.Reasoning, + Input: s.Tokens.Input, Output: s.Tokens.Output, CacheRead: s.Tokens.CacheRead, CacheWrite: s.Tokens.CacheWrite, CacheWrite1h: s.Tokens.CacheWrite1h, Reasoning: s.Reasoning, BodyIn: sessionBody(s.Input, config), BodyOut: sessionBody(s.Output, config), OTel: &OTelSpan{TraceID: sessions.TraceID(s.Agent, s.Session, s.Turn), SpanID: s.ID, ParentID: s.Parent, Root: s.Parent == "", End: s.End, Name: s.Name, Type: typ, SessionID: s.Session, TraceName: s.Agent + " interaction", Session: true, Inferred: s.Inferred, Update: s.Update}} diff --git a/internal/usage/otel_wire.go b/internal/usage/otel_wire.go index 68beb67a..9bd0966a 100644 --- a/internal/usage/otel_wire.go +++ b/internal/usage/otel_wire.go @@ -171,10 +171,12 @@ func (e *otelExporter) traces(records []Record) any { generation := r.OTel == nil || !r.OTel.Root && (!r.OTel.Session || r.OTel.Type == "generation") if generation && !r.IsRejected() { if pr := priceOf(r); pr != nil { + // the call's tier, by its whole input, for both halves + f := pr.At(r.Input + r.CacheRead + r.CacheWrite) cost, err := json.Marshal(map[string]float64{ - "input": pr.Cost(r.Input, 0, r.CacheRead, r.CacheWrite), - "output": pr.Cost(0, r.Output, 0, 0), - "total": pr.Cost(r.Input, r.Output, r.CacheRead, r.CacheWrite), + "input": f.CostSplit(r.Input, 0, r.CacheRead, r.CacheWrite, r.CacheWrite1h), + "output": f.Cost(0, r.Output, 0, 0), + "total": r.CostAt(*pr), }) if err == nil { a = append(a, otelString("langfuse.observation.cost_details", string(cost))) diff --git a/internal/usage/request_page.go b/internal/usage/request_page.go index a61431ac..be2e1cfe 100644 --- a/internal/usage/request_page.go +++ b/internal/usage/request_page.go @@ -52,7 +52,7 @@ type RequestPage struct { type packedRow struct { Time time.Time Text [28]uint32 - Tokens [5]int64 + Tokens [6]int64 Millis, TTFT, FirstText, Order int64 Sent int64 RouteID int64 @@ -96,7 +96,7 @@ func (c *rowChunk) add(r Row, msg string, order int64, failed bool) { c.Bytes += int64(len(s) + 48) return id } - p := packedRow{Time: r.Time, Tokens: [5]int64{int64(r.Input), int64(r.Output), int64(r.CacheRead), int64(r.CacheWrite), int64(r.Reasoning)}, Millis: r.Millis, TTFT: r.TTFT, FirstText: r.FirstText, Sent: r.Sent, Order: order, RouteID: r.RouteID, Cost: r.Cost, Status: int32(r.Status)} + p := packedRow{Time: r.Time, Tokens: [6]int64{int64(r.Input), int64(r.Output), int64(r.CacheRead), int64(r.CacheWrite), int64(r.Reasoning), int64(r.CacheWrite1h)}, Millis: r.Millis, TTFT: r.TTFT, FirstText: r.FirstText, Sent: r.Sent, Order: order, RouteID: r.RouteID, Cost: r.Cost, Status: int32(r.Status)} for i, s := range rowText(&r) { p.Text[i] = intern(*s) } @@ -128,7 +128,7 @@ func (c *rowChunk) add(r Row, msg string, order int64, failed bool) { } func (c *rowChunk) row(i int) Row { p := &c.Rows[i] - r := Row{Record: Record{RouteID: p.RouteID, Time: p.Time, Input: int(p.Tokens[0]), Output: int(p.Tokens[1]), CacheRead: int(p.Tokens[2]), CacheWrite: int(p.Tokens[3]), Reasoning: int(p.Tokens[4]), Millis: p.Millis, TTFT: p.TTFT, FirstText: p.FirstText, Sent: p.Sent, Status: int(p.Status), Rejected: p.Flags&4 != 0, SessionOfficialLogin: p.Flags&8 != 0}, Cost: p.Cost, Priced: p.Flags&1 != 0, Swapped: p.Flags&2 != 0, Routed: p.Flags&32 != 0} + r := Row{Record: Record{RouteID: p.RouteID, Time: p.Time, Input: int(p.Tokens[0]), Output: int(p.Tokens[1]), CacheRead: int(p.Tokens[2]), CacheWrite: int(p.Tokens[3]), CacheWrite1h: int(p.Tokens[5]), Reasoning: int(p.Tokens[4]), Millis: p.Millis, TTFT: p.TTFT, FirstText: p.FirstText, Sent: p.Sent, Status: int(p.Status), Rejected: p.Flags&4 != 0, SessionOfficialLogin: p.Flags&8 != 0}, Cost: p.Cost, Priced: p.Flags&1 != 0, Swapped: p.Flags&2 != 0, Routed: p.Flags&32 != 0} for i, s := range rowText(&r) { *s = c.Strings[p.Text[i]] } @@ -296,7 +296,7 @@ func queryPage(p Period, f Filter, offset, limit int, readSource func(sessions.C row := Row{Record: r, Source: source, Swapped: r.Served != "" && Swapped(sent, r.Served), Routed: GroupRouted(sent, r.Served)} if pr := price(r); pr != nil && r.Input+r.Output > 0 { row.Priced = true - row.Cost = pr.Cost(r.Input, r.Output, r.CacheRead, r.CacheWrite) + row.Cost = r.CostAt(*pr) } row.Agent = AgentOf(r.Agent) return row @@ -561,7 +561,7 @@ type matchKey struct { // Codex that names cache_write_input_tokens beside a gateway record that // didn't, an older Codex that doesn't beside one that does; the sum is the // same either way. -func matchTokens(t [5]int64) [3]int64 { return [3]int64{t[0] + t[3], t[1], t[2]} } +func matchTokens(t [6]int64) [3]int64 { return [3]int64{t[0] + t[3], t[1], t[2]} } type matchEnd struct { at time.Time diff --git a/internal/usage/usage.go b/internal/usage/usage.go index 020c7619..35c776a0 100644 --- a/internal/usage/usage.go +++ b/internal/usage/usage.go @@ -60,7 +60,13 @@ type Record struct { Output int `json:"out"` CacheRead int `json:"cache_read,omitempty"` CacheWrite int `json:"cache_write,omitempty"` - Reasoning int `json:"reasoning,omitempty"` + // CacheWrite1h is how many of CacheWrite were written to be kept for + // an hour (Anthropic's 1-hour TTL, billed at 2× input where 5 minutes' + // is 1.25×); the rest were for 5 minutes. A record from before it was + // kept has none: all its writes are counted as 5-minute ones, as they + // were. + CacheWrite1h int `json:"cache_write_1h,omitempty"` + Reasoning int `json:"reasoning,omitempty"` // Effort is the reasoning the model was asked for — a routing group's // pick for the turn, or the agent's own — as it takes it; "" for none Effort string `json:"effort,omitempty"` @@ -245,17 +251,25 @@ const ( // Periods lists the windows in order. var Periods = []Period{Today, Week, Month, All} +// CostAt is what the call costs at a price: at the tier its input reaches, +// its 1-hour cache writes at theirs. +func (r Record) CostAt(p catalog.Price) float64 { + return p.CostSplit(r.Input, r.Output, r.CacheRead, r.CacheWrite, r.CacheWrite1h) +} + // Totals is a sum of calls. type Totals struct { - Calls int `json:"calls"` - Errors int `json:"errors"` - Input int `json:"input"` - Output int `json:"output"` - CacheRead int `json:"cache_read"` - CacheWrite int `json:"cache_write"` - Reasoning int `json:"reasoning"` - Cost float64 `json:"cost"` // USD at the effective price, for the priced calls - Unpriced int `json:"unpriced"` // calls with tokens but no known price + Calls int `json:"calls"` + Errors int `json:"errors"` + Input int `json:"input"` + Output int `json:"output"` + CacheRead int `json:"cache_read"` + CacheWrite int `json:"cache_write"` + // CacheWrite1h is how many of CacheWrite were 1-hour writes + CacheWrite1h int `json:"cache_write_1h,omitempty"` + Reasoning int `json:"reasoning"` + Cost float64 `json:"cost"` // USD at the effective price, for the priced calls + Unpriced int `json:"unpriced"` // calls with tokens but no known price // Timed: the answered calls whose first token was timed (streamed), // TTFT the sum of their ttft_ms; DecodeMs the time from it to the end // of those that wrote any, over which DecodeOut tokens came: their @@ -346,6 +360,7 @@ func (t *Totals) add(r Record, price *catalog.Price) { t.Output += r.Output t.CacheRead += r.CacheRead t.CacheWrite += r.CacheWrite + t.CacheWrite1h += r.CacheWrite1h t.Reasoning += r.Reasoning if r.TTFT > 0 && !r.Failed() { t.Timed++ @@ -362,7 +377,7 @@ func (t *Totals) add(r Record, price *catalog.Price) { t.Unpriced++ return } - t.Cost += price.Cost(r.Input, r.Output, r.CacheRead, r.CacheWrite) + t.Cost += r.CostAt(*price) } // Group is the share of one agent or model. diff --git a/model_cli.go b/model_cli.go index 234836dc..f5a465cf 100644 --- a/model_cli.go +++ b/model_cli.go @@ -26,7 +26,11 @@ const modelUsage = `usage: magpie model price ,,, say what it costs, in USD per million tokens, all four parts as 0.12,1.20,0.01,0.15; 0 is a model served for nothing, which is - a price, not the absence of one + a price, not the absence of one; a fifth part is a 1-hour cache + write's (Anthropic's 2× input, what it is counted at if not given) + magpie model price ,,, --tier 272k ,,, + and what a request whose input, cached tokens included, is over + 272K costs, the whole request; --tier may be given again magpie model price --reset take your price off this model magpie model price ,,, what the model costs from any provider you have not priced it @@ -287,7 +291,9 @@ func modelPrice(args []string) error { return nil } fmt.Println(bold.Render(perMillion(pr)), muted.Render("· "+id)) - fmt.Println(faint.Render(" · cache read " + money(pr.CacheRead) + ", cache write " + money(pr.CacheWrite))) + for _, l := range priceDetail(pr) { + fmt.Println(faint.Render(l)) + } switch priceFrom(settings.Load(), p.ID, model) { case "model": fmt.Println(faint.Render(" · what you said this model costs · --reset takes that away")) @@ -313,26 +319,98 @@ func modelPrice(args []string) error { return err } fmt.Println(green.Render("✓"), id, muted.Render("costs"), bold.Render(perMillion(pr))) + for _, t := range pr.Tiers { + fmt.Println(faint.Render(fmt.Sprintf(" · and $%.4g/$%.4g in/out the whole request over %s input tokens", t.Input, t.Output, tokenSize(t.Above)))) + } return nil } -// parsePrice is the four parts of a price as typed, in USD per million -// tokens. +// parsePrice is a price as typed, in USD per million tokens: its four +// parts, a 1-hour cache write's after them if given, and then each +// "--tier " with the parts of what a request whose input is over that +// size costs. func parsePrice(rest []string) (catalog.Price, error) { - var nums []float64 - for _, a := range rest { - for _, f := range strings.FieldsFunc(a, func(r rune) bool { return r == ',' || r == ' ' || r == '/' }) { - v, err := strconv.ParseFloat(f, 64) - if err != nil { - return catalog.Price{}, fmt.Errorf("a price is numbers in USD per million tokens, like 0.12,1.20,0.01,0.15, not %q", f) + var groups [][]string + var sizes []string + cur := []string{} + for i := 0; i < len(rest); i++ { + a := rest[i] + size, isTier := strings.CutPrefix(a, "--tier=") + if !isTier && a == "--tier" { + if i+1 >= len(rest) { + return catalog.Price{}, fmt.Errorf("--tier takes the input size it starts over, like --tier 272k 20,75,2,25") } - nums = append(nums, v) + isTier, size = true, rest[i+1] + i++ } + if isTier { + groups, sizes, cur = append(groups, cur), append(sizes, size), []string{} + continue + } + cur = append(cur, a) } - if len(nums) != 4 { - return catalog.Price{}, fmt.Errorf("give all four parts, input,output,cache read,cache write — %d given", len(nums)) + groups = append(groups, cur) + var out catalog.Price + for g, args := range groups { + var nums []float64 + for _, a := range args { + for _, f := range strings.FieldsFunc(a, func(r rune) bool { return r == ',' || r == ' ' || r == '/' }) { + v, err := strconv.ParseFloat(f, 64) + if err != nil { + return catalog.Price{}, fmt.Errorf("a price is numbers in USD per million tokens, like 0.12,1.20,0.01,0.15, not %q", f) + } + nums = append(nums, v) + } + } + if len(nums) != 4 && len(nums) != 5 { + what := "the price" + if g > 0 { + what = "the tier over " + sizes[g-1] + } + return catalog.Price{}, fmt.Errorf("give all four parts of %s, input,output,cache read,cache write (and a 1-hour cache write after them if you like) — %d given", what, len(nums)) + } + var oneHour float64 + if len(nums) == 5 { + oneHour = nums[4] + } + if g == 0 { + out = catalog.Price{Input: nums[0], Output: nums[1], CacheRead: nums[2], CacheWrite: nums[3], CacheWrite1h: oneHour} + continue + } + above, err := provider.ParseTokens(sizes[g-1]) + if err != nil || above <= 0 { + return catalog.Price{}, fmt.Errorf("--tier takes the input size it starts over, like 272k or 200000, not %q", sizes[g-1]) + } + out.Tiers = append(out.Tiers, catalog.Tier{Above: above, Input: nums[0], Output: nums[1], CacheRead: nums[2], CacheWrite: nums[3], CacheWrite1h: oneHour}) } - return catalog.Price{Input: nums[0], Output: nums[1], CacheRead: nums[2], CacheWrite: nums[3]}, nil + slices.SortFunc(out.Tiers, func(a, b catalog.Tier) int { return a.Above - b.Above }) + return out, nil +} + +// priceDetail is the parts of a price after the two perMillion gives: its +// cache's, and what a request over each of its sizes costs. +func priceDetail(p catalog.Price) []string { + cache := " · cache read " + money(p.CacheRead) + ", cache write " + money(p.CacheWrite) + " (5 min), " + money(p.OneHour()) + " (1 hour)" + if p.CacheWrite1h <= 0 { + cache += " at 2× input" + } + lines := []string{cache} + for _, t := range p.Tiers { + lines = append(lines, " · over "+tokenSize(t.Above)+" input tokens, the whole request: "+ + fmt.Sprintf("$%.4g/$%.4g in/out", t.Input, t.Output)+", cache "+money(t.CacheRead)+"/"+money(t.CacheWrite)) + } + return lines +} + +// tokenSize is a number of tokens as --tier takes it: 272K, 1M. +func tokenSize(n int) string { + switch { + case n >= 1e6 && n%1e6 == 0: + return strconv.Itoa(n/1e6) + "M" + case n >= 1e3 && n%1e3 == 0: + return strconv.Itoa(n/1e3) + "K" + } + return strconv.Itoa(n) } // anyModelPrice shows or sets what a model costs from any provider (*/model): @@ -347,7 +425,9 @@ func anyModelPrice(ref string, rest []string) error { if len(rest) == 0 { if pr, ok := statedPrice(settings.Load().ModelPrices, key); ok { fmt.Println(bold.Render(perMillion(pr)), muted.Render("· "+key)) - fmt.Println(faint.Render(" · cache read " + money(pr.CacheRead) + ", cache write " + money(pr.CacheWrite))) + for _, l := range priceDetail(pr) { + fmt.Println(faint.Render(l)) + } fmt.Println(faint.Render(" · what you said this model costs from any provider · --reset takes that away")) return nil } @@ -480,6 +560,9 @@ func modelPrices() error { } fmt.Println(bold.Render(id), muted.Render("· "+perMillion(p)+ " · cache "+money(p.CacheRead)+"/"+money(p.CacheWrite))) + for _, t := range p.Tiers { + fmt.Println(faint.Render(fmt.Sprintf(" · over %s input tokens $%.4g/$%.4g in/out · cache %s/%s", tokenSize(t.Above), t.Input, t.Output, money(t.CacheRead), money(t.CacheWrite)))) + } } return nil } diff --git a/model_price_tier_cli_test.go b/model_price_tier_cli_test.go new file mode 100644 index 00000000..314bf115 --- /dev/null +++ b/model_price_tier_cli_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "strings" + "testing" + + "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/provider" +) + +// PAMI on Discord: magpie model price m 10,50,1,12.5 --tier 272k 20,75,2,25 +// gives what the whole request costs over 272K input, as OpenAI bills +// gpt-6-astra, and a fifth part is the 1-hour cache write. Both are kept, +// counted and shown; a size that isn't one, or a tier short of a part, is +// refused. +func TestModelPriceTierCmd(t *testing.T) { + groupsHome(t) + if err := modelCmd([]string{"price", "a/m", "10,50,1,12.5", "--tier", "272k", "20,75,2,25", "--tier=1M", "40", "100", "4", "50"}); err != nil { + t.Fatal(err) + } + pr, ok := provider.EffectivePrice("a", "m") + want := catalog.Price{Input: 10, Output: 50, CacheRead: 1, CacheWrite: 12.5, Tiers: []catalog.Tier{ + {Above: 272000, Input: 20, Output: 75, CacheRead: 2, CacheWrite: 25}, + {Above: 1000000, Input: 40, Output: 100, CacheRead: 4, CacheWrite: 50}}} + if !ok || !pr.Same(want) { + t.Fatalf("price = %+v, want %+v", pr, want) + } + out, err := said(t, func() error { return modelCmd([]string{"price", "a/m"}) }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "over 272K input tokens") || !strings.Contains(out, "over 1M input tokens") || !strings.Contains(out, "$20/$75") { + t.Errorf("magpie model price a/m: %q; want its tiers", out) + } + out, _ = said(t, func() error { return modelCmd([]string{"prices"}) }) + if !strings.Contains(out, "over 272K") { + t.Errorf("magpie model prices: %q; want its tier", out) + } + + if err := modelCmd([]string{"price", "a/only-a", "5,25,0.5,6.25,8"}); err != nil { + t.Fatal(err) + } + if pr, _ := provider.EffectivePrice("a", "only-a"); pr.CacheWrite1h != 8 || pr.OneHour() != 8 { + t.Fatalf("1h = %+v", pr) + } + out, _ = said(t, func() error { return modelCmd([]string{"price", "a/only-a"}) }) + if !strings.Contains(out, "(1 hour)") { + t.Errorf("magpie model price a/only-a: %q; want its 1-hour cache write", out) + } + + for _, args := range [][]string{ + {"10,50,1,12.5", "--tier", "lots", "20,75,2,25"}, + {"10,50,1,12.5", "--tier", "272k", "20,75"}, + {"10,50,1,12.5", "--tier"}, + {"10,50,1,12.5", "--tier", "272k", "20,75,2,25", "--tier", "272k", "20,75,2,25"}, + } { + if err := modelCmd(append([]string{"price", "a/m"}, args...)); err == nil { + t.Errorf("%q was taken", args) + } + } + if pr, _ := provider.EffectivePrice("a", "m"); !pr.Same(want) { + t.Errorf("a refused price was written: %+v", pr) + } +}