diff --git a/CHANGELOG.md b/CHANGELOG.md index 33fd3cc..e068148 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,17 @@ publishing empty notes. ### 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. - **Codex usage is priced again on current Codex versions.** Newer Codex no longer names the model in `session_meta`; it lives on each turn's `turn_context` record and can change mid-session. The scan now follows those diff --git a/docs/guide/costs.md b/docs/guide/costs.md index d48bd6d..653eb93 100644 --- a/docs/guide/costs.md +++ b/docs/guide/costs.md @@ -89,19 +89,27 @@ than a guess, and a configured entry always beats a detected plan. |---|---| | `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 6b1dab6..432ae80 100644 --- a/src/app.rs +++ b/src/app.rs @@ -472,7 +472,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; } } @@ -504,7 +504,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; } } @@ -642,7 +642,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. @@ -673,7 +676,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 288041b..39c4380 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1154,11 +1154,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(), @@ -1257,8 +1261,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() { @@ -2359,11 +2365,17 @@ mod tests { /// cards narrow each one, and the qualifier must survive whole. #[test] fn the_token_cost_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);