diff --git a/docs/reference.md b/docs/reference.md index 75425eb0..3a6f6145 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -456,8 +456,10 @@ 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 +given, is 2× input for a Claude model — Anthropic's rule. Any other model has +no 1-hour price unless you give one: a 1-hour write it reports is counted at +the 5-minute price, and neither `magpie model price` nor the app's boxes show +a 1-hour price for it. A call recorded before magpie kept the split counts all its writes at the 5-minute price, as it did. diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 8c09fb53..0715a638 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -105,8 +105,8 @@ type Price struct { // 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. + // counted at the 5-minute price, as no other vendor has an hour's + // writes to bill apart; a Claude model's is 2× input (OneHourFor). 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× @@ -177,10 +177,13 @@ func (p *Price) UnmarshalJSON(b []byte) error { 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) { +// OneHourFor gives a Claude model's price its 1-hour cache write where none +// is given — models.dev lists none, and a price the user gives may leave it +// out: 2× input, as Anthropic bills it for every model, wherever the model +// has a (5-minute) cache write price at all. Any other model's stays +// unset, its writes all at the 5-minute price: only Anthropic has a 1-hour +// one (PAMI on Discord: gpt-6-astra was shown a $20 one that isn't). +func OneHourFor(id string, p *Price) { if p == nil || p.CacheWrite <= 0 || p.CacheWrite1h > 0 || !strings.Contains(strings.ToLower(id), "claude") { return } @@ -222,12 +225,12 @@ func (p Price) At(prompt int) Price { } // OneHour is what a 1-hour cache write costs at this flat price: the price -// given, else 2× input, Anthropic's rule. +// given, else the 5-minute one (a Claude model's is given, OneHourFor). func (p Price) OneHour() float64 { if p.CacheWrite1h > 0 { return p.CacheWrite1h } - return 2 * p.Input + return p.CacheWrite } // Cost of a call at this price, every cache write a 5-minute one. @@ -370,7 +373,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) + OneHourFor(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 index 49a8aad6..d845f26f 100644 --- a/internal/catalog/price_tiers_test.go +++ b/internal/catalog/price_tiers_test.go @@ -56,12 +56,24 @@ func TestPriceTiers(t *testing.T) { } // 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. +// 1.25×: a call's 1-hour writes at theirs, the price's own else the +// 5-minute one (PAMI on Discord: not 2× input for a model with no 1-hour +// writes at all), and a call from before the split was kept (none for 1 +// hour) as before. A Claude model's price is given 2× input. 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) + if got, want := p.CostSplit(1000, 100, 0, 3000, 1000), (1000*5.0+100*25+3000*6.25)/1e6; !near(got, want) { + t.Errorf("unset 1h: %v, want %v, every write at the 5-minute price", got, want) + } + c := p + OneHourFor("claude-sonnet-5-5", &c) + if got, want := c.CostSplit(1000, 100, 0, 3000, 1000), (1000*5.0+100*25+2000*6.25+1000*10)/1e6; !near(got, want) { + t.Errorf("claude unset 1h: %v, want %v", got, want) + } + g := p + OneHourFor("gpt-6-astra", &g) + if g.CacheWrite1h != 0 { + t.Errorf("gpt given a 1-hour write: %v", g.CacheWrite1h) } 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) { diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 0afcc657..3d2203a3 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -8619,7 +8619,9 @@ 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 - // the 1-hour cache write, empty, is 2× input as Anthropic bills it; + // the 1-hour cache write, empty, is 2× input as Anthropic bills it, + // and only a Claude model (or one priced with one) has the box: no + // other vendor has 1-hour writes (PAMI on Discord); // 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"]]; @@ -8647,7 +8649,10 @@ function renderModels(p) { 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"); + if (k === "cache_write_1h") { + box.title = t("A cache write kept for an hour, as Anthropic bills it; empty: 2× input"); + box.hidden = !/claude/i.test(m.id) && !m.list?.cache_write_1h && !pr?.cache_write_1h && !(typed && typed[n]); + } box.append(el("span", "", t(l)), i); priceBox.append(box); return i; diff --git a/internal/gui/tests/model-price-tiers.test.cjs b/internal/gui/tests/model-price-tiers.test.cjs index 4a999b40..f5d38a13 100644 --- a/internal/gui/tests/model-price-tiers.test.cjs +++ b/internal/gui/tests/model-price-tiers.test.cjs @@ -80,7 +80,11 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", 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"); + // only a Claude model has a 1-hour box: gpt-6-astra has no such write + // to price, so no 2× input one is shown for it (PAMI on Discord) + assert(await row("claude-opus-5-5").getByRole("textbox", { name: L.oneHour, exact: true }).isVisible()); + assert(!(await row("gpt-6-astra").getByRole("textbox", { name: L.oneHour, exact: true }).isVisible())); + assert(!(await row("plain-1").getByRole("textbox", { name: L.oneHour, exact: true }).isVisible())); // 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()); diff --git a/internal/provider/models.go b/internal/provider/models.go index 1048dc86..d55bad63 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -917,6 +917,8 @@ func EffectivePriceIn(s settings.Settings, providerID, model string) (catalog.Pr for _, key := range [...]string{id + "/" + model, id + "/*", AnyPriceKey(model)} { if m, ok := s.ModelPrices[key]; ok { if pr, bad := m.Price(); bad == "" { + // a Claude model's 1-hour cache write left out: 2× input + catalog.OneHourFor(model, &pr) return pr, true } } diff --git a/internal/sessions/sessions.go b/internal/sessions/sessions.go index c12bdc09..8b57c475 100644 --- a/internal/sessions/sessions.go +++ b/internal/sessions/sessions.go @@ -1248,6 +1248,7 @@ func priceOf(s settings.Settings, model string) (catalog.Price, bool) { // from them if p, ok := s.ModelPrices[provider.AnyPriceKey(bare)]; ok { if pr, bad := p.Price(); bad == "" { + catalog.OneHourFor(bare, &pr) return pr, true } } diff --git a/internal/settings/price_tiers_test.go b/internal/settings/price_tiers_test.go index be714531..0e8ce4e6 100644 --- a/internal/settings/price_tiers_test.go +++ b/internal/settings/price_tiers_test.go @@ -32,7 +32,7 @@ func TestModelPriceTiers(t *testing.T) { 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 { + if got, bad := old.Price(); bad != "" || !got.Same(catalog.Price{Input: 1, Output: 2, CacheRead: 0.1}) || got.OneHour() != 0 { 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") { diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 35b1a830..deb56aa9 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -374,7 +374,8 @@ type ModelPrice struct { 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). + // given: such a write is then counted at 2× input for a Claude model, + // else at the 5-minute price (catalog.OneHourFor). 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). diff --git a/model_cli.go b/model_cli.go index f5a465cf..695e1155 100644 --- a/model_cli.go +++ b/model_cli.go @@ -27,7 +27,7 @@ const modelUsage = `usage: 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 fifth part is a 1-hour cache - write's (Anthropic's 2× input, what it is counted at if not given) + write's (not given: a Claude model's is 2× input, any other's the 4th) 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 @@ -389,10 +389,12 @@ func parsePrice(rest []string) (catalog.Price, error) { // 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. +// A 1-hour cache write is shown only where the price has one — a Claude +// model's, or one given — not one no vendor bills (PAMI on Discord). 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" + cache := " · cache read " + money(p.CacheRead) + ", cache write " + money(p.CacheWrite) + if p.CacheWrite1h > 0 { + cache += " (5 min), " + money(p.CacheWrite1h) + " (1 hour)" } lines := []string{cache} for _, t := range p.Tiers { @@ -424,6 +426,7 @@ func anyModelPrice(ref string, rest []string) error { } if len(rest) == 0 { if pr, ok := statedPrice(settings.Load().ModelPrices, key); ok { + catalog.OneHourFor(strings.TrimPrefix(key, settings.AnyProvider), &pr) fmt.Println(bold.Render(perMillion(pr)), muted.Render("· "+key)) for _, l := range priceDetail(pr) { fmt.Println(faint.Render(l)) diff --git a/model_price_tier_cli_test.go b/model_price_tier_cli_test.go index 314bf115..b83d1cea 100644 --- a/model_price_tier_cli_test.go +++ b/model_price_tier_cli_test.go @@ -37,6 +37,26 @@ func TestModelPriceTierCmd(t *testing.T) { t.Errorf("magpie model prices: %q; want its tier", out) } + // PAMI on Discord: four parts give no 1-hour cache write but a Claude + // model's — gpt-6-astra was shown "$20 (1 hour) at 2× input", a write + // OpenAI has no price for + if strings.Contains(out, "1 hour") || strings.Contains(out, "2× input") { + t.Errorf("magpie model price a/m: %q; want no 1-hour cache write", out) + } + if pr.OneHour() != 12.5 { + t.Errorf("a/m 1h = %v, want the 5-minute 12.5", pr.OneHour()) + } + if err := modelCmd([]string{"price", "a/claude-opus-5-5", "3,15,0.3,3.75"}); err != nil { + t.Fatal(err) + } + if pr, _ := provider.EffectivePrice("a", "claude-opus-5-5"); pr.OneHour() != 6 { + t.Errorf("claude 1h = %+v, want 2× input", pr) + } + out, _ = said(t, func() error { return modelCmd([]string{"price", "a/claude-opus-5-5"}) }) + if !strings.Contains(out, "$6.00 (1 hour)") && !strings.Contains(out, "$6 (1 hour)") { + t.Errorf("magpie model price a/claude-opus-5-5: %q; want its 1-hour cache write", out) + } + if err := modelCmd([]string{"price", "a/only-a", "5,25,0.5,6.25,8"}); err != nil { t.Fatal(err) }