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 {