| 1 | //! What a model's tokens cost at list price, for estimating what a |
| 2 | //! harness session cost from the token usage it reports. |
| 3 | //! |
| 4 | //! The prices are Anthropic's first-party list prices in US dollars per |
| 5 | //! million tokens, from the pricing page of Anthropic's API documentation, |
| 6 | //! checked on 2026-10-06. Writing to the prompt cache costs 1.25 times the input price |
| 7 | //! for the 5-minute lifetime and twice the input price for the 1-hour one, |
| 8 | //! on every model. Any other model, another provider's included, is priced |
| 9 | //! as unknown: its cost is not estimated. |
| 10 | //! |
| 11 | //! A session reports its usage as a list of [`ModelUsage`], one per model |
| 12 | //! (and tier) it used, cumulative for the session. [`parse_usage`] reads |
| 13 | //! and checks one as given; [`estimate`] prices it. |
| 14 | |
| 15 | use serde::{Deserialize, Serialize}; |
| 16 | use serde_json::Value; |
| 17 | |
| 18 | /// A model's list prices, in US dollars per million tokens. |
| 19 | #[derive(Clone, Copy, Debug, PartialEq)] |
| 20 | pub struct ModelPrice { |
| 21 | pub input: f64, |
| 22 | pub output: f64, |
| 23 | /// Reading from the prompt cache. |
| 24 | pub cache_read: f64, |
| 25 | } |
| 26 | |
| 27 | impl ModelPrice { |
| 28 | /// Writing to the prompt cache for 5 minutes: 1.25 times input. |
| 29 | pub fn cache_write_5m(&self) -> f64 { |
| 30 | self.input * 1.25 |
| 31 | } |
| 32 | |
| 33 | /// Writing to the prompt cache for 1 hour: twice input. |
| 34 | pub fn cache_write_1h(&self) -> f64 { |
| 35 | self.input * 2.0 |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | const fn price(input: f64, output: f64, cache_read: f64) -> ModelPrice { |
| 40 | ModelPrice { input, output, cache_read } |
| 41 | } |
| 42 | |
| 43 | /// The tier of claude-haiku-5-5 for a request whose prompt (input, cache |
| 44 | /// read and cache write tokens) is over [`LONG_PROMPT_TOKENS`]. |
| 45 | pub const LONG_TIER: &str = "long"; |
| 46 | |
| 47 | /// The prompt size above which claude-haiku-5-5 is priced as [`LONG_TIER`]. |
| 48 | pub const LONG_PROMPT_TOKENS: u64 = 100_000; |
| 49 | |
| 50 | /// Every model priced, by its id: from Anthropic's pricing documentation, |
| 51 | /// checked on 2026-10-06. claude-haiku-5-5 has a second row for its |
| 52 | /// [`LONG_TIER`]. |
| 53 | pub const MODEL_PRICES: &[(&str, Option<&str>, ModelPrice)] = &[ |
| 54 | ("claude-fable-5-1", None, price(10.0, 50.0, 0.25)), |
| 55 | ("claude-mythos-5-1", None, price(10.0, 50.0, 0.25)), |
| 56 | ("claude-fable-5", None, price(10.0, 50.0, 1.00)), |
| 57 | ("claude-mythos-5", None, price(10.0, 50.0, 1.00)), |
| 58 | ("claude-opus-5-5", None, price(4.0, 20.0, 0.20)), |
| 59 | ("claude-opus-5", None, price(5.0, 25.0, 0.50)), |
| 60 | ("claude-opus-4-8", None, price(5.0, 25.0, 0.50)), |
| 61 | ("claude-opus-4-7", None, price(5.0, 25.0, 0.50)), |
| 62 | ("claude-opus-4-6", None, price(5.0, 25.0, 0.50)), |
| 63 | ("claude-sonnet-5-5", None, price(2.0, 10.0, 0.20)), |
| 64 | ("claude-sonnet-5", None, price(2.0, 10.0, 0.20)), |
| 65 | ("claude-sonnet-4-6", None, price(3.0, 15.0, 0.30)), |
| 66 | ("claude-haiku-5-5", None, price(0.10, 0.50, 0.01)), |
| 67 | ("claude-haiku-5-5", Some(LONG_TIER), price(0.50, 2.50, 0.05)), |
| 68 | ("claude-haiku-4-5", None, price(1.0, 5.0, 0.10)), |
| 69 | ]; |
| 70 | |
| 71 | /// A model id as the price table names it: lowercase, without a provider |
| 72 | /// prefix (`anthropic.`, `us.anthropic.`), a context suffix (`[1m]`), a |
| 73 | /// version suffix (`-v1:0`) or a date (`-20251001`). |
| 74 | pub fn model_key(model: &str) -> String { |
| 75 | let mut key = model.trim().to_ascii_lowercase(); |
| 76 | if let Some(at) = key.find("anthropic.") { |
| 77 | key = key[at + "anthropic.".len()..].to_owned(); |
| 78 | } |
| 79 | if let Some(at) = key.find('[') { |
| 80 | key.truncate(at); |
| 81 | } |
| 82 | if let Some(at) = key.find(':') { |
| 83 | key.truncate(at); |
| 84 | } |
| 85 | loop { |
| 86 | let Some((head, tail)) = key.rsplit_once('-') else { break }; |
| 87 | let date = tail.len() == 8 && tail.bytes().all(|b| b.is_ascii_digit()); |
| 88 | let version = tail.len() >= 2 && tail.starts_with('v') && tail[1..].bytes().all(|b| b.is_ascii_digit()); |
| 89 | if !(date || version) { |
| 90 | break; |
| 91 | } |
| 92 | key = head.to_owned(); |
| 93 | } |
| 94 | key |
| 95 | } |
| 96 | |
| 97 | /// A model's list prices for `tier`, or none for a model not in the table. |
| 98 | /// A tier the model has no row for is priced as its base row. |
| 99 | pub fn model_price(model: &str, tier: Option<&str>) -> Option<ModelPrice> { |
| 100 | let key = model_key(model); |
| 101 | let rows = || MODEL_PRICES.iter().filter(|(id, _, _)| *id == key); |
| 102 | rows() |
| 103 | .find(|(_, row_tier, _)| tier.is_some() && *row_tier == tier) |
| 104 | .or_else(|| rows().find(|(_, row_tier, _)| row_tier.is_none())) |
| 105 | .map(|(_, _, price)| *price) |
| 106 | } |
| 107 | |
| 108 | /// What one model (and tier) of a session used, in tokens, as its harness |
| 109 | /// reported it. |
| 110 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 111 | pub struct ModelUsage { |
| 112 | pub model: String, |
| 113 | /// `long` for a claude-haiku-5-5 prompt over 100,000 tokens; none otherwise. |
| 114 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 115 | pub tier: Option<String>, |
| 116 | #[serde(default)] |
| 117 | pub input_tokens: u64, |
| 118 | #[serde(default)] |
| 119 | pub output_tokens: u64, |
| 120 | #[serde(default)] |
| 121 | pub cache_write_5m_tokens: u64, |
| 122 | #[serde(default)] |
| 123 | pub cache_write_1h_tokens: u64, |
| 124 | #[serde(default)] |
| 125 | pub cache_read_tokens: u64, |
| 126 | } |
| 127 | |
| 128 | impl ModelUsage { |
| 129 | /// What it cost at list price in US dollars, or none for a model not |
| 130 | /// in the table. |
| 131 | pub fn cost_usd(&self) -> Option<f64> { |
| 132 | let price = model_price(&self.model, self.tier.as_deref())?; |
| 133 | let tokens = |count: u64, per_million: f64| count as f64 * per_million; |
| 134 | Some( |
| 135 | (tokens(self.input_tokens, price.input) |
| 136 | + tokens(self.output_tokens, price.output) |
| 137 | + tokens(self.cache_write_5m_tokens, price.cache_write_5m()) |
| 138 | + tokens(self.cache_write_1h_tokens, price.cache_write_1h()) |
| 139 | + tokens(self.cache_read_tokens, price.cache_read)) |
| 140 | / 1_000_000.0, |
| 141 | ) |
| 142 | } |
| 143 | |
| 144 | fn tokens(&self) -> u64 { |
| 145 | self.input_tokens |
| 146 | .saturating_add(self.output_tokens) |
| 147 | .saturating_add(self.cache_write_5m_tokens) |
| 148 | .saturating_add(self.cache_write_1h_tokens) |
| 149 | .saturating_add(self.cache_read_tokens) |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | /// A session's usage priced: the sum over the models priced (none when no |
| 154 | /// model is), and whether some of it was for models priced as unknown. |
| 155 | #[derive(Clone, Copy, Debug, Default, PartialEq)] |
| 156 | pub struct CostEstimate { |
| 157 | pub cost_usd: Option<f64>, |
| 158 | pub partial: bool, |
| 159 | } |
| 160 | |
| 161 | /// Prices a session's usage. A model with no tokens counts for nothing. |
| 162 | pub fn estimate(usage: &[ModelUsage]) -> CostEstimate { |
| 163 | let mut out = CostEstimate::default(); |
| 164 | for one in usage.iter().filter(|one| one.tokens() > 0) { |
| 165 | match one.cost_usd() { |
| 166 | Some(cost) => out.cost_usd = Some(out.cost_usd.unwrap_or(0.0) + cost), |
| 167 | None => out.partial = true, |
| 168 | } |
| 169 | } |
| 170 | out |
| 171 | } |
| 172 | |
| 173 | /// The most models one session's usage names. |
| 174 | pub const MAX_USAGE_MODELS: usize = 50; |
| 175 | /// The longest model id and tier kept. |
| 176 | pub const MAX_USAGE_MODEL_CHARS: usize = 100; |
| 177 | pub const MAX_USAGE_TIER_CHARS: usize = 20; |
| 178 | /// The most tokens one count may be: far beyond any session. |
| 179 | pub const MAX_USAGE_TOKENS: u64 = 1_000_000_000_000; |
| 180 | |
| 181 | const COUNTS: [&str; 5] = ["input_tokens", "output_tokens", "cache_write_5m_tokens", "cache_write_1h_tokens", "cache_read_tokens"]; |
| 182 | |
| 183 | /// A session's usage as given (a list of `{ model, tier?, input_tokens, |
| 184 | /// output_tokens, cache_write_5m_tokens, cache_write_1h_tokens, |
| 185 | /// cache_read_tokens }`), checked: at most [`MAX_USAGE_MODELS`] models, |
| 186 | /// each named, every count a whole number from 0 to [`MAX_USAGE_TOKENS`]. |
| 187 | /// The same model and tier given twice are added together. |
| 188 | pub fn parse_usage(value: &Value) -> Result<Vec<ModelUsage>, String> { |
| 189 | let Value::Array(rows) = value else { |
| 190 | return Err("usage is a list of { model, tier, input_tokens, output_tokens, cache_write_5m_tokens, cache_write_1h_tokens, cache_read_tokens }.".to_owned()); |
| 191 | }; |
| 192 | if rows.len() > MAX_USAGE_MODELS { |
| 193 | return Err(format!("usage names at most {MAX_USAGE_MODELS} models.")); |
| 194 | } |
| 195 | let mut out: Vec<ModelUsage> = Vec::new(); |
| 196 | for row in rows { |
| 197 | let Value::Object(fields) = row else { |
| 198 | return Err("Each usage is an object with model and its token counts.".to_owned()); |
| 199 | }; |
| 200 | let model = fields.get("model").and_then(Value::as_str).map(str::trim).unwrap_or_default(); |
| 201 | if model.is_empty() || model.chars().count() > MAX_USAGE_MODEL_CHARS { |
| 202 | return Err(format!("Each usage names its model, in up to {MAX_USAGE_MODEL_CHARS} characters.")); |
| 203 | } |
| 204 | let tier = match fields.get("tier") { |
| 205 | None | Some(Value::Null) => None, |
| 206 | Some(Value::String(tier)) if tier.trim().is_empty() => None, |
| 207 | Some(Value::String(tier)) if tier.trim().chars().count() <= MAX_USAGE_TIER_CHARS => Some(tier.trim().to_owned()), |
| 208 | Some(_) => return Err(format!("A usage's tier is text, up to {MAX_USAGE_TIER_CHARS} characters.")), |
| 209 | }; |
| 210 | let mut counts = [0u64; 5]; |
| 211 | for (count, name) in counts.iter_mut().zip(COUNTS) { |
| 212 | *count = match fields.get(name) { |
| 213 | None | Some(Value::Null) => 0, |
| 214 | Some(value) => match value.as_u64() { |
| 215 | Some(n) if n <= MAX_USAGE_TOKENS => n, |
| 216 | _ => return Err(format!("{name} is a whole number of tokens from 0 to {MAX_USAGE_TOKENS}, not {value}.")), |
| 217 | }, |
| 218 | }; |
| 219 | } |
| 220 | let [input_tokens, output_tokens, cache_write_5m_tokens, cache_write_1h_tokens, cache_read_tokens] = counts; |
| 221 | let one = ModelUsage { model: model.to_owned(), tier, input_tokens, output_tokens, cache_write_5m_tokens, cache_write_1h_tokens, cache_read_tokens }; |
| 222 | match out.iter_mut().find(|known| known.model == one.model && known.tier == one.tier) { |
| 223 | Some(known) => { |
| 224 | known.input_tokens = known.input_tokens.saturating_add(one.input_tokens); |
| 225 | known.output_tokens = known.output_tokens.saturating_add(one.output_tokens); |
| 226 | known.cache_write_5m_tokens = known.cache_write_5m_tokens.saturating_add(one.cache_write_5m_tokens); |
| 227 | known.cache_write_1h_tokens = known.cache_write_1h_tokens.saturating_add(one.cache_write_1h_tokens); |
| 228 | known.cache_read_tokens = known.cache_read_tokens.saturating_add(one.cache_read_tokens); |
| 229 | } |
| 230 | None => out.push(one), |
| 231 | } |
| 232 | } |
| 233 | Ok(out) |
| 234 | } |
| 235 | |
| 236 | #[cfg(test)] |
| 237 | mod tests { |
| 238 | use super::*; |
| 239 | use serde_json::json; |
| 240 | |
| 241 | fn close(a: f64, b: f64) -> bool { |
| 242 | (a - b).abs() < 1e-9 |
| 243 | } |
| 244 | |
| 245 | #[test] |
| 246 | fn the_table_pins_list_prices() { |
| 247 | assert_eq!(model_price("claude-opus-5-5", None), Some(price(4.0, 20.0, 0.20))); |
| 248 | assert_eq!(model_price("claude-fable-5-1", None), Some(price(10.0, 50.0, 0.25))); |
| 249 | assert_eq!(model_price("claude-fable-5", None), Some(price(10.0, 50.0, 1.00))); |
| 250 | assert_eq!(model_price("claude-sonnet-4-6", None), Some(price(3.0, 15.0, 0.30))); |
| 251 | assert_eq!(model_price("claude-haiku-5-5", None), Some(price(0.10, 0.50, 0.01))); |
| 252 | assert_eq!(model_price("claude-haiku-5-5", Some("long")), Some(price(0.50, 2.50, 0.05))); |
| 253 | assert_eq!(model_price("claude-haiku-4-5", None), Some(price(1.0, 5.0, 0.10))); |
| 254 | let opus = model_price("claude-opus-4-7", None).unwrap(); |
| 255 | assert!(close(opus.cache_write_5m(), 6.25) && close(opus.cache_write_1h(), 10.0)); |
| 256 | } |
| 257 | |
| 258 | #[test] |
| 259 | fn a_model_is_found_without_its_date_context_or_provider() { |
| 260 | assert_eq!(model_key("claude-haiku-4-5-20251001"), "claude-haiku-4-5"); |
| 261 | assert_eq!(model_key("claude-opus-5-5[1m]"), "claude-opus-5-5"); |
| 262 | assert_eq!(model_key("us.anthropic.claude-sonnet-4-6-20260101-v1:0"), "claude-sonnet-4-6"); |
| 263 | assert_eq!(model_key("anthropic.claude-opus-4-8"), "claude-opus-4-8"); |
| 264 | assert!(model_price("Claude-Opus-5-5[1M]", None).is_some()); |
| 265 | // A tier a model has no row for is its base price. |
| 266 | assert_eq!(model_price("claude-opus-5-5", Some("long")), model_price("claude-opus-5-5", None)); |
| 267 | } |
| 268 | |
| 269 | #[test] |
| 270 | fn other_models_are_priced_as_unknown() { |
| 271 | for model in ["gpt-5.1-codex", "claude-opus-3", "o3", "<synthetic>", "claude-opus-5-5x"] { |
| 272 | assert_eq!(model_price(model, None), None, "{model}"); |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | #[test] |
| 277 | fn a_sessions_cost_is_the_sum_of_the_models_priced() { |
| 278 | let usage = vec![ |
| 279 | ModelUsage { model: "claude-opus-5-5".into(), input_tokens: 1_000_000, output_tokens: 100_000, cache_write_5m_tokens: 200_000, cache_write_1h_tokens: 100_000, cache_read_tokens: 10_000_000, ..Default::default() }, |
| 280 | ModelUsage { model: "claude-haiku-5-5".into(), tier: Some("long".into()), input_tokens: 2_000_000, ..Default::default() }, |
| 281 | ]; |
| 282 | // Opus 5.5: 4 + 2 + 1 (5m: 0.2M × 5) + 0.8 (1h: 0.1M × 8) + 2 = 9.8; Haiku 5.5 long: 2M × 0.5 = 1. |
| 283 | let priced = estimate(&usage); |
| 284 | assert!(close(priced.cost_usd.unwrap(), 10.8), "{priced:?}"); |
| 285 | assert!(!priced.partial); |
| 286 | let mixed = [usage.clone(), vec![ModelUsage { model: "gpt-5".into(), input_tokens: 5, ..Default::default() }]].concat(); |
| 287 | let priced = estimate(&mixed); |
| 288 | assert!(close(priced.cost_usd.unwrap(), 10.8) && priced.partial); |
| 289 | let unknown = estimate(&[ModelUsage { model: "gpt-5".into(), output_tokens: 1, ..Default::default() }]); |
| 290 | assert_eq!(unknown, CostEstimate { cost_usd: None, partial: true }); |
| 291 | assert_eq!(estimate(&[ModelUsage { model: "gpt-5".into(), ..Default::default() }]), CostEstimate::default()); |
| 292 | } |
| 293 | |
| 294 | #[test] |
| 295 | fn usage_is_checked_as_given() { |
| 296 | let usage = parse_usage(&json!([ |
| 297 | { "model": "claude-opus-5-5", "input_tokens": 3, "output_tokens": 4 }, |
| 298 | { "model": "claude-opus-5-5", "input_tokens": 1, "tier": null }, |
| 299 | { "model": "claude-haiku-5-5", "tier": "long", "cache_read_tokens": 9 }, |
| 300 | ])) |
| 301 | .unwrap(); |
| 302 | assert_eq!(usage.len(), 2); |
| 303 | assert_eq!(usage[0].input_tokens, 4); |
| 304 | assert_eq!(usage[1].tier.as_deref(), Some("long")); |
| 305 | assert!(parse_usage(&json!({})).is_err()); |
| 306 | assert!(parse_usage(&json!([{ "input_tokens": 1 }])).is_err()); |
| 307 | assert!(parse_usage(&json!([{ "model": "m", "input_tokens": -1 }])).is_err()); |
| 308 | assert!(parse_usage(&json!([{ "model": "m", "input_tokens": 1.5 }])).is_err()); |
| 309 | assert!(parse_usage(&json!([{ "model": "m", "input_tokens": "7" }])).is_err()); |
| 310 | assert!(parse_usage(&json!([{ "model": "m", "output_tokens": MAX_USAGE_TOKENS + 1 }])).is_err()); |
| 311 | assert!(parse_usage(&json!([{ "model": "x".repeat(101) }])).is_err()); |
| 312 | let many: Vec<Value> = (0..51).map(|i| json!({ "model": format!("m{i}") })).collect(); |
| 313 | assert!(parse_usage(&Value::Array(many)).is_err()); |
| 314 | assert_eq!(parse_usage(&json!([])).unwrap(), vec![]); |
| 315 | } |
| 316 | } |