Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions docs/guide/costs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down
5 changes: 4 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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}),
}
Expand Down
133 changes: 114 additions & 19 deletions src/pricing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,26 @@ 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<f64>,
pub cache_creation: Option<f64>,
}

/// What a model's usage cost, and whether we could price it at all.
#[derive(Debug, Clone, Copy, PartialEq)]
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.
Expand All @@ -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)]
Expand Down Expand Up @@ -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;
Expand All @@ -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<f64>, 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.
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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]
Expand All @@ -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]
Expand Down
22 changes: 17 additions & 5 deletions src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1154,11 +1154,15 @@ fn draw_cost(frame: &mut Frame, area: Rect, app: &App) -> Option<Rows> {
.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(),
Expand Down Expand Up @@ -1257,8 +1261,10 @@ fn draw_cost(frame: &mut Frame, area: Rect, app: &App) -> Option<Rows> {
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() {
Expand Down Expand Up @@ -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);
Expand Down