From f825080501dc8842199f908af797b0bcb793beae Mon Sep 17 00:00:00 2001 From: Adriano Koshiyama Date: Sat, 1 Aug 2026 22:14:47 +0100 Subject: [PATCH 1/2] fix(pricing): a missing cache rate prices as a floor, not a silent zero For a model whose table entry omits cache_read_input_token_cost or cache_creation_input_token_cost, cache tokens were multiplied by an unwrap_or(0.0) rate and the result returned as Cost::Known - "unpriced is not free" broken silently, inside the one number the tool exists to report, on the token kind that dominates a coding-agent corpus. The two absent-rate fallbacks lean opposite ways for the same reason. Cache creation bills at the input rate: providers that publish a write rate set it above input, so this understates. Cache reads bill at zero: their real rate sits below input, so an input fallback would overstate. Both understate, so the result is a new Cost::Floor - shown as `>=` in the Cost view, "floor" in --json, and counted into every total's floor marker through Cost::not_fully_priced, which replaces is_unpriced at each aggregation site. A rate listed as 0.0 still prices as zero exactly, because the table said so: only an absent key floors, and only when the usage actually has tokens of that kind. The parse-time filter keeps cache-only entries now instead of dropping them with the embeddings, and PerToken's cache rates are Option so "absent" survives to the pricing decision. The Cost view's floor title reads "not fully priced" rather than "have no price", because a floored model has one. Closes #16. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 14 +++++ docs/guide/costs.md | 14 ++++- src/app.rs | 11 ++-- src/main.rs | 5 +- src/pricing.rs | 133 +++++++++++++++++++++++++++++++++++++------- src/ui/mod.rs | 12 +++- 6 files changed, 159 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7e5880..c475dc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,20 @@ publishing empty notes. ## [Unreleased] +### Fixed + +- **A missing cache rate no longer bills cache tokens at a silent zero.** + (#16) For a model whose price-table entry omits `cache_read_input_token_cost` + or `cache_creation_input_token_cost`, those tokens were priced at zero and + the result presented as exact — breaking "unpriced is not free" inside the + one number the tool exists to report. Cache creation now falls back to the + input rate and cache reads to zero, both of which understate, and the result + is a **floor**: `≥` on the figure, `floor` as the `--json` cost state, and + counted into every total's `≥` marker. A rate listed as `0.0` still prices + as zero exactly, because the table said so — only an *absent* rate floors. + Cache-only table entries also survive parsing now instead of being dropped + with the embeddings. + ## [0.1.0] - 2026-07-28 First release. diff --git a/docs/guide/costs.md b/docs/guide/costs.md index 2c20234..1ea6a1e 100644 --- a/docs/guide/costs.md +++ b/docs/guide/costs.md @@ -86,19 +86,27 @@ config, it is suffixed `est`. |---|---| | `input` | Input rate | | `output` | Output rate | -| `cache_read` | Cache-read rate | -| `cache_creation` | Cache-write rate | +| `cache_read` | Cache-read rate — or zero when the table omits one, and the figure becomes a floor | +| `cache_creation` | Cache-write rate — or the input rate when the table omits one, and the figure becomes a floor | | `reasoning` | Output rate | Cache reads at cache rates and reasoning at output rates is how the providers that distinguish them do it. This is also why the six token counters are never merged before pricing: one blended number cannot be priced correctly. +The two fallbacks lean opposite ways for the same reason: providers that +publish a cache-write rate set it *above* input, and a cache-read rate *below* +it, so input-for-writes and zero-for-reads both understate. A figure that +understates is shown as `≥` — a floor, never passed off as exact, and never a +silent `$0` inside a number that claims to be known. + ## The four ways to be wrong A summary, because these are the ones to check before quoting a figure at anyone: -1. **`≥` on a total** — unpriced models underneath it. The real number is higher. +1. **`≥` on a total** — models underneath it that are not fully priced: absent + from the table, or in it with a cache rate missing. The real number is + higher. 2. **`built-in price table`** — the rates are as old as the binary. Run once online. 3. **`(unattributed)` is large** — the spend is real, but not tied to a diff --git a/src/app.rs b/src/app.rs index 0b908d5..5c8ce01 100644 --- a/src/app.rs +++ b/src/app.rs @@ -420,7 +420,7 @@ impl App { tokens.add(t); let cost = self.prices.cost(model, t); usd += cost.usd(); - if cost.is_unpriced() { + if cost.not_fully_priced() { unpriced += 1; } } @@ -451,7 +451,7 @@ impl App { tokens.add(t); let cost = self.prices.cost(model, t); usd += cost.usd(); - if cost.is_unpriced() { + if cost.not_fully_priced() { unpriced += 1; } } @@ -589,7 +589,10 @@ impl App { /// Models with no price. A total with these under it is a floor, not a figure. pub fn unpriced_models(&self) -> usize { - self.models.iter().filter(|m| m.cost.is_unpriced()).count() + self.models + .iter() + .filter(|m| m.cost.not_fully_priced()) + .count() } /// Spend per tool over the window, biggest first. @@ -620,7 +623,7 @@ impl App { for model in &self.models { let entry = per_model.entry(model.model.clone()).or_default(); entry.0 += model.cost.usd(); - entry.1 += usize::from(model.cost.is_unpriced()); + entry.1 += usize::from(model.cost.not_fully_priced()); } let mut rows: Vec<(String, f64, usize)> = per_model .into_iter() diff --git a/src/main.rs b/src/main.rs index 473a2d7..ff9b678 100644 --- a/src/main.rs +++ b/src/main.rs @@ -346,7 +346,7 @@ fn print_json(scan: &scan::Scan, timings: &scan::Timings, prices: &pricing::Pric total.add(t); let cost = prices.cost(model, t); usd += cost.usd(); - if cost.is_unpriced() { + if cost.not_fully_priced() { unpriced += 1; } } @@ -421,6 +421,9 @@ fn cost_json(cost: &pricing::Cost) -> serde_json::Value { use serde_json::json; match cost { pricing::Cost::Known(usd) => json!({"state": "known", "usd": usd}), + // A floor carries its figure, unlike unpriced: the dollars are real, + // there are just at least that many. + pricing::Cost::Floor(usd) => json!({"state": "floor", "usd": usd}), pricing::Cost::Local => json!({"state": "local", "usd": 0.0}), pricing::Cost::Unpriced => json!({"state": "unpriced", "usd": null}), } diff --git a/src/pricing.rs b/src/pricing.rs index 32540a3..51df09d 100644 --- a/src/pricing.rs +++ b/src/pricing.rs @@ -40,12 +40,16 @@ pub const CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60); const MAX_DOWNLOAD_BYTES: u64 = 32 * 1024 * 1024; /// USD per single token, by kind. +/// +/// The cache rates are `None` when the table omits them, which is not the +/// same as a listed `0.0`: an absent rate prices as a floor, a zero rate +/// prices as zero because the table said so. #[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] pub struct PerToken { pub input: f64, pub output: f64, - pub cache_read: f64, - pub cache_creation: f64, + pub cache_read: Option, + pub cache_creation: Option, } /// What a model's usage cost, and whether we could price it at all. @@ -53,6 +57,9 @@ pub struct PerToken { pub enum Cost { /// Priced from the table. Known(f64), + /// Priced, but the table omitted a rate for a token kind this usage has, + /// so the real figure is at least this. Never presented as exact. + Floor(f64), /// Runs locally, so there is no per-token charge. Local, /// Not in the table — the cost is unknown, which is not the same as zero. @@ -62,14 +69,25 @@ pub enum Cost { impl Cost { pub fn usd(&self) -> f64 { match self { - Cost::Known(v) => *v, + Cost::Known(v) | Cost::Floor(v) => *v, Cost::Local | Cost::Unpriced => 0.0, } } + /// Strictly unknown — a floor is *not* unpriced, its dollars are real. + /// The distinction the tests assert; production paths ask + /// [`Cost::not_fully_priced`] instead, which spans both. + #[cfg_attr(not(test), allow(dead_code))] pub fn is_unpriced(&self) -> bool { matches!(self, Cost::Unpriced) } + + /// Whether a total above this figure is a floor rather than a sum: + /// unpriced contributes an unknown amount, a floor an underestimate. + /// This is the test every `≥` marker keys off. + pub fn not_fully_priced(&self) -> bool { + matches!(self, Cost::Unpriced | Cost::Floor(_)) + } } #[derive(Debug, Clone, Default)] @@ -107,6 +125,16 @@ impl Prices { } /// Cost of one model's tokens. + /// + /// A missing cache rate never bills as a silent zero inside a `Known` + /// figure — that was the one arithmetic lie this tool must not tell, + /// hiding in its headline number. Cache creation falls back to the input + /// rate, which understates it (providers that publish a write rate set it + /// *above* input); cache reads bill at zero, because their real rate is + /// *below* input and a fallback would overstate. Both directions + /// understate, so the result is [`Cost::Floor`], and only when the usage + /// actually has tokens of the rateless kind — a missing rate for tokens + /// that do not exist costs nothing and stays exact. pub fn cost(&self, model: &str, tokens: &crate::ledger::Tokens) -> Cost { if is_local_model(model) { return Cost::Local; @@ -115,15 +143,30 @@ impl Prices { return Cost::Unpriced; }; - Cost::Known( - tokens.input as f64 * price.input - + tokens.output as f64 * price.output - + tokens.cache_read as f64 * price.cache_read - + tokens.cache_creation as f64 * price.cache_creation - // Reasoning tokens are billed as output everywhere that - // distinguishes them. - + tokens.reasoning as f64 * price.output, - ) + let mut floor = false; + let mut rate = |listed: Option, kind_tokens: u64, fallback: f64| match listed { + Some(rate) => rate, + None => { + floor |= kind_tokens > 0; + fallback + } + }; + let cache_read = rate(price.cache_read, tokens.cache_read, 0.0); + let cache_creation = rate(price.cache_creation, tokens.cache_creation, price.input); + + let usd = tokens.input as f64 * price.input + + tokens.output as f64 * price.output + + tokens.cache_read as f64 * cache_read + + tokens.cache_creation as f64 * cache_creation + // Reasoning tokens are billed as output everywhere that + // distinguishes them. + + tokens.reasoning as f64 * price.output; + + if floor { + Cost::Floor(usd) + } else { + Cost::Known(usd) + } } /// Parse LiteLLM's table. @@ -136,16 +179,23 @@ impl Prices { if name == "sample_spec" { continue; } - let num = |key: &str| entry.get(key).and_then(|v| v.as_f64()).unwrap_or(0.0); + let num = |key: &str| entry.get(key).and_then(|v| v.as_f64()); let price = PerToken { - input: num("input_cost_per_token"), - output: num("output_cost_per_token"), + input: num("input_cost_per_token").unwrap_or(0.0), + output: num("output_cost_per_token").unwrap_or(0.0), + // Absent stays absent: a missing cache rate prices as a + // floor, where a listed zero prices as zero. cache_read: num("cache_read_input_token_cost"), cache_creation: num("cache_creation_input_token_cost"), }; // Entries with no cost at all are embeddings, moderation and the // like; keeping them would let a suffix match find a zero price. - if price.input == 0.0 && price.output == 0.0 { + // A cache rate alone is a cost, so a cache-only entry survives. + if price.input == 0.0 + && price.output == 0.0 + && price.cache_read.unwrap_or(0.0) == 0.0 + && price.cache_creation.unwrap_or(0.0) == 0.0 + { continue; } models.insert(name, price); @@ -305,6 +355,9 @@ mod tests { "input_cost_per_token": 1e-06, "output_cost_per_token": 2e-06 }, + "cache-desk": { + "cache_read_input_token_cost": 5e-07 + }, "text-embedding-3-small": { "input_cost_per_token": 0.0, "output_cost_per_token": 0.0 @@ -329,8 +382,12 @@ mod tests { let opus = prices.lookup("claude-opus-4-8").unwrap(); assert_eq!(opus.input, 5e-06); assert_eq!(opus.output, 2.5e-05); - assert_eq!(opus.cache_read, 5e-07); - assert_eq!(opus.cache_creation, 6.25e-06); + assert_eq!(opus.cache_read, Some(5e-07)); + assert_eq!(opus.cache_creation, Some(6.25e-06)); + // Absent keys stay absent rather than becoming a free rate. + let glm = prices.lookup("glm-5.2").unwrap(); + assert_eq!(glm.cache_read, None); + assert_eq!(glm.cache_creation, None); } #[test] @@ -339,7 +396,45 @@ mod tests { assert!(prices.lookup("sample_spec").is_none()); // Otherwise a suffix match could find a free price for a real model. assert!(prices.lookup("text-embedding-3-small").is_none()); - assert_eq!(prices.len(), 2); + assert_eq!(prices.len(), 3); + } + + /// The acceptance test from the issue: cache tokens against an entry with + /// input and output rates only must be neither zero nor exact. + #[test] + fn missing_cache_rates_price_as_a_floor_never_a_silent_zero() { + let prices = table(); + let usage = Tokens { + input: 1_000_000, + cache_read: 1_000_000, + cache_creation: 1_000_000, + ..Default::default() + }; + + let cost = prices.cost("glm-5.2", &usage); + + // Input at its rate, cache creation at the input rate (providers that + // publish a write rate set it above input, so this understates), and + // cache reads at zero (their real rate sits below input, so a + // fallback would overstate). Both directions understate: a floor. + assert_eq!(cost, Cost::Floor(2.0)); + assert!(cost.usd() > 0.0, "not a silent zero"); + assert!(cost.not_fully_priced(), "not presented as exact"); + assert!(!cost.is_unpriced(), "the dollars are real, just a minimum"); + } + + #[test] + fn a_missing_cache_rate_with_no_cache_tokens_stays_exact() { + // The rate for tokens that do not exist costs nothing. + let cost = table().cost("glm-5.2", &tokens(1_000_000, 0, 0)); + assert_eq!(cost, Cost::Known(1.0)); + } + + #[test] + fn a_cache_only_entry_survives_the_parse_and_prices_exactly() { + // Dropping it with the embeddings would make its usage unpriced. + let cost = table().cost("cache-desk", &tokens(0, 0, 1_000_000)); + assert_eq!(cost, Cost::Known(0.5)); } #[test] diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 274bcee..4f10062 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1077,11 +1077,15 @@ fn draw_cost(frame: &mut Frame, area: Rect, app: &App) -> Option { .fold(0.0f64, f64::max) .max(0.000_001); - // The three cost states are visually distinct in both modes: "unpriced" must - // never read as "$0.00". + // The four cost states are visually distinct in both modes: "unpriced" + // must never read as "$0.00", and a floor must never read as exact. let amount_cell = |cost: &Cost| { let (amount, style) = match cost { Cost::Known(usd) => (format_usd(*usd), Style::default().fg(theme::MONEY)), + Cost::Floor(usd) => ( + format!("\u{2265}{}", format_usd(*usd)), + Style::default().fg(theme::WARN), + ), Cost::Local => ("local".to_string(), Style::default().fg(theme::DIM)), Cost::Unpriced => ( "\u{25b2} unpriced".to_string(), @@ -1180,8 +1184,10 @@ fn draw_cost(frame: &mut Frame, area: Rect, app: &App) -> Option { app.scan.usage.window_days ); if unpriced > 0 { + // "not fully priced" rather than "have no price": a model whose cache + // rate the table omits has a price — a floor — and lands here too. title.push_str(&format!( - " \u{b7} \u{25b2} a floor: {unpriced} model(s) have no price" + " \u{b7} \u{25b2} a floor: {unpriced} model(s) not fully priced" )); } if app.prices.is_builtin() { From 9376c3477094c1fb4a497e4a0db29605deb754a5 Mon Sep 17 00:00:00 2001 From: Adriano Koshiyama Date: Sun, 2 Aug 2026 07:54:08 +0100 Subject: [PATCH 2/2] fix: the all-priced card fixture gains cache rates under floor semantics --- src/ui/mod.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 1a49c21..ce08590 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -2187,11 +2187,17 @@ mod tests { /// never both. #[test] fn the_spend_card_qualifies_its_dollars() { + // Cache rates included: the fixture's tokens carry cache reads, and a + // table that omits their rate is not "all priced" — it is a floor. let all_priced = crate::pricing::Prices::parse( r#"{"claude-opus-5": {"input_cost_per_token": 0.000005, - "output_cost_per_token": 0.000025}, + "output_cost_per_token": 0.000025, + "cache_read_input_token_cost": 5e-7, + "cache_creation_input_token_cost": 6e-6}, "gpt-5.6-sol": {"input_cost_per_token": 0.000001, - "output_cost_per_token": 0.000004}}"#, + "output_cost_per_token": 0.000004, + "cache_read_input_token_cost": 1e-7, + "cache_creation_input_token_cost": 2e-6}}"#, ) .expect("a two-model table parses"); let mut app = populated_with(all_priced);