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-7";
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-7",
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-6",
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-5",
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-1",
99 ModelPrice {
100 base_input: 15.0,
101 cache_write_5m: 18.75,
102 cache_write_1h: 30.0,
103 cache_read: 1.50,
104 output: 75.0,
105 },
106 ),
107 (
108 "claude-opus-4",
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-sonnet-4-6",
119 ModelPrice {
120 base_input: 3.0,
121 cache_write_5m: 3.75,
122 cache_write_1h: 6.0,
123 cache_read: 0.30,
124 output: 15.0,
125 },
126 ),
127 (
128 "claude-sonnet-4-5",
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",
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-haiku-4-5",
149 ModelPrice {
150 base_input: 1.0,
151 cache_write_5m: 1.25,
152 cache_write_1h: 2.0,
153 cache_read: 0.10,
154 output: 5.0,
155 },
156 ),
157 (
158 "claude-haiku-3-5",
159 ModelPrice {
160 base_input: 0.80,
161 cache_write_5m: 1.0,
162 cache_write_1h: 1.60,
163 cache_read: 0.08,
164 output: 4.0,
165 },
166 ),
167 (
168 "claude-3-haiku",
169 ModelPrice {
170 base_input: 0.25,
171 cache_write_5m: 0.30,
172 cache_write_1h: 0.50,
173 cache_read: 0.03,
174 output: 1.25,
175 },
176 ),
177 ];
178
179 entries
180 .into_iter()
181 .map(|(k, v)| (k.to_string(), v))
182 .collect()
183}
184
185pub struct PricingCalculator {
189 prices: HashMap<String, ModelPrice>,
190 overrides: HashMap<String, ModelPrice>,
191}
192
193impl Default for PricingCalculator {
194 fn default() -> Self {
195 Self::new()
196 }
197}
198
199impl PricingCalculator {
200 pub fn new() -> Self {
202 Self {
203 prices: builtin_prices(),
204 overrides: HashMap::new(),
205 }
206 }
207
208 pub fn with_overrides(mut self, overrides: HashMap<String, ModelPrice>) -> Self {
210 self.overrides = overrides;
211 self
212 }
213
214 pub fn get_price(&self, model: &str) -> Option<(&ModelPrice, PriceSource)> {
223 if let Some(p) = self.overrides.get(model) {
225 return Some((p, PriceSource::Config));
226 }
227 if let Some(p) = Self::prefix_lookup(&self.overrides, model) {
229 return Some((p, PriceSource::Config));
230 }
231 if let Some(p) = self.prices.get(model) {
233 return Some((p, PriceSource::Builtin));
234 }
235 if let Some(p) = Self::prefix_lookup(&self.prices, model) {
237 return Some((p, PriceSource::Builtin));
238 }
239 if let Some((fallback_key, fallback_price)) = self.latest_builtin_claude() {
242 return Some((
243 fallback_price,
244 PriceSource::Fallback {
245 requested: model.to_string(),
246 fallback_to: fallback_key.to_string(),
247 },
248 ));
249 }
250 None
251 }
252
253 fn prefix_lookup<'a>(
255 map: &'a HashMap<String, ModelPrice>,
256 model: &str,
257 ) -> Option<&'a ModelPrice> {
258 map.iter()
259 .filter(|(key, _)| model.starts_with(key.as_str()))
260 .max_by_key(|(key, _)| key.len())
261 .map(|(_, v)| v)
262 }
263
264 fn latest_builtin_claude(&self) -> Option<(&str, &ModelPrice)> {
269 self.prices
270 .get_key_value(LATEST_FALLBACK_MODEL)
271 .map(|(k, v)| (k.as_str(), v))
272 }
273
274 pub fn calculate_turn_cost(&self, model: &str, usage: &TokenUsage) -> CostBreakdown {
276 let (price, source) = match self.get_price(model) {
277 Some((p, s)) => (p, s),
278 None => {
279 return CostBreakdown {
280 input_cost: 0.0,
281 cache_write_5m_cost: 0.0,
282 cache_write_1h_cost: 0.0,
283 cache_read_cost: 0.0,
284 output_cost: 0.0,
285 total: 0.0,
286 price_source: PriceSource::Unknown,
287 };
288 }
289 };
290
291 let input_mtok = usage.input_tokens.unwrap_or(0) as f64 / 1_000_000.0;
292 let output_mtok = usage.output_tokens.unwrap_or(0) as f64 / 1_000_000.0;
293 let cache_read_mtok = usage.cache_read_input_tokens.unwrap_or(0) as f64 / 1_000_000.0;
294
295 let (cw_5m, cw_1h) = match &usage.cache_creation {
297 Some(detail) => (
298 detail.ephemeral_5m_input_tokens.unwrap_or(0) as f64 / 1_000_000.0,
299 detail.ephemeral_1h_input_tokens.unwrap_or(0) as f64 / 1_000_000.0,
300 ),
301 None => {
302 let total_cw = usage.cache_creation_input_tokens.unwrap_or(0) as f64 / 1_000_000.0;
304 (total_cw, 0.0)
305 }
306 };
307
308 let input_cost = input_mtok * price.base_input;
309 let cache_write_5m_cost = cw_5m * price.cache_write_5m;
310 let cache_write_1h_cost = cw_1h * price.cache_write_1h;
311 let cache_read_cost = cache_read_mtok * price.cache_read;
312 let output_cost = output_mtok * price.output;
313
314 let total =
315 input_cost + cache_write_5m_cost + cache_write_1h_cost + cache_read_cost + output_cost;
316
317 CostBreakdown {
318 input_cost,
319 cache_write_5m_cost,
320 cache_write_1h_cost,
321 cache_read_cost,
322 output_cost,
323 total,
324 price_source: source,
325 }
326 }
327
328 pub fn pricing_age_days() -> i64 {
330 let fetch_date =
331 NaiveDate::parse_from_str(PRICING_FETCH_DATE, "%Y-%m-%d").expect("valid date constant");
332 let today = chrono::Utc::now().date_naive();
333 (today - fetch_date).num_days()
334 }
335
336 pub fn is_pricing_stale() -> bool {
338 Self::pricing_age_days() > 90
339 }
340}
341
342#[cfg(test)]
345mod tests {
346 use super::*;
347 use crate::data::models::{CacheCreationDetail, TokenUsage};
348
349 fn make_usage(
351 input: u64,
352 output: u64,
353 cache_create: u64,
354 cache_read: u64,
355 cw_5m: u64,
356 cw_1h: u64,
357 ) -> TokenUsage {
358 let cache_creation = if cw_5m > 0 || cw_1h > 0 {
359 Some(CacheCreationDetail {
360 ephemeral_5m_input_tokens: Some(cw_5m),
361 ephemeral_1h_input_tokens: Some(cw_1h),
362 })
363 } else {
364 None
365 };
366
367 TokenUsage {
368 input_tokens: Some(input),
369 output_tokens: Some(output),
370 cache_creation_input_tokens: Some(cache_create),
371 cache_read_input_tokens: Some(cache_read),
372 cache_creation,
373 server_tool_use: None,
374 service_tier: None,
375 speed: None,
376 inference_geo: None,
377 }
378 }
379
380 #[test]
381 fn opus_46_pricing() {
382 let calc = PricingCalculator::new();
383 let usage = make_usage(1_000_000, 1_000_000, 1_000_000, 1_000_000, 1_000_000, 0);
385 let cost = calc.calculate_turn_cost("claude-opus-4-6", &usage);
386
387 assert!(
388 (cost.input_cost - 5.0).abs() < 1e-9,
389 "input_cost: {}",
390 cost.input_cost
391 );
392 assert!(
393 (cost.cache_write_5m_cost - 6.25).abs() < 1e-9,
394 "cache_write_5m_cost: {}",
395 cost.cache_write_5m_cost
396 );
397 assert!(
398 (cost.cache_write_1h_cost - 0.0).abs() < 1e-9,
399 "cache_write_1h_cost: {}",
400 cost.cache_write_1h_cost
401 );
402 assert!(
403 (cost.cache_read_cost - 0.50).abs() < 1e-9,
404 "cache_read_cost: {}",
405 cost.cache_read_cost
406 );
407 assert!(
408 (cost.output_cost - 25.0).abs() < 1e-9,
409 "output_cost: {}",
410 cost.output_cost
411 );
412 assert!((cost.total - 36.75).abs() < 1e-9, "total: {}", cost.total);
413 assert_eq!(cost.price_source, PriceSource::Builtin);
414 }
415
416 #[test]
417 fn distinguishes_5m_and_1h_cache() {
418 let calc = PricingCalculator::new();
419 let usage = make_usage(0, 0, 1_000_000, 0, 500_000, 500_000);
421 let cost = calc.calculate_turn_cost("claude-opus-4-6", &usage);
422
423 assert!(
425 (cost.cache_write_5m_cost - 3.125).abs() < 1e-9,
426 "cache_write_5m_cost: {}",
427 cost.cache_write_5m_cost
428 );
429 assert!(
431 (cost.cache_write_1h_cost - 5.0).abs() < 1e-9,
432 "cache_write_1h_cost: {}",
433 cost.cache_write_1h_cost
434 );
435 assert!((cost.total - 8.125).abs() < 1e-9, "total: {}", cost.total);
436 }
437
438 #[test]
439 fn prefix_matching() {
440 let calc = PricingCalculator::new();
441 let usage = make_usage(1_000_000, 0, 0, 0, 0, 0);
442 let cost = calc.calculate_turn_cost("claude-opus-4-5-20251101", &usage);
443
444 assert!(
446 (cost.input_cost - 5.0).abs() < 1e-9,
447 "input_cost: {}",
448 cost.input_cost
449 );
450 assert_eq!(cost.price_source, PriceSource::Builtin);
451 }
452
453 #[test]
454 fn unknown_model_zero() {
455 let calc = PricingCalculator {
459 prices: HashMap::new(),
460 overrides: HashMap::new(),
461 };
462 let usage = make_usage(1_000_000, 1_000_000, 1_000_000, 1_000_000, 1_000_000, 0);
463 let cost = calc.calculate_turn_cost("gpt-99-turbo", &usage);
464
465 assert!((cost.total - 0.0).abs() < 1e-9, "total: {}", cost.total);
466 assert_eq!(cost.price_source, PriceSource::Unknown);
467 }
468
469 #[test]
470 fn config_override_priority() {
471 let mut overrides = HashMap::new();
472 overrides.insert(
473 "claude-opus-4-6".to_string(),
474 ModelPrice {
475 base_input: 99.0,
476 cache_write_5m: 0.0,
477 cache_write_1h: 0.0,
478 cache_read: 0.0,
479 output: 0.0,
480 },
481 );
482
483 let calc = PricingCalculator::new().with_overrides(overrides);
484 let usage = make_usage(1_000_000, 0, 0, 0, 0, 0);
485 let cost = calc.calculate_turn_cost("claude-opus-4-6", &usage);
486
487 assert!(
488 (cost.input_cost - 99.0).abs() < 1e-9,
489 "input_cost: {}",
490 cost.input_cost
491 );
492 assert_eq!(cost.price_source, PriceSource::Config);
493 }
494
495 #[test]
500 fn opus_4_7_uses_opus_4_6_pricing() {
501 let calc = PricingCalculator::new();
502 let usage = make_usage(1_000_000, 1_000_000, 1_000_000, 1_000_000, 1_000_000, 0);
503 let cost = calc.calculate_turn_cost("claude-opus-4-7", &usage);
504
505 assert!(
507 (cost.input_cost - 5.0).abs() < 1e-9,
508 "input_cost: {}",
509 cost.input_cost
510 );
511 assert!(
512 (cost.output_cost - 25.0).abs() < 1e-9,
513 "output_cost: {}",
514 cost.output_cost
515 );
516 assert!(
517 (cost.cache_write_5m_cost - 6.25).abs() < 1e-9,
518 "cache_write_5m_cost: {}",
519 cost.cache_write_5m_cost
520 );
521 assert!(
522 (cost.cache_read_cost - 0.50).abs() < 1e-9,
523 "cache_read_cost: {}",
524 cost.cache_read_cost
525 );
526 assert!((cost.total - 36.75).abs() < 1e-9, "total: {}", cost.total);
527 assert_eq!(cost.price_source, PriceSource::Builtin);
528 }
529
530 #[test]
537 fn unknown_model_falls_back_to_latest_with_warning() {
538 let calc = PricingCalculator::new();
539 let usage = make_usage(1_000_000, 1_000_000, 0, 0, 0, 0);
540 let cost = calc.calculate_turn_cost("claude-future-x-1", &usage);
541
542 assert!((cost.total - 30.0).abs() < 1e-9, "total: {}", cost.total);
544 match cost.price_source {
545 PriceSource::Fallback {
546 ref requested,
547 ref fallback_to,
548 } => {
549 assert_eq!(requested, "claude-future-x-1");
550 assert_eq!(fallback_to, LATEST_FALLBACK_MODEL);
551 }
552 other => panic!("expected PriceSource::Fallback, got {:?}", other),
553 }
554 }
555
556 #[test]
560 fn fallback_model_must_exist_in_builtin() {
561 let calc = PricingCalculator::new();
562 assert!(
563 calc.prices.contains_key(LATEST_FALLBACK_MODEL),
564 "LATEST_FALLBACK_MODEL ({}) must exist in builtin_prices()",
565 LATEST_FALLBACK_MODEL
566 );
567 assert!(calc.latest_builtin_claude().is_some());
568 }
569
570 #[test]
573 fn cost_breakdown_carries_source() {
574 let calc = PricingCalculator::new();
575 let usage = make_usage(1_000_000, 0, 0, 0, 0, 0);
576
577 let builtin = calc.calculate_turn_cost("claude-opus-4-6", &usage);
578 assert_eq!(builtin.price_source, PriceSource::Builtin);
579
580 let fallback = calc.calculate_turn_cost("claude-future-x-1", &usage);
581 assert!(matches!(
582 fallback.price_source,
583 PriceSource::Fallback { .. }
584 ));
585
586 let mut overrides = HashMap::new();
587 overrides.insert(
588 "claude-opus-4-6".to_string(),
589 ModelPrice {
590 base_input: 1.0,
591 cache_write_5m: 0.0,
592 cache_write_1h: 0.0,
593 cache_read: 0.0,
594 output: 0.0,
595 },
596 );
597 let calc_with_override = PricingCalculator::new().with_overrides(overrides);
598 let config = calc_with_override.calculate_turn_cost("claude-opus-4-6", &usage);
599 assert_eq!(config.price_source, PriceSource::Config);
600 }
601}