From 71d2da1f426bfad02b3c18362bcc4a2124552e1b Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 5 Oct 2026 21:56:56 +0800 Subject: [PATCH] catalog, usage, gateway, gui: a price can have a long-context tier and a 1-hour cache write, and the price boxes take decimals (PAMI on Discord) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAI bills gpt-6-astra, gpt-6.1-sol and gpt-6-luna over 272K input at 2× input and cache and 1.5× output, for the whole request. A price now has tiers: `magpie model price 10,50,1,12.5 --tier 272k 20,75,2,25` (--tier can repeat). A tier applies when input + cache reads + cache writes is over its size, so 272,000 is not over and 272,001 is. The built-in prices take tiers only where models.dev lists one (cost.tiers of type "context"), and it does list one for those three. The ledger, each request and OTLP are costed at the tier the call reached. Session and day totals sum many calls, so they stay at the base price. Anthropic bills a cache write kept for an hour at 2× input and one kept for 5 minutes at 1.25×. Its usage reports which is which in cache_creation.ephemeral_5m/1h_input_tokens. The gateway (API and Claude subscription) and Claude Code's transcripts now keep that split. A price's fifth part is the 1-hour write. When it isn't given, it is 2× input: that is Anthropic's rule, and only Anthropic reports 1-hour writes. Claude list prices carry the 2× too. cache_write_tokens is still the total, and the usage CSV adds cache_write_5m_tokens and cache_write_1h_tokens at the end. The app shows the split on a request's Cache and on the usage tooltips. Old prices and old records, which have no 1-hour part, read as before. The sessions cache (18) and the call cache (calls-v8) are re-read once. In the GUI, the price boxes were number inputs and dropped what they didn't parse ("0,25"). They are now decimal text boxes that take both "0.25" and "0,25". A model's price row has a 1-hour box (empty means 2× input) and a quiet Long-context price row. That row shows the list's tier greyed in, or sits behind a link when the list has none. A bad size is refused by name. en/zh/ja/de. Tests: - New: TestPriceTiers, TestCostSplit, TestPriceJSON, TestModelPriceTiers, TestModelPriceTierCmd, TestAnthropicCacheWriteSplit and TestClaudeCacheWriteSplit. The ledger CSV test expects the new columns. - model-price-tiers.test.cjs is new; model-price-edit and provider-price-rate are updated. All of them fail against the old assets and pass in WebKit. Chromium couldn't be launched here. - go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass. --- docs/reference.md | 37 ++++- internal/agent/route.go | 2 +- internal/catalog/catalog.go | 143 +++++++++++++++- internal/catalog/price_tiers_test.go | 103 ++++++++++++ internal/gateway/anthropic.go | 24 ++- internal/gateway/anthropic_cache_ttl_test.go | 52 ++++++ internal/gateway/claude_subscription.go | 18 ++- internal/gateway/gateway.go | 6 +- internal/gateway/ir.go | 10 +- internal/gui/assets/app.css | 3 + internal/gui/assets/app.js | 153 ++++++++++++++---- internal/gui/assets/i18n.js | 24 +++ internal/gui/tests/model-price-edit.test.cjs | 28 ++-- internal/gui/tests/model-price-tiers.test.cjs | 130 +++++++++++++++ .../gui/tests/provider-price-rate.test.cjs | 3 +- internal/provider/clinepass_test.go | 4 +- internal/provider/kilo_test.go | 2 +- internal/provider/modelprefs.go | 5 +- internal/provider/modelprefs_price_test.go | 2 +- internal/provider/price_override_test.go | 4 +- internal/sessions/agents_test.go | 24 +-- internal/sessions/call_cache.go | 3 +- internal/sessions/calls.go | 11 +- internal/sessions/calls_opencode_test.go | 8 +- internal/sessions/calls_test.go | 24 +-- internal/sessions/claude.go | 22 +-- internal/sessions/claude_cache_ttl_test.go | 33 ++++ .../sessions/claude_native_review_test.go | 4 +- internal/sessions/claude_usage_test.go | 18 +-- internal/sessions/codex_native_review_test.go | 4 +- internal/sessions/codex_runtime_reset_test.go | 4 +- internal/sessions/codex_snapshot_test.go | 2 +- internal/sessions/codex_usage_test.go | 10 +- internal/sessions/droid_test.go | 6 +- internal/sessions/grok_workbuddy_test.go | 10 +- internal/sessions/hermes_test.go | 10 +- .../sessions/hermes_usage_residual_test.go | 10 +- internal/sessions/omp_test.go | 10 +- internal/sessions/opencode_pi_test.go | 26 +-- internal/sessions/sessions.go | 29 +++- internal/sessions/sessions_test.go | 8 +- internal/sessions/stats.go | 4 +- internal/sessions/stats_test.go | 20 +-- internal/sessions/traces.go | 2 +- internal/sessions/traces_claude.go | 2 +- internal/sessions/traces_opencode.go | 2 +- internal/settings/price_tiers_test.go | 56 +++++++ internal/settings/settings.go | 39 ++++- internal/usage/ledger.go | 15 +- internal/usage/ledger_test.go | 6 +- internal/usage/otel_sessions.go | 2 +- internal/usage/otel_wire.go | 8 +- internal/usage/request_page.go | 10 +- internal/usage/usage.go | 37 +++-- model_cli.go | 113 +++++++++++-- model_price_tier_cli_test.go | 64 ++++++++ 56 files changed, 1167 insertions(+), 242 deletions(-) create mode 100644 internal/catalog/price_tiers_test.go create mode 100644 internal/gateway/anthropic_cache_ttl_test.go create mode 100644 internal/gui/tests/model-price-tiers.test.cjs create mode 100644 internal/sessions/claude_cache_ttl_test.go create mode 100644 internal/settings/price_tiers_test.go create mode 100644 model_price_tier_cli_test.go 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) + } +}