Problem
Three related defects, all in the "the dollar number is confidently wrong and nothing says so" class. They share one fix, so they are tracked together.
1. Pricing never refreshes inside a running process
src/lib/pricing/index.js:52 — ensurePricingLoaded() returns immediately once state.loaded is true. There is no loadedAt, no reload path, and the only call sites are src/commands/serve.js:90 (once at startup) and src/lib/local-api.js:1393 (a no-op after the first load). The 24h TTL in litellm-fetcher.js therefore governs only which snapshot is chosen at startup — never a refresh.
This is the systemic cause of the claude-opus-5 incident on 2026-07-25: the dashboard LaunchAgent had been up 21 hours holding a snapshot written before the model existed upstream. It showed 3.87M tokens at $0.000000, understating the day by $7.37, for 21 hours, with no signal. Deleting the cache file was incidental — a restart alone would have fixed it, and nothing short of a restart ever will.
It recurs on every new model and every upstream price change. The next occurrence is already scheduled: Sonnet 5 intro pricing reverts on 2026-08-31 (see #87), and a dashboard that has been up since August will keep billing the intro rate indefinitely.
2. Unknown model → silent $0; the provenance is computed and then discarded
src/lib/pricing/index.js:101-104 — lookupPricing() returns {hit, source, value}, but getModelPricing() throws source away and returns bare numbers. On a miss it negative-caches and returns ZERO_PRICING with no log line, no counter, no API field, no UI badge. test/pricing.test.js:265-271 tests the miss mechanism but asserts no signal, so the silence is codified.
A $0 model is indistinguishable from a genuinely free model on every surface. The maintenance cost is already visible: curated-overrides.json carries hand-written pins for claude-fable-5, claude-mythos-5, and claude-opus-4-8 precisely because each new frontier model starts life as a silent miss.
3. Fuzzy tiers assign plausible-but-wrong prices, also silently
- Curated fuzzy over-capture —
curated-overrides.json maps {"match": "kimi", "ref": "kimi-k2.5"} and {"match": "composer", "ref": "composer-1"}. A future kimi-k3 falls past exact/alias and lands on the k2.5 price. The file already needed a kimi-k2.6 exact entry to escape that net, which is evidence the net catches real models.
- LiteLLM reverse substring —
src/lib/pricing/matcher.js:192-203 matches longest-key-first with lower.includes(keyLower). A hypothetical gpt-5.5-preview contains the literal key gpt-5 and would price at gpt-5 rates. Minor-version successors are exactly the models most likely to launch at a different price.
Silence is defensible for step 5 (suffix-strip: -high-fast variants genuinely share base pricing). It is not defensible for steps 4, 5b, and 6, where a mis-hit is invisible forever.
Fix (one change closes all three)
The hard part is already done — lookupPricing computes the resolution tier at the exact line where getModelPricing discards it.
- Thread
result.source through getModelPricing (parallel getModelPricingMeta(), or attach a non-enumerable tier).
- Record
loadedAt on load; when a lookup is about to miss or Date.now() - loadedAt > ttl, fire one background reload() (reuse loadLitellmData, clear the negative cache on completion). Makes "a new model appeared" self-healing within one TTL instead of "until the owner happens to notice $0".
- Surface the signal where a human sees it: one
console.warn per unknown model per process, unpriced_models and fuzzy_priced_models arrays in the stats API response, and a dashboard badge on any model row whose tier is miss or *:fuzzy.
Optionally, demote step 6 to require a family match (first-token equality) before accepting a substring hit.
Acceptance
Sizing estimate ~150 lines plus tests. Deliberately a separate PR from the small hardening work, because it touches the pricing path that just had a live incident and deserves an isolated diff.
Found during a Fable-model QA review of 0.39.40.
Problem
Three related defects, all in the "the dollar number is confidently wrong and nothing says so" class. They share one fix, so they are tracked together.
1. Pricing never refreshes inside a running process
src/lib/pricing/index.js:52—ensurePricingLoaded()returns immediately oncestate.loadedis true. There is noloadedAt, no reload path, and the only call sites aresrc/commands/serve.js:90(once at startup) andsrc/lib/local-api.js:1393(a no-op after the first load). The 24h TTL inlitellm-fetcher.jstherefore governs only which snapshot is chosen at startup — never a refresh.This is the systemic cause of the
claude-opus-5incident on 2026-07-25: the dashboard LaunchAgent had been up 21 hours holding a snapshot written before the model existed upstream. It showed 3.87M tokens at $0.000000, understating the day by $7.37, for 21 hours, with no signal. Deleting the cache file was incidental — a restart alone would have fixed it, and nothing short of a restart ever will.It recurs on every new model and every upstream price change. The next occurrence is already scheduled: Sonnet 5 intro pricing reverts on 2026-08-31 (see #87), and a dashboard that has been up since August will keep billing the intro rate indefinitely.
2. Unknown model → silent
$0; the provenance is computed and then discardedsrc/lib/pricing/index.js:101-104—lookupPricing()returns{hit, source, value}, butgetModelPricing()throwssourceaway and returns bare numbers. On a miss it negative-caches and returnsZERO_PRICINGwith no log line, no counter, no API field, no UI badge.test/pricing.test.js:265-271tests the miss mechanism but asserts no signal, so the silence is codified.A
$0model is indistinguishable from a genuinely free model on every surface. The maintenance cost is already visible:curated-overrides.jsoncarries hand-written pins forclaude-fable-5,claude-mythos-5, andclaude-opus-4-8precisely because each new frontier model starts life as a silent miss.3. Fuzzy tiers assign plausible-but-wrong prices, also silently
curated-overrides.jsonmaps{"match": "kimi", "ref": "kimi-k2.5"}and{"match": "composer", "ref": "composer-1"}. A futurekimi-k3falls past exact/alias and lands on the k2.5 price. The file already needed akimi-k2.6exact entry to escape that net, which is evidence the net catches real models.src/lib/pricing/matcher.js:192-203matches longest-key-first withlower.includes(keyLower). A hypotheticalgpt-5.5-previewcontains the literal keygpt-5and would price at gpt-5 rates. Minor-version successors are exactly the models most likely to launch at a different price.Silence is defensible for step 5 (suffix-strip:
-high-fastvariants genuinely share base pricing). It is not defensible for steps 4, 5b, and 6, where a mis-hit is invisible forever.Fix (one change closes all three)
The hard part is already done —
lookupPricingcomputes the resolution tier at the exact line wheregetModelPricingdiscards it.result.sourcethroughgetModelPricing(parallelgetModelPricingMeta(), or attach a non-enumerable tier).loadedAton load; when a lookup is about to miss orDate.now() - loadedAt > ttl, fire one backgroundreload()(reuseloadLitellmData, clear the negative cache on completion). Makes "a new model appeared" self-healing within one TTL instead of "until the owner happens to notice$0".console.warnper unknown model per process,unpriced_modelsandfuzzy_priced_modelsarrays in the stats API response, and a dashboard badge on any model row whose tier ismissor*:fuzzy.Optionally, demote step 6 to require a family match (first-token equality) before accepting a substring hit.
Acceptance
Sizing estimate ~150 lines plus tests. Deliberately a separate PR from the small hardening work, because it touches the pricing path that just had a live incident and deserves an isolated diff.
Found during a Fable-model QA review of 0.39.40.