From 76f46877678bed11acbb1eefbcf6bed72dd28c3e Mon Sep 17 00:00:00 2001 From: hallelx2 Date: Sun, 2 Aug 2026 23:38:01 +0100 Subject: [PATCH] fix(pricing): generate the embedded price table from the live feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The embedded table answers every lookup the override and remote layers do not: UseRemote off, network down, or a first refresh still in flight. Hand-maintained and dated "as of April 2026", it had drifted. gemini-2.5-flash carried an output rate of 0.60 against a real 2.50 — under-reporting spend on that model by more than 4x, in the direction that silently defeats budget caps. The Claude 5 and 4.6 families were missing outright, so an offline caller running them got nothing at all. defaultPrices now lives in defaults_gen.go, produced by pricing/gen from the LiteLLM feed. Rather than reimplement rate selection, the generator installs a real snapshot and reads it back through pricing.Lookup, so vendor precedence, dropped rows and cache backfill apply exactly as they do at runtime and the two cannot diverge. Regenerating uncovered two further defects, both fixed here. Canonical() reduced "anthropic.claude-v1" and "claude-v2:1" to a bare "claude" at Claude 1's 8.00/24.00. longestPrefix matches at segment boundaries, so that key then answered for every Claude model not listed explicitly: claude-haiku-3-5 resolved to 8.00/24.00 against its real 0.80/4.00, a 10x over-report. An ID that has lost all version information is a family name, not a model, so the qualified form is kept instead. This also stops the legacy entries collapsing together, which is why the snapshot now holds 1086 models rather than 1070. The curated list needed claude-haiku-3-5 sourced from upstream's claude-3-5-haiku — Anthropic put the generation before the tier on the 3.x line and after it from 4.x on. The generator now requires an exact key in the snapshot rather than accepting whatever Lookup's prefix fallback returns, so a curated ID that has quietly gone missing fails the build instead of inheriting a sibling's price. Rates are rounded to ten decimal places on the way out. Upstream quotes per-token, so every value arrives multiplied by a million and 2e-07 renders as 0.19999999999999998, which is unreadable and makes each regeneration a noisy diff. A weekly workflow regenerates and fails if the committed table has moved, which is the signal that a vendor changed a price. The generator leaves the file untouched when only its own timestamp would change, so that job fires on real drift rather than every Monday. --- .github/workflows/price-drift.yml | 46 ++++++ pricing/canonical.go | 12 ++ pricing/defaults_gen.go | 54 +++++++ pricing/defaults_test.go | 103 +++++++++++++ pricing/gen/main.go | 246 ++++++++++++++++++++++++++++++ pricing/pricing.go | 58 +++---- 6 files changed, 478 insertions(+), 41 deletions(-) create mode 100644 .github/workflows/price-drift.yml create mode 100644 pricing/defaults_gen.go create mode 100644 pricing/defaults_test.go create mode 100644 pricing/gen/main.go diff --git a/.github/workflows/price-drift.yml b/.github/workflows/price-drift.yml new file mode 100644 index 0000000..e189a6f --- /dev/null +++ b/.github/workflows/price-drift.yml @@ -0,0 +1,46 @@ +name: price drift + +# The embedded price table answers every lookup when UseRemote is off, the +# network is down, or the first refresh is still in flight. Left alone it +# rots: gemini-2.5-flash sat at an output rate of 0.60 against a real 2.50 +# for months, under-reporting spend on that model by more than 4x. +# +# This job regenerates the table from the live feed once a week and fails if +# the result differs from what is committed. A failure is not a broken +# build — it means a vendor changed a price and the table needs regenerating. + +on: + schedule: + # Mondays, 06:00 UTC. + - cron: "0 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + drift: + name: embedded price table vs live feed + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.25" + cache: true + + - name: Regenerate the embedded table + run: go generate ./pricing + + - name: Fail if the committed table has drifted + run: | + if ! git diff --exit-code -- pricing/defaults_gen.go; then + echo + echo "::error::The embedded price table no longer matches the upstream feed." + echo "A vendor has changed a price. Run 'go generate ./pricing', review the" + echo "diff above, and commit it." + exit 1 + fi + echo "Embedded price table matches the live feed." diff --git a/pricing/canonical.go b/pricing/canonical.go index 1fd87d2..87616ee 100644 --- a/pricing/canonical.go +++ b/pricing/canonical.go @@ -91,9 +91,21 @@ func Canonical(model string) string { // so a lookup for "claude-sonnet-4-5-20250929" landed in a bucket // holding only regional and GovCloud rates and quietly billed the // 10-20% premium. + qualified := s s = versionSuffix.ReplaceAllString(s, "") s = dateSuffix.ReplaceAllString(s, "") + // Stripping the qualifiers must not strip away the model itself. + // "anthropic.claude-v1" reduces to a bare "claude", and since + // longestPrefix matches at segment boundaries, that key then answers + // for every Claude model the book does not list explicitly: + // claude-haiku-3-5 resolved to Claude 1's 8.00/24.00 rather than its + // own 0.80/4.00, a 10x over-report. An ID with no version left is a + // family name, not a model, so keep the qualified form. + if !strings.ContainsAny(s, "0123456789") { + return qualified + } + return s } diff --git a/pricing/defaults_gen.go b/pricing/defaults_gen.go new file mode 100644 index 0000000..b59aa57 --- /dev/null +++ b/pricing/defaults_gen.go @@ -0,0 +1,54 @@ +// Code generated by pricing/gen. DO NOT EDIT. +// +// Source: litellm, fetched 2026-08-02T22:34:49Z. +// Regenerate with: go generate ./pricing + +package pricing + +// defaultPrices is the compiled-in price table. It serves every lookup +// that the override and remote layers do not answer: no UseRemote, no +// network, or a first refresh still in flight. +// +// Rates are USD per 1,000,000 tokens. A zero cache rate means the feed +// published none, and cacheRates falls back to the family multiplier. +var defaultPrices = map[string]Price{ + // Anthropic + "claude-opus-5": {InputPerMTok: 5, OutputPerMTok: 25, CacheWritePerMTok: 6.25, CacheReadPerMTok: 0.5}, + "claude-opus-4-6": {InputPerMTok: 5, OutputPerMTok: 25, CacheWritePerMTok: 6.25, CacheReadPerMTok: 0.5}, + "claude-opus-4-5": {InputPerMTok: 5, OutputPerMTok: 25, CacheWritePerMTok: 6.25, CacheReadPerMTok: 0.5}, + "claude-opus-4-1": {InputPerMTok: 15, OutputPerMTok: 75, CacheWritePerMTok: 18.75, CacheReadPerMTok: 1.5}, + "claude-sonnet-5": {InputPerMTok: 2, OutputPerMTok: 10, CacheWritePerMTok: 2.5, CacheReadPerMTok: 0.2}, + "claude-sonnet-4-6": {InputPerMTok: 3, OutputPerMTok: 15, CacheWritePerMTok: 3.75, CacheReadPerMTok: 0.3}, + "claude-sonnet-4-5": {InputPerMTok: 3, OutputPerMTok: 15, CacheWritePerMTok: 3.75, CacheReadPerMTok: 0.3}, + "claude-sonnet-4": {InputPerMTok: 3, OutputPerMTok: 15, CacheWritePerMTok: 3.75, CacheReadPerMTok: 0.3}, + "claude-haiku-4-5": {InputPerMTok: 1, OutputPerMTok: 5, CacheWritePerMTok: 1.25, CacheReadPerMTok: 0.1}, + "claude-haiku-3-5": {InputPerMTok: 0.8, OutputPerMTok: 4, CacheWritePerMTok: 1, CacheReadPerMTok: 0.08}, + + // OpenAI + "gpt-4o": {InputPerMTok: 2.5, OutputPerMTok: 10, CacheReadPerMTok: 1.25}, + "gpt-4o-mini": {InputPerMTok: 0.15, OutputPerMTok: 0.6, CacheReadPerMTok: 0.075}, + "gpt-4.1": {InputPerMTok: 2, OutputPerMTok: 8, CacheReadPerMTok: 0.5}, + "gpt-4.1-mini": {InputPerMTok: 0.4, OutputPerMTok: 1.6, CacheReadPerMTok: 0.1}, + "gpt-4.1-nano": {InputPerMTok: 0.1, OutputPerMTok: 0.4, CacheReadPerMTok: 0.025}, + "o3": {InputPerMTok: 2, OutputPerMTok: 8, CacheReadPerMTok: 0.5}, + "o3-mini": {InputPerMTok: 1.1, OutputPerMTok: 4.4, CacheReadPerMTok: 0.55}, + "o4-mini": {InputPerMTok: 1.1, OutputPerMTok: 4.4, CacheReadPerMTok: 0.275}, + + // Google + "gemini-2.5-pro": {InputPerMTok: 1.25, OutputPerMTok: 10, CacheReadPerMTok: 0.125}, + "gemini-2.5-flash": {InputPerMTok: 0.3, OutputPerMTok: 2.5, CacheReadPerMTok: 0.03}, + "gemini-2.0-flash": {InputPerMTok: 0.1, OutputPerMTok: 0.4, CacheReadPerMTok: 0.025}, + + // Zhipu / Z.ai GLM + "glm-5.1": {InputPerMTok: 1.4, OutputPerMTok: 4.4, CacheReadPerMTok: 0.26}, + "glm-5": {InputPerMTok: 1, OutputPerMTok: 3.2, CacheReadPerMTok: 0.2}, + "glm-4.7": {InputPerMTok: 0.6, OutputPerMTok: 2.2, CacheReadPerMTok: 0.11}, + "glm-4.6": {InputPerMTok: 0.6, OutputPerMTok: 2.2, CacheReadPerMTok: 0.11}, + "glm-4.5": {InputPerMTok: 0.6, OutputPerMTok: 2.2, CacheReadPerMTok: 0.11}, + "glm-4.5-air": {InputPerMTok: 0.2, OutputPerMTok: 1.1, CacheReadPerMTok: 0.03}, +} + +// defaultsAsOf is when the table above was generated. Exposed through +// AsOf when no remote snapshot is installed, so a caller can tell how +// old the rates behind a cost figure are. +var defaultsAsOf = "2026-08-02T22:34:49Z" diff --git a/pricing/defaults_test.go b/pricing/defaults_test.go new file mode 100644 index 0000000..d1ddd7f --- /dev/null +++ b/pricing/defaults_test.go @@ -0,0 +1,103 @@ +package pricing_test + +import ( + "testing" + "time" + + "github.com/hallelx2/llmgate/pricing" +) + +// TestFamilyNameIsNotAModel is the regression test for a 10x over-report. +// +// LiteLLM carries "anthropic.claude-v1" and "anthropic.claude-v2:1" at +// Claude 1's 8.00/24.00. Canonical stripped the vendor prefix and the "-v1", +// leaving a bare "claude" in the price book — and because longestPrefix +// matches at segment boundaries, that key then answered for every Claude +// model not listed explicitly. claude-haiku-3-5 came back 8.00/24.00 +// instead of its real 0.80/4.00. +func TestFamilyNameIsNotAModel(t *testing.T) { + for _, id := range []string{ + "anthropic.claude-v1", + "anthropic.claude-v2:1", + "bedrock/us-east-1/anthropic.claude-v1", + } { + if got := pricing.Canonical(id); got == "claude" { + t.Errorf("Canonical(%q) = %q — a bare family name becomes a catch-all "+ + "prefix for every unlisted Claude model", id, got) + } + } + + // And the model that was mispriced by it. + p, ok := pricing.Lookup("claude-haiku-3-5") + if !ok { + t.Fatal("claude-haiku-3-5 must be priced") + } + if p.InputPerMTok != 0.80 || p.OutputPerMTok != 4.00 { + t.Errorf("claude-haiku-3-5 = %.4f/%.4f, want 0.8000/4.0000", p.InputPerMTok, p.OutputPerMTok) + } +} + +// TestGeneratedDefaultsAreSane spot-checks the generated table against +// vendor list prices. These are the rates the vendors publish; if a +// regeneration moves one, that is either a real price change worth noticing +// in review or a bug in the generator — both warrant a failing test. +func TestGeneratedDefaultsAreSane(t *testing.T) { + want := map[string]struct{ in, out float64 }{ + "claude-opus-4-1": {15.00, 75.00}, + "claude-sonnet-4-5": {3.00, 15.00}, + "claude-haiku-4-5": {1.00, 5.00}, + "claude-haiku-3-5": {0.80, 4.00}, + "gpt-4o": {2.50, 10.00}, + "gpt-4o-mini": {0.15, 0.60}, + "gemini-2.5-pro": {1.25, 10.00}, + "gemini-2.5-flash": {0.30, 2.50}, + "glm-4.6": {0.60, 2.20}, + "glm-4.5-air": {0.20, 1.10}, + } + + for id, w := range want { + p, ok := pricing.Lookup(id) + if !ok { + t.Errorf("%s: not in the embedded table", id) + continue + } + if p.InputPerMTok != w.in || p.OutputPerMTok != w.out { + t.Errorf("%s = %.4f/%.4f, want %.4f/%.4f", id, p.InputPerMTok, p.OutputPerMTok, w.in, w.out) + } + } +} + +// TestNoAbsurdDefaults guards the units error that would do the most +// damage. Every rate the vendors publish sits well inside this range, so +// anything outside it means the generator wrote per-token values as +// per-million or similar. +func TestNoAbsurdDefaults(t *testing.T) { + for _, id := range pricing.KnownModels() { + p, ok := pricing.Lookup(id) + if !ok { + continue + } + if p.InputPerMTok <= 0 || p.InputPerMTok > 200 { + t.Errorf("%s: input %.4f per MTok is outside any plausible range", id, p.InputPerMTok) + } + if p.OutputPerMTok <= 0 || p.OutputPerMTok > 1000 { + t.Errorf("%s: output %.4f per MTok is outside any plausible range", id, p.OutputPerMTok) + } + if p.CacheReadPerMTok > p.InputPerMTok { + t.Errorf("%s: cache read %.4f exceeds input %.4f — a cache hit is never "+ + "more expensive than the uncached token", id, p.CacheReadPerMTok, p.InputPerMTok) + } + } +} + +// TestDefaultsAsOfIsParseable keeps the provenance stamp honest: a caller +// reading it wants to know how old the rates behind a cost figure are. +func TestDefaultsAsOfIsParseable(t *testing.T) { + got := pricing.DefaultsAsOf() + if got.IsZero() { + t.Fatal("DefaultsAsOf is zero — the generated stamp did not parse") + } + if got.After(time.Now().Add(24 * time.Hour)) { + t.Errorf("DefaultsAsOf = %s, which is in the future", got) + } +} diff --git a/pricing/gen/main.go b/pricing/gen/main.go new file mode 100644 index 0000000..1aa3478 --- /dev/null +++ b/pricing/gen/main.go @@ -0,0 +1,246 @@ +// Command gen regenerates the embedded price table in +// pricing/defaults_gen.go from the live LiteLLM feed. +// +// Run it with `go generate ./pricing`. It is not part of the library build. +// +// The embedded table exists for the cases where the remote layer cannot +// help: UseRemote not enabled, the network down, or the first refresh still +// in flight. That makes staleness a correctness problem rather than an +// untidiness one — before this generator existed, gemini-2.5-flash sat at +// an output rate of 0.60 against a real 2.50, under-reporting spend on that +// model by more than 4x for anyone who had not opted into remote pricing. +// +// Rather than reimplement rate selection, the generator installs a real +// snapshot and reads it back through pricing.Lookup. Everything the library +// does to choose between the many upstream entries for one model — vendor +// precedence over resellers, dropping implausible rows, backfilling cache +// rates — therefore applies here identically, and the embedded table cannot +// drift from the rules the remote layer follows. +package main + +import ( + "context" + "fmt" + "go/format" + "math" + "os" + "sort" + "strconv" + "strings" + "time" + + "github.com/hallelx2/llmgate/pricing" +) + +// model is one curated entry. id is the key written into the generated +// table; from is the upstream ID to read it from when the two differ. +type model struct { + id string + from string // optional; defaults to id +} + +func m(id string) model { return model{id: id} } +func alias(id, from string) model { return model{id: id, from: from} } + +func (x model) source() string { + if x.from != "" { + return x.from + } + return x.id +} + +// curated is the editorial decision this generator cannot make for itself: +// which models are worth carrying in the binary. The feed has over 2,000. +// +// The rule of thumb is models a caller might plausibly run without having +// configured remote pricing — current frontier models from the four +// families llmgate classifies, plus the GLM line vectorless runs in +// production. Ordering here drives the ordering of the generated file. +var curated = []struct { + group string + models []model +}{ + {"Anthropic", []model{ + m("claude-opus-5"), m("claude-opus-4-6"), m("claude-opus-4-5"), m("claude-opus-4-1"), + m("claude-sonnet-5"), m("claude-sonnet-4-6"), m("claude-sonnet-4-5"), m("claude-sonnet-4"), + m("claude-haiku-4-5"), + // Anthropic put the generation before the tier for the 3.x line + // and after it from 4.x on. Upstream follows suit; llmgate has + // always keyed this one the modern way, so keep that and source + // it from the ID the feed actually carries. + alias("claude-haiku-3-5", "claude-3-5-haiku"), + }}, + {"OpenAI", []model{ + m("gpt-4o"), m("gpt-4o-mini"), m("gpt-4.1"), m("gpt-4.1-mini"), m("gpt-4.1-nano"), + m("o3"), m("o3-mini"), m("o4-mini"), + }}, + {"Google", []model{ + m("gemini-2.5-pro"), m("gemini-2.5-flash"), m("gemini-2.0-flash"), + }}, + {"Zhipu / Z.ai GLM", []model{ + m("glm-5.1"), m("glm-5"), m("glm-4.7"), m("glm-4.6"), m("glm-4.5"), m("glm-4.5-air"), + }}, +} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "gen:", err) + os.Exit(1) + } +} + +func run() error { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + var fetchErr error + stop, err := pricing.UseRemote(ctx, pricing.RemoteConfig{ + Sources: []pricing.Source{pricing.LiteLLMSource{}}, + RefreshInterval: -1, + Timeout: 2 * time.Minute, + OnError: func(src string, e error) { fetchErr = fmt.Errorf("%s: %w", src, e) }, + OnRefresh: func(src string, n int) { + fmt.Fprintf(os.Stderr, "gen: %s supplied %d models\n", src, n) + }, + }) + if err != nil { + return err + } + defer stop() + + asOf, source := pricing.AsOf() + if asOf.IsZero() { + if fetchErr != nil { + return fmt.Errorf("no snapshot installed: %w", fetchErr) + } + return fmt.Errorf("no snapshot installed and no error reported") + } + + // A missing model is a hard failure, not an omission: silently dropping + // one would shrink the embedded table without anyone noticing. + // + // Require the exact key. Lookup would happily fall back to a prefix + // match, which is how claude-haiku-3-5 once picked up Claude 1's + // 8.00/24.00 — a curated ID that is not really in the feed must fail + // loudly here, not be quietly filled with a sibling's rate. + present := map[string]bool{} + for _, k := range pricing.KnownModels() { + present[k] = true + } + + var missing []string + prices := map[string]pricing.Price{} + for _, g := range curated { + for _, mo := range g.models { + src := mo.source() + if !present[src] { + missing = append(missing, src) + continue + } + p, ok := pricing.Lookup(src) + if !ok { + missing = append(missing, src) + continue + } + prices[mo.id] = p + } + } + if len(missing) > 0 { + sort.Strings(missing) + return fmt.Errorf("not present in %s: %v — remove them from the curated list "+ + "or fix the ID before regenerating", source, missing) + } + + src, err := render(prices, asOf, source) + if err != nil { + return err + } + + // Every run stamps a new fetch time, so writing unconditionally would + // dirty the file even when no vendor moved a price — and the weekly + // drift job, which is just `go generate` plus `git diff --exit-code`, + // would then fail every week and teach everyone to ignore it. Rewrite + // only when a rate actually changed. + if old, e := os.ReadFile("defaults_gen.go"); e == nil && sameRates(old, src) { + fmt.Fprintln(os.Stderr, "gen: rates unchanged, leaving defaults_gen.go alone") + return nil + } + return os.WriteFile("defaults_gen.go", src, 0o644) +} + +// sameRates compares two generated files ignoring the provenance stamps, +// which differ on every run by construction. +func sameRates(a, b []byte) bool { + return stripStamps(a) == stripStamps(b) +} + +func stripStamps(src []byte) string { + var kept []string + for line := range strings.SplitSeq(string(src), "\n") { + t := strings.TrimSpace(strings.TrimSuffix(line, "\r")) + if strings.HasPrefix(t, "// Source:") || strings.HasPrefix(t, "var defaultsAsOf") { + continue + } + kept = append(kept, t) + } + return strings.Join(kept, "\n") +} + +func render(prices map[string]pricing.Price, asOf time.Time, source string) ([]byte, error) { + var b []byte + add := func(format string, args ...any) { + b = fmt.Appendf(b, format, args...) + } + + add("// Code generated by pricing/gen. DO NOT EDIT.\n") + add("//\n") + add("// Source: %s, fetched %s.\n", source, asOf.UTC().Format(time.RFC3339)) + add("// Regenerate with: go generate ./pricing\n\n") + add("package pricing\n\n") + add("// defaultPrices is the compiled-in price table. It serves every lookup\n") + add("// that the override and remote layers do not answer: no UseRemote, no\n") + add("// network, or a first refresh still in flight.\n") + add("//\n") + add("// Rates are USD per 1,000,000 tokens. A zero cache rate means the feed\n") + add("// published none, and cacheRates falls back to the family multiplier.\n") + add("var defaultPrices = map[string]Price{\n") + + for _, g := range curated { + add("\t// %s\n", g.group) + for _, mo := range g.models { + p := prices[mo.id] + add("\t%q: {InputPerMTok: %s, OutputPerMTok: %s", + mo.id, num(p.InputPerMTok), num(p.OutputPerMTok)) + if p.CacheWritePerMTok > 0 { + add(", CacheWritePerMTok: %s", num(p.CacheWritePerMTok)) + } + if p.CacheReadPerMTok > 0 { + add(", CacheReadPerMTok: %s", num(p.CacheReadPerMTok)) + } + if p.ReasoningPerMTok > 0 { + add(", ReasoningPerMTok: %s", num(p.ReasoningPerMTok)) + } + add("},\n") + } + add("\n") + } + add("}\n\n") + + add("// defaultsAsOf is when the table above was generated. Exposed through\n") + add("// AsOf when no remote snapshot is installed, so a caller can tell how\n") + add("// old the rates behind a cost figure are.\n") + add("var defaultsAsOf = %q\n", asOf.UTC().Format(time.RFC3339)) + + return format.Source(b) +} + +// num renders a rate as the shortest clean decimal. +// +// Upstream quotes per-token, so every rate arrives having been multiplied +// by a million: 2e-07 becomes 0.19999999999999998. Printed verbatim that +// is unreadable and makes every regeneration a noisy diff. Rates are +// published to at most a few decimal places, so rounding to ten recovers +// the number the vendor actually quoted without touching any real value. +func num(f float64) string { + return strconv.FormatFloat(math.Round(f*1e10)/1e10, 'g', -1, 64) +} diff --git a/pricing/pricing.go b/pricing/pricing.go index a67ed4e..1344e80 100644 --- a/pricing/pricing.go +++ b/pricing/pricing.go @@ -12,6 +12,7 @@ import ( "sort" "sync" "sync/atomic" + "time" ) // Price is the USD cost per 1,000,000 tokens for a given model. @@ -39,52 +40,13 @@ type Price struct { ReasoningPerMTok float64 } -// defaultPrices are public list prices as of April 2026. Refresh as -// providers update. -// -// Keys are canonical base IDs. Dated, prefixed, and gateway-qualified -// variants resolve here through Canonical, so there is no need to add -// "claude-sonnet-4-5-20250929" alongside "claude-sonnet-4-5". -var defaultPrices = map[string]Price{ - // ── Anthropic ───────────────────────────────────────────────── - // Cache rates are Anthropic's published 1.25x write / 0.1x read. - "claude-sonnet-4-5": {InputPerMTok: 3.00, OutputPerMTok: 15.00, CacheWritePerMTok: 3.75, CacheReadPerMTok: 0.30}, - "claude-sonnet-4": {InputPerMTok: 3.00, OutputPerMTok: 15.00, CacheWritePerMTok: 3.75, CacheReadPerMTok: 0.30}, - "claude-opus-4-1": {InputPerMTok: 15.00, OutputPerMTok: 75.00, CacheWritePerMTok: 18.75, CacheReadPerMTok: 1.50}, - "claude-haiku-4-5": {InputPerMTok: 1.00, OutputPerMTok: 5.00, CacheWritePerMTok: 1.25, CacheReadPerMTok: 0.10}, - "claude-haiku-3-5": {InputPerMTok: 0.80, OutputPerMTok: 4.00, CacheWritePerMTok: 1.00, CacheReadPerMTok: 0.08}, - - // ── OpenAI ──────────────────────────────────────────────────── - // Cached input bills at 0.5x; OpenAI caches implicitly, so there is - // no separate write charge. - "gpt-4o": {InputPerMTok: 2.50, OutputPerMTok: 10.00, CacheReadPerMTok: 1.25}, - "gpt-4o-mini": {InputPerMTok: 0.15, OutputPerMTok: 0.60, CacheReadPerMTok: 0.075}, - "gpt-4.1": {InputPerMTok: 2.00, OutputPerMTok: 8.00, CacheReadPerMTok: 0.50}, - "gpt-4.1-mini": {InputPerMTok: 0.40, OutputPerMTok: 1.60, CacheReadPerMTok: 0.10}, - "gpt-4.1-nano": {InputPerMTok: 0.10, OutputPerMTok: 0.40, CacheReadPerMTok: 0.025}, - "o3": {InputPerMTok: 2.00, OutputPerMTok: 8.00, CacheReadPerMTok: 0.50}, - "o3-mini": {InputPerMTok: 1.10, OutputPerMTok: 4.40, CacheReadPerMTok: 0.55}, - "o4-mini": {InputPerMTok: 1.10, OutputPerMTok: 4.40, CacheReadPerMTok: 0.275}, - - // ── Google ──────────────────────────────────────────────────── - // Cached content bills at 0.25x. - "gemini-2.5-flash": {InputPerMTok: 0.15, OutputPerMTok: 0.60, CacheReadPerMTok: 0.0375}, - "gemini-2.5-pro": {InputPerMTok: 1.25, OutputPerMTok: 10.00, CacheReadPerMTok: 0.3125}, - "gemini-2.0-flash": {InputPerMTok: 0.10, OutputPerMTok: 0.40, CacheReadPerMTok: 0.025}, - - // ── Zhipu / Z.ai GLM (public Z.ai API list prices, added May 2026) ─ - // The family vectorless runs in production, through z.ai's - // Anthropic-compatible gateway. - "glm-4.6": {InputPerMTok: 0.60, OutputPerMTok: 2.20, CacheReadPerMTok: 0.11}, - "glm-4.5": {InputPerMTok: 0.60, OutputPerMTok: 2.20, CacheReadPerMTok: 0.11}, - "glm-4.5-air": {InputPerMTok: 0.20, OutputPerMTok: 1.10, CacheReadPerMTok: 0.03}, -} +//go:generate go run ./gen // The price book resolves in three layers, most authoritative first: // // 1. overrides — explicit Register calls, always win // 2. remote — a refreshed snapshot, when one has been installed -// 3. defaults — the table above, compiled in and always available +// 3. defaults — defaults_gen.go, compiled in and always available // // Every layer is optional except the last, and a lookup falls through on // a miss. The point is that a remote refresh can never leave the library @@ -281,6 +243,20 @@ func Compute(model string, in, out int) float64 { return cost } +// DefaultsAsOf reports when the compiled-in price table was generated from +// the upstream feed. +// +// AsOf tells you the vintage of the remote snapshot, and reports the zero +// time when there is none. This is the answer for the other case: how old +// the rates behind a cost figure are when nothing remote is installed. +func DefaultsAsOf() time.Time { + t, err := time.Parse(time.RFC3339, defaultsAsOf) + if err != nil { + return time.Time{} + } + return t +} + // KnownModels returns every model ID with pricing data, across all three // layers, deduplicated and sorted. Useful for diagnostics. func KnownModels() []string {