1use crate::model::Usage;
23
24#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct ModelPricing {
27 pub input: f64,
28 pub output: f64,
29 pub cache_read: f64,
30 pub cache_write_5m: f64,
31 pub cache_write_1h: f64,
32}
33
34pub const SNAPSHOT: &[(&str, ModelPricing)] = &[
39 (
40 "claude-fable-5",
41 ModelPricing {
42 input: 10.0,
43 output: 50.0,
44 cache_read: 1.0,
45 cache_write_5m: 12.50,
46 cache_write_1h: 20.0,
47 },
48 ),
49 (
50 "claude-mythos-5",
51 ModelPricing {
52 input: 10.0,
53 output: 50.0,
54 cache_read: 1.0,
55 cache_write_5m: 12.50,
56 cache_write_1h: 20.0,
57 },
58 ),
59 (
60 "claude-sonnet-4-6",
61 ModelPricing {
62 input: 3.0,
63 output: 15.0,
64 cache_read: 0.30,
65 cache_write_5m: 3.75,
66 cache_write_1h: 6.0,
67 },
68 ),
69 (
70 "claude-sonnet-4-5",
71 ModelPricing {
72 input: 3.0,
73 output: 15.0,
74 cache_read: 0.30,
75 cache_write_5m: 3.75,
76 cache_write_1h: 6.0,
77 },
78 ),
79 (
80 "claude-sonnet-4",
81 ModelPricing {
82 input: 3.0,
83 output: 15.0,
84 cache_read: 0.30,
85 cache_write_5m: 3.75,
86 cache_write_1h: 6.0,
87 },
88 ),
89 (
90 "claude-3-7-sonnet",
91 ModelPricing {
92 input: 3.0,
93 output: 15.0,
94 cache_read: 0.30,
95 cache_write_5m: 3.75,
96 cache_write_1h: 6.0,
97 },
98 ),
99 (
100 "claude-opus-4-8",
101 ModelPricing {
102 input: 5.0,
103 output: 25.0,
104 cache_read: 0.50,
105 cache_write_5m: 6.25,
106 cache_write_1h: 10.0,
107 },
108 ),
109 (
110 "claude-opus-4-7",
111 ModelPricing {
112 input: 5.0,
113 output: 25.0,
114 cache_read: 0.50,
115 cache_write_5m: 6.25,
116 cache_write_1h: 10.0,
117 },
118 ),
119 (
120 "claude-opus-4-6",
121 ModelPricing {
122 input: 5.0,
123 output: 25.0,
124 cache_read: 0.50,
125 cache_write_5m: 6.25,
126 cache_write_1h: 10.0,
127 },
128 ),
129 (
130 "claude-opus-4-5",
131 ModelPricing {
132 input: 5.0,
133 output: 25.0,
134 cache_read: 0.50,
135 cache_write_5m: 6.25,
136 cache_write_1h: 10.0,
137 },
138 ),
139 (
140 "claude-opus-4-1",
141 ModelPricing {
142 input: 15.0,
143 output: 75.0,
144 cache_read: 1.50,
145 cache_write_5m: 18.75,
146 cache_write_1h: 30.0,
147 },
148 ),
149 (
150 "claude-opus-4",
151 ModelPricing {
152 input: 15.0,
153 output: 75.0,
154 cache_read: 1.50,
155 cache_write_5m: 18.75,
156 cache_write_1h: 30.0,
157 },
158 ),
159 (
160 "claude-haiku-4-5",
161 ModelPricing {
162 input: 1.0,
163 output: 5.0,
164 cache_read: 0.10,
165 cache_write_5m: 1.25,
166 cache_write_1h: 2.0,
167 },
168 ),
169 (
170 "claude-3-5-haiku",
171 ModelPricing {
172 input: 0.80,
173 output: 4.0,
174 cache_read: 0.08,
175 cache_write_5m: 1.0,
176 cache_write_1h: 1.6,
177 },
178 ),
179];
180
181pub fn lookup(model: &str) -> Option<&'static ModelPricing> {
185 let m = normalize_model(model);
186 SNAPSHOT
187 .iter()
188 .filter(|(key, _)| key_matches(&m, key))
189 .max_by_key(|(key, _)| key.len()) .map(|(_, p)| p)
191}
192
193fn normalize_model(model: &str) -> String {
197 let mut m = model.trim().to_ascii_lowercase();
198 for prefix in ["anthropic/", "us.anthropic.", "anthropic."] {
199 if let Some(rest) = m.strip_prefix(prefix) {
200 m = rest.to_string();
201 break;
202 }
203 }
204 m
205}
206
207fn key_matches(model: &str, key: &str) -> bool {
211 match model.strip_prefix(key) {
212 None => false,
213 Some("") => true,
214 Some(rest) => {
215 (rest.starts_with('-') || rest.starts_with('@'))
216 && rest.len() >= 9
217 && rest[1..].chars().all(|c| c.is_ascii_digit())
218 }
219 }
220}
221
222pub fn cost_usd(model: Option<&str>, usage: &Usage) -> Option<f64> {
226 Some(price_usage(lookup(model?)?, usage))
227}
228
229fn price_usage(p: &ModelPricing, usage: &Usage) -> f64 {
233 let per_m = |tokens: Option<u64>, rate: f64| tokens.unwrap_or(0) as f64 * rate / 1e6;
234
235 let mut cost = per_m(usage.input, p.input)
236 + per_m(usage.output, p.output)
237 + per_m(usage.thinking, p.output)
239 + per_m(usage.cache_read, p.cache_read);
240
241 cost += match (usage.cache_creation_5m, usage.cache_creation_1h) {
244 (None, None) => per_m(usage.cache_creation, p.cache_write_5m),
245 (m5, h1) => per_m(m5, p.cache_write_5m) + per_m(h1, p.cache_write_1h),
246 };
247 cost
248}
249
250#[derive(Debug, Clone, Default)]
256pub struct PricingTable {
257 overrides: Vec<(String, ModelPricing)>,
260}
261
262impl PricingTable {
263 pub fn embedded() -> Self {
265 Self::default()
266 }
267
268 pub fn with_overrides(overrides: Vec<(String, ModelPricing)>) -> Self {
271 let overrides = overrides
272 .into_iter()
273 .map(|(k, p)| (k.trim().to_ascii_lowercase(), p))
274 .collect();
275 Self { overrides }
276 }
277
278 pub fn override_count(&self) -> usize {
280 self.overrides.len()
281 }
282
283 pub fn lookup(&self, model: &str) -> Option<&ModelPricing> {
286 if !self.overrides.is_empty() {
287 let m = normalize_model(model);
288 if let Some((_, p)) = self
289 .overrides
290 .iter()
291 .filter(|(key, _)| key_matches(&m, key))
292 .max_by_key(|(key, _)| key.len())
293 {
294 return Some(p);
295 }
296 }
297 lookup(model)
298 }
299
300 pub fn cost_usd(&self, model: Option<&str>, usage: &Usage) -> Option<f64> {
302 Some(price_usage(self.lookup(model?)?, usage))
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 #[test]
311 fn dated_release_suffixes_match() {
312 assert!(lookup("claude-sonnet-4-5-20250929").is_some());
313 assert!(lookup("claude-haiku-4-5-20251001").is_some());
314 assert!(lookup("anthropic/claude-sonnet-4-5").is_some());
315 assert_eq!(lookup("claude-sonnet-4-5").map(|p| p.input), Some(3.0));
317 }
318
319 #[test]
320 fn unknown_models_are_never_guessed() {
321 assert!(lookup("claude-opus-4-9").is_none()); assert!(lookup("claude-opus-5-0").is_none());
325 assert!(lookup("claude-fable-6").is_none());
326 assert!(lookup("<synthetic>").is_none());
327 assert!(lookup("gpt-yolo").is_none());
328 }
329
330 #[test]
331 fn current_models_from_official_snapshot_are_priced() {
332 assert_eq!(lookup("claude-opus-4-8").map(|p| p.input), Some(5.0));
336 assert_eq!(lookup("claude-opus-4-7").map(|p| p.input), Some(5.0));
337 assert_eq!(lookup("claude-opus-4-6").map(|p| p.input), Some(5.0));
338 assert_eq!(lookup("claude-sonnet-4-6").map(|p| p.output), Some(15.0));
339 assert_eq!(lookup("claude-fable-5").map(|p| p.output), Some(50.0));
340 assert_eq!(lookup("claude-mythos-5").map(|p| p.input), Some(10.0));
341 let fable = lookup("claude-fable-5").unwrap();
343 assert_eq!(fable.cache_read, 1.0);
344 assert_eq!(fable.cache_write_5m, 12.50);
345 assert_eq!(fable.cache_write_1h, 20.0);
346 }
347
348 #[test]
349 fn cache_ttls_price_differently() {
350 let split = Usage {
351 cache_creation: Some(1_000_000),
352 cache_creation_5m: Some(0),
353 cache_creation_1h: Some(1_000_000),
354 ..Usage::default()
355 };
356 let cost = cost_usd(Some("claude-sonnet-4-5"), &split).unwrap();
358 assert!((cost - 6.0).abs() < 1e-9, "got {cost}");
359
360 let unsplit = Usage {
361 cache_creation: Some(1_000_000),
362 ..Usage::default()
363 };
364 let cost = cost_usd(Some("claude-sonnet-4-5"), &unsplit).unwrap();
365 assert!(
366 (cost - 3.75).abs() < 1e-9,
367 "unsplit assumes 5m rate, got {cost}"
368 );
369 }
370
371 #[test]
372 fn cost_traces_to_unit_prices() {
373 let usage = Usage {
374 input: Some(1_000_000),
375 output: Some(1_000_000),
376 cache_read: Some(1_000_000),
377 ..Usage::default()
378 };
379 let cost = cost_usd(Some("claude-haiku-4-5"), &usage).unwrap();
380 assert!((cost - (1.0 + 5.0 + 0.10)).abs() < 1e-9);
381 assert_eq!(cost_usd(None, &usage), None);
382 }
383}