1use std::collections::HashMap;
2
3use chrono::NaiveDate;
4
5use crate::data::models::TokenUsage;
6
7pub const PRICING_FETCH_DATE: &str = "2026-03-21";
9pub const PRICING_SOURCE: &str = "platform.claude.com/docs/en/about-claude/pricing";
11
12pub const LATEST_FALLBACK_MODEL: &str = "claude-opus-4-8";
15
16#[derive(Debug, Clone)]
20pub struct ModelPrice {
21 pub base_input: f64,
23 pub cache_write_5m: f64,
25 pub cache_write_1h: f64,
27 pub cache_read: f64,
29 pub output: f64,
31}
32
33#[derive(Debug, Clone)]
35pub struct CostBreakdown {
36 pub input_cost: f64,
37 pub cache_write_5m_cost: f64,
38 pub cache_write_1h_cost: f64,
39 pub cache_read_cost: f64,
40 pub output_cost: f64,
41 pub total: f64,
42 pub price_source: PriceSource,
43}
44
45#[derive(Debug, Clone, PartialEq)]
47pub enum PriceSource {
48 Builtin,
50 Config,
52 Fallback {
56 requested: String,
57 fallback_to: String,
58 },
59 Unknown,
61}
62
63fn builtin_prices() -> HashMap<String, ModelPrice> {
66 let entries: Vec<(&str, ModelPrice)> = vec![
67 (
68 "claude-opus-4-8",
69 ModelPrice {
70 base_input: 5.0,
71 cache_write_5m: 6.25,
72 cache_write_1h: 10.0,
73 cache_read: 0.50,
74 output: 25.0,
75 },
76 ),
77 (
78 "claude-opus-4-7",
79 ModelPrice {
80 base_input: 5.0,
81 cache_write_5m: 6.25,
82 cache_write_1h: 10.0,
83 cache_read: 0.50,
84 output: 25.0,
85 },
86 ),
87 (
88 "claude-opus-4-6",
89 ModelPrice {
90 base_input: 5.0,
91 cache_write_5m: 6.25,
92 cache_write_1h: 10.0,
93 cache_read: 0.50,
94 output: 25.0,
95 },
96 ),
97 (
98 "claude-opus-4-5",
99 ModelPrice {
100 base_input: 5.0,
101 cache_write_5m: 6.25,
102 cache_write_1h: 10.0,
103 cache_read: 0.50,
104 output: 25.0,
105 },
106 ),
107 (
108 "claude-opus-4-1",
109 ModelPrice {
110 base_input: 15.0,
111 cache_write_5m: 18.75,
112 cache_write_1h: 30.0,
113 cache_read: 1.50,
114 output: 75.0,
115 },
116 ),
117 (
118 "claude-opus-4",
119 ModelPrice {
120 base_input: 15.0,
121 cache_write_5m: 18.75,
122 cache_write_1h: 30.0,
123 cache_read: 1.50,
124 output: 75.0,
125 },
126 ),
127 (
128 "claude-sonnet-4-6",
129 ModelPrice {
130 base_input: 3.0,
131 cache_write_5m: 3.75,
132 cache_write_1h: 6.0,
133 cache_read: 0.30,
134 output: 15.0,
135 },
136 ),
137 (
138 "claude-sonnet-4-5",
139 ModelPrice {
140 base_input: 3.0,
141 cache_write_5m: 3.75,
142 cache_write_1h: 6.0,
143 cache_read: 0.30,
144 output: 15.0,
145 },
146 ),
147 (
148 "claude-sonnet-4",
149 ModelPrice {
150 base_input: 3.0,
151 cache_write_5m: 3.75,
152 cache_write_1h: 6.0,
153 cache_read: 0.30,
154 output: 15.0,
155 },
156 ),
157 (
158 "claude-haiku-4-5",
159 ModelPrice {
160 base_input: 1.0,
161 cache_write_5m: 1.25,
162 cache_write_1h: 2.0,
163 cache_read: 0.10,
164 output: 5.0,
165 },
166 ),
167 (
168 "claude-haiku-3-5",
169 ModelPrice {
170 base_input: 0.80,
171 cache_write_5m: 1.0,
172 cache_write_1h: 1.60,
173 cache_read: 0.08,
174 output: 4.0,
175 },
176 ),
177 (
178 "claude-3-haiku",
179 ModelPrice {
180 base_input: 0.25,
181 cache_write_5m: 0.30,
182 cache_write_1h: 0.50,
183 cache_read: 0.03,
184 output: 1.25,
185 },
186 ),
187 ];
188
189 entries
190 .into_iter()
191 .map(|(k, v)| (k.to_string(), v))
192 .collect()
193}
194
195pub struct PricingCalculator {
199 prices: HashMap<String, ModelPrice>,
200 overrides: HashMap<String, ModelPrice>,
201}
202
203impl Default for PricingCalculator {
204 fn default() -> Self {
205 Self::new()
206 }
207}
208
209impl PricingCalculator {
210 pub fn new() -> Self {
212 Self {
213 prices: builtin_prices(),
214 overrides: HashMap::new(),
215 }
216 }
217
218 pub fn with_overrides(mut self, overrides: HashMap<String, ModelPrice>) -> Self {
220 self.overrides = overrides;
221 self
222 }
223
224 pub fn get_price(&self, model: &str) -> Option<(&ModelPrice, PriceSource)> {
233 let model = match model.split_once('[') {
243 Some((base, rest)) if rest.ends_with(']') => base,
244 _ => model,
245 };
246
247 if let Some(p) = self.overrides.get(model) {
249 return Some((p, PriceSource::Config));
250 }
251 if let Some(p) = Self::prefix_lookup(&self.overrides, model) {
253 return Some((p, PriceSource::Config));
254 }
255 if let Some(p) = self.prices.get(model) {
257 return Some((p, PriceSource::Builtin));
258 }
259 if let Some(p) = Self::prefix_lookup(&self.prices, model) {
261 return Some((p, PriceSource::Builtin));
262 }
263 if let Some((fallback_key, fallback_price)) = self.latest_builtin_claude() {
266 return Some((
267 fallback_price,
268 PriceSource::Fallback {
269 requested: model.to_string(),
270 fallback_to: fallback_key.to_string(),
271 },
272 ));
273 }
274 None
275 }
276
277 fn prefix_lookup<'a>(
279 map: &'a HashMap<String, ModelPrice>,
280 model: &str,
281 ) -> Option<&'a ModelPrice> {
282 map.iter()
283 .filter(|(key, _)| model.starts_with(key.as_str()))
284 .max_by_key(|(key, _)| key.len())
285 .map(|(_, v)| v)
286 }
287
288 fn latest_builtin_claude(&self) -> Option<(&str, &ModelPrice)> {
293 self.prices
294 .get_key_value(LATEST_FALLBACK_MODEL)
295 .map(|(k, v)| (k.as_str(), v))
296 }
297
298 pub fn calculate_turn_cost(&self, model: &str, usage: &TokenUsage) -> CostBreakdown {
300 let (price, source) = match self.get_price(model) {
301 Some((p, s)) => (p, s),
302 None => {
303 return CostBreakdown {
304 input_cost: 0.0,
305 cache_write_5m_cost: 0.0,
306 cache_write_1h_cost: 0.0,
307 cache_read_cost: 0.0,
308 output_cost: 0.0,
309 total: 0.0,
310 price_source: PriceSource::Unknown,
311 };
312 }
313 };
314
315 let input_mtok = usage.input_tokens.unwrap_or(0) as f64 / 1_000_000.0;
316 let output_mtok = usage.output_tokens.unwrap_or(0) as f64 / 1_000_000.0;
317 let cache_read_mtok = usage.cache_read_input_tokens.unwrap_or(0) as f64 / 1_000_000.0;
318
319 let (cw_5m, cw_1h) = match &usage.cache_creation {
321 Some(detail) => (
322 detail.ephemeral_5m_input_tokens.unwrap_or(0) as f64 / 1_000_000.0,
323 detail.ephemeral_1h_input_tokens.unwrap_or(0) as f64 / 1_000_000.0,
324 ),
325 None => {
326 let total_cw = usage.cache_creation_input_tokens.unwrap_or(0) as f64 / 1_000_000.0;
328 (total_cw, 0.0)
329 }
330 };
331
332 let input_cost = input_mtok * price.base_input;
333 let cache_write_5m_cost = cw_5m * price.cache_write_5m;
334 let cache_write_1h_cost = cw_1h * price.cache_write_1h;
335 let cache_read_cost = cache_read_mtok * price.cache_read;
336 let output_cost = output_mtok * price.output;
337
338 let total =
339 input_cost + cache_write_5m_cost + cache_write_1h_cost + cache_read_cost + output_cost;
340
341 CostBreakdown {
342 input_cost,
343 cache_write_5m_cost,
344 cache_write_1h_cost,
345 cache_read_cost,
346 output_cost,
347 total,
348 price_source: source,
349 }
350 }
351
352 pub fn pricing_age_days() -> i64 {
354 let fetch_date =
355 NaiveDate::parse_from_str(PRICING_FETCH_DATE, "%Y-%m-%d").expect("valid date constant");
356 let today = chrono::Utc::now().date_naive();
357 (today - fetch_date).num_days()
358 }
359
360 pub fn is_pricing_stale() -> bool {
362 Self::pricing_age_days() > 90
363 }
364}
365
366#[cfg(test)]
369mod tests {
370 use super::*;
371 use crate::data::models::{CacheCreationDetail, TokenUsage};
372
373 fn make_usage(
375 input: u64,
376 output: u64,
377 cache_create: u64,
378 cache_read: u64,
379 cw_5m: u64,
380 cw_1h: u64,
381 ) -> TokenUsage {
382 let cache_creation = if cw_5m > 0 || cw_1h > 0 {
383 Some(CacheCreationDetail {
384 ephemeral_5m_input_tokens: Some(cw_5m),
385 ephemeral_1h_input_tokens: Some(cw_1h),
386 })
387 } else {
388 None
389 };
390
391 TokenUsage {
392 input_tokens: Some(input),
393 output_tokens: Some(output),
394 cache_creation_input_tokens: Some(cache_create),
395 cache_read_input_tokens: Some(cache_read),
396 cache_creation,
397 server_tool_use: None,
398 service_tier: None,
399 speed: None,
400 inference_geo: None,
401 }
402 }
403
404 #[test]
405 fn opus_46_pricing() {
406 let calc = PricingCalculator::new();
407 let usage = make_usage(1_000_000, 1_000_000, 1_000_000, 1_000_000, 1_000_000, 0);
409 let cost = calc.calculate_turn_cost("claude-opus-4-6", &usage);
410
411 assert!(
412 (cost.input_cost - 5.0).abs() < 1e-9,
413 "input_cost: {}",
414 cost.input_cost
415 );
416 assert!(
417 (cost.cache_write_5m_cost - 6.25).abs() < 1e-9,
418 "cache_write_5m_cost: {}",
419 cost.cache_write_5m_cost
420 );
421 assert!(
422 (cost.cache_write_1h_cost - 0.0).abs() < 1e-9,
423 "cache_write_1h_cost: {}",
424 cost.cache_write_1h_cost
425 );
426 assert!(
427 (cost.cache_read_cost - 0.50).abs() < 1e-9,
428 "cache_read_cost: {}",
429 cost.cache_read_cost
430 );
431 assert!(
432 (cost.output_cost - 25.0).abs() < 1e-9,
433 "output_cost: {}",
434 cost.output_cost
435 );
436 assert!((cost.total - 36.75).abs() < 1e-9, "total: {}", cost.total);
437 assert_eq!(cost.price_source, PriceSource::Builtin);
438 }
439
440 #[test]
441 fn distinguishes_5m_and_1h_cache() {
442 let calc = PricingCalculator::new();
443 let usage = make_usage(0, 0, 1_000_000, 0, 500_000, 500_000);
445 let cost = calc.calculate_turn_cost("claude-opus-4-6", &usage);
446
447 assert!(
449 (cost.cache_write_5m_cost - 3.125).abs() < 1e-9,
450 "cache_write_5m_cost: {}",
451 cost.cache_write_5m_cost
452 );
453 assert!(
455 (cost.cache_write_1h_cost - 5.0).abs() < 1e-9,
456 "cache_write_1h_cost: {}",
457 cost.cache_write_1h_cost
458 );
459 assert!((cost.total - 8.125).abs() < 1e-9, "total: {}", cost.total);
460 }
461
462 #[test]
463 fn prefix_matching() {
464 let calc = PricingCalculator::new();
465 let usage = make_usage(1_000_000, 0, 0, 0, 0, 0);
466 let cost = calc.calculate_turn_cost("claude-opus-4-5-20251101", &usage);
467
468 assert!(
470 (cost.input_cost - 5.0).abs() < 1e-9,
471 "input_cost: {}",
472 cost.input_cost
473 );
474 assert_eq!(cost.price_source, PriceSource::Builtin);
475 }
476
477 #[test]
478 fn unknown_model_zero() {
479 let calc = PricingCalculator {
483 prices: HashMap::new(),
484 overrides: HashMap::new(),
485 };
486 let usage = make_usage(1_000_000, 1_000_000, 1_000_000, 1_000_000, 1_000_000, 0);
487 let cost = calc.calculate_turn_cost("gpt-99-turbo", &usage);
488
489 assert!((cost.total - 0.0).abs() < 1e-9, "total: {}", cost.total);
490 assert_eq!(cost.price_source, PriceSource::Unknown);
491 }
492
493 #[test]
494 fn config_override_priority() {
495 let mut overrides = HashMap::new();
496 overrides.insert(
497 "claude-opus-4-6".to_string(),
498 ModelPrice {
499 base_input: 99.0,
500 cache_write_5m: 0.0,
501 cache_write_1h: 0.0,
502 cache_read: 0.0,
503 output: 0.0,
504 },
505 );
506
507 let calc = PricingCalculator::new().with_overrides(overrides);
508 let usage = make_usage(1_000_000, 0, 0, 0, 0, 0);
509 let cost = calc.calculate_turn_cost("claude-opus-4-6", &usage);
510
511 assert!(
512 (cost.input_cost - 99.0).abs() < 1e-9,
513 "input_cost: {}",
514 cost.input_cost
515 );
516 assert_eq!(cost.price_source, PriceSource::Config);
517 }
518
519 #[test]
524 fn opus_4_7_uses_opus_4_6_pricing() {
525 let calc = PricingCalculator::new();
526 let usage = make_usage(1_000_000, 1_000_000, 1_000_000, 1_000_000, 1_000_000, 0);
527 let cost = calc.calculate_turn_cost("claude-opus-4-7", &usage);
528
529 assert!(
531 (cost.input_cost - 5.0).abs() < 1e-9,
532 "input_cost: {}",
533 cost.input_cost
534 );
535 assert!(
536 (cost.output_cost - 25.0).abs() < 1e-9,
537 "output_cost: {}",
538 cost.output_cost
539 );
540 assert!(
541 (cost.cache_write_5m_cost - 6.25).abs() < 1e-9,
542 "cache_write_5m_cost: {}",
543 cost.cache_write_5m_cost
544 );
545 assert!(
546 (cost.cache_read_cost - 0.50).abs() < 1e-9,
547 "cache_read_cost: {}",
548 cost.cache_read_cost
549 );
550 assert!((cost.total - 36.75).abs() < 1e-9, "total: {}", cost.total);
551 assert_eq!(cost.price_source, PriceSource::Builtin);
552 }
553
554 #[test]
559 fn opus_4_8_uses_opus_generation_pricing_not_opus_4() {
560 let calc = PricingCalculator::new();
561 let usage = make_usage(1_000_000, 1_000_000, 1_000_000, 1_000_000, 1_000_000, 0);
562
563 for model in [
564 "claude-opus-4-8",
565 "claude-opus-4-8[1m]",
566 "claude-opus-4-8[200k]",
567 ] {
568 let cost = calc.calculate_turn_cost(model, &usage);
569 assert!(
571 (cost.input_cost - 5.0).abs() < 1e-9,
572 "{model} input_cost: {} (must be opus-gen $5, not opus-4 $15)",
573 cost.input_cost
574 );
575 assert!(
576 (cost.output_cost - 25.0).abs() < 1e-9,
577 "{model} output_cost: {} (must be opus-gen $25, not opus-4 $75)",
578 cost.output_cost
579 );
580 assert!(
581 (cost.total - 36.75).abs() < 1e-9,
582 "{model} total: {} (must be 36.75, not opus-4's 110.25)",
583 cost.total
584 );
585 assert_eq!(
586 cost.price_source,
587 PriceSource::Builtin,
588 "{model} must resolve to a builtin entry, not a fallback"
589 );
590 }
591 }
592
593 #[test]
600 fn unknown_model_falls_back_to_latest_with_warning() {
601 let calc = PricingCalculator::new();
602 let usage = make_usage(1_000_000, 1_000_000, 0, 0, 0, 0);
603 let cost = calc.calculate_turn_cost("claude-future-x-1", &usage);
604
605 assert!((cost.total - 30.0).abs() < 1e-9, "total: {}", cost.total);
607 match cost.price_source {
608 PriceSource::Fallback {
609 ref requested,
610 ref fallback_to,
611 } => {
612 assert_eq!(requested, "claude-future-x-1");
613 assert_eq!(fallback_to, LATEST_FALLBACK_MODEL);
614 }
615 other => panic!("expected PriceSource::Fallback, got {:?}", other),
616 }
617 }
618
619 #[test]
623 fn fallback_model_must_exist_in_builtin() {
624 let calc = PricingCalculator::new();
625 assert!(
626 calc.prices.contains_key(LATEST_FALLBACK_MODEL),
627 "LATEST_FALLBACK_MODEL ({}) must exist in builtin_prices()",
628 LATEST_FALLBACK_MODEL
629 );
630 assert!(calc.latest_builtin_claude().is_some());
631 }
632
633 #[test]
636 fn cost_breakdown_carries_source() {
637 let calc = PricingCalculator::new();
638 let usage = make_usage(1_000_000, 0, 0, 0, 0, 0);
639
640 let builtin = calc.calculate_turn_cost("claude-opus-4-6", &usage);
641 assert_eq!(builtin.price_source, PriceSource::Builtin);
642
643 let fallback = calc.calculate_turn_cost("claude-future-x-1", &usage);
644 assert!(matches!(
645 fallback.price_source,
646 PriceSource::Fallback { .. }
647 ));
648
649 let mut overrides = HashMap::new();
650 overrides.insert(
651 "claude-opus-4-6".to_string(),
652 ModelPrice {
653 base_input: 1.0,
654 cache_write_5m: 0.0,
655 cache_write_1h: 0.0,
656 cache_read: 0.0,
657 output: 0.0,
658 },
659 );
660 let calc_with_override = PricingCalculator::new().with_overrides(overrides);
661 let config = calc_with_override.calculate_turn_cost("claude-opus-4-6", &usage);
662 assert_eq!(config.price_source, PriceSource::Config);
663 }
664}