Skip to main content

cc_token_usage/pricing/
calculator.rs

1use std::collections::HashMap;
2
3use chrono::NaiveDate;
4
5use crate::data::models::TokenUsage;
6
7/// Date when pricing data was fetched from Anthropic's official pricing page.
8pub const PRICING_FETCH_DATE: &str = "2026-03-21";
9/// Source URL for pricing data.
10pub const PRICING_SOURCE: &str = "platform.claude.com/docs/en/about-claude/pricing";
11
12/// Built-in model used to price unknown models when no exact/prefix match exists.
13/// Updated whenever a new "latest" Claude flagship is added to `builtin_prices()`.
14pub const LATEST_FALLBACK_MODEL: &str = "claude-opus-4-8";
15
16// ─── Data Structures ─────────────────────────────────────────────────────────
17
18/// Per-model pricing in dollars per million tokens.
19#[derive(Debug, Clone)]
20pub struct ModelPrice {
21    /// Base input price ($/MTok).
22    pub base_input: f64,
23    /// Cache write price for 5-minute ephemeral TTL ($/MTok).
24    pub cache_write_5m: f64,
25    /// Cache write price for 1-hour ephemeral TTL ($/MTok).
26    pub cache_write_1h: f64,
27    /// Cache read price ($/MTok).
28    pub cache_read: f64,
29    /// Output price ($/MTok).
30    pub output: f64,
31}
32
33/// Itemised cost breakdown for a single turn.
34#[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/// Where the pricing data came from.
46#[derive(Debug, Clone, PartialEq)]
47pub enum PriceSource {
48    /// Hardcoded in the binary.
49    Builtin,
50    /// Loaded from a user config file override.
51    Config,
52    /// Unknown model — priced using the latest built-in Claude as a stand-in.
53    /// `requested` is the model name actually queried; `fallback_to` is the
54    /// built-in entry whose prices were used.
55    Fallback {
56        requested: String,
57        fallback_to: String,
58    },
59    /// Model not found and no fallback available – all costs are zero.
60    Unknown,
61}
62
63// ─── Built-in Price Table ────────────────────────────────────────────────────
64
65fn 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
195// ─── Calculator ──────────────────────────────────────────────────────────────
196
197/// Pricing calculator with built-in prices and optional config overrides.
198pub 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    /// Create a new calculator initialised with built-in prices.
211    pub fn new() -> Self {
212        Self {
213            prices: builtin_prices(),
214            overrides: HashMap::new(),
215        }
216    }
217
218    /// Set config-file price overrides. These take priority over built-in prices.
219    pub fn with_overrides(mut self, overrides: HashMap<String, ModelPrice>) -> Self {
220        self.overrides = overrides;
221        self
222    }
223
224    /// Look up the price for a model.
225    ///
226    /// Resolution order:
227    /// 1. Exact match in overrides
228    /// 2. Prefix match in overrides
229    /// 3. Exact match in built-in prices
230    /// 4. Prefix match in built-in prices
231    /// 5. Fallback to the latest built-in Claude (returns `PriceSource::Fallback`)
232    pub fn get_price(&self, model: &str) -> Option<(&ModelPrice, PriceSource)> {
233        // Strip any trailing context-window suffix in square brackets, e.g.
234        // `claude-opus-4-8[1m]` → `claude-opus-4-8`. This is purely a routing
235        // affix appended by Claude Code to mark the active context window; it is
236        // not part of the priced model identity. Stripping makes the name resolve
237        // via the exact builtin entry (`PriceSource::Builtin`) instead of relying
238        // on prefix matching, and is robust to malformed/multiple brackets. (The
239        // longest-prefix lookup below would still land on `claude-opus-4-8` once
240        // that builtin exists; the older ~3x mispricing against `claude-opus-4`
241        // only occurred before the `claude-opus-4-8` entry was added.)
242        let model = match model.split_once('[') {
243            Some((base, rest)) if rest.ends_with(']') => base,
244            _ => model,
245        };
246
247        // 1. Exact override
248        if let Some(p) = self.overrides.get(model) {
249            return Some((p, PriceSource::Config));
250        }
251        // 2. Prefix override (longest prefix wins)
252        if let Some(p) = Self::prefix_lookup(&self.overrides, model) {
253            return Some((p, PriceSource::Config));
254        }
255        // 3. Exact built-in
256        if let Some(p) = self.prices.get(model) {
257            return Some((p, PriceSource::Builtin));
258        }
259        // 4. Prefix built-in
260        if let Some(p) = Self::prefix_lookup(&self.prices, model) {
261            return Some((p, PriceSource::Builtin));
262        }
263        // 5. Fallback to latest built-in Claude so unknown models don't
264        // silently produce $0 costs. Caller can detect via `PriceSource::Fallback`.
265        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    /// Find the entry whose key is the longest prefix of `model`.
278    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    /// Look up the built-in entry used as the unknown-model fallback.
289    ///
290    /// Returns `None` only if `LATEST_FALLBACK_MODEL` is somehow missing from
291    /// the built-in table — guarded by a unit test.
292    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    /// Calculate the cost of a single assistant turn.
299    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        // Distinguish 5m and 1h cache write buckets
320        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                // No breakdown available – treat everything as 5m (conservative estimate)
327                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    /// Number of days since the built-in pricing data was fetched.
353    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    /// Returns `true` if the built-in pricing data is older than 90 days.
361    pub fn is_pricing_stale() -> bool {
362        Self::pricing_age_days() > 90
363    }
364}
365
366// ─── Tests ───────────────────────────────────────────────────────────────────
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use crate::data::models::{CacheCreationDetail, TokenUsage};
372
373    /// Helper to build a `TokenUsage` for testing.
374    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        // 1M input + 1M output + 1M cache_write_5m + 1M cache_read
408        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        // 500k 5m-cache + 500k 1h-cache for opus-4-6
444        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        // 0.5 MTok * $6.25 = $3.125
448        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        // 0.5 MTok * $10.0 = $5.0
454        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        // Should match claude-opus-4-5 → base_input = $5.0
469        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        // With no built-in entries at all, an unknown model has no fallback and
480        // produces zero cost with `PriceSource::Unknown`. This guards the path
481        // taken when `calculate_turn_cost` cannot resolve a price.
482        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    /// `claude-opus-4-7` must use the same pricing as the opus-4-6 generation
520    /// ($5 input / $25 output), not the older opus-4 generation ($15/$75).
521    /// Previously opus-4-7 fell through the prefix chain to `claude-opus-4`,
522    /// inflating its cost ~3x.
523    #[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        // Same total as opus-4-6 with the same usage: 5 + 6.25 + 0.50 + 25 = 36.75
530        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    /// `claude-opus-4-8` (with and without a `[1m]` context-window suffix) must
555    /// resolve to the opus-4-6/4-7 generation pricing ($5 input / $25 output),
556    /// never the older `claude-opus-4` generation ($15/$75). The bracketed
557    /// variant previously prefix-matched `claude-opus-4`, ~3x over-pricing.
558    #[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            // opus-4-6/4-7/4-8 rates: 5 + 6.25 + 0.50 + 25 = 36.75 (NOT 110.25 @ opus-4).
570            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    /// An unknown model name with no prefix overlap with any built-in entry
594    /// must fall back to `LATEST_FALLBACK_MODEL` (currently claude-opus-4-8)
595    /// with a `PriceSource::Fallback` so the cost is not silently $0.
596    ///
597    /// Note: we pick "claude-future-x-1" deliberately — names like
598    /// `claude-opus-4-999` would be eaten by the `claude-opus-4` prefix.
599    #[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        // Priced at LATEST_FALLBACK_MODEL (opus-4-7) rates: $5 input + $25 output = $30.
606        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    /// Guard against typos: the constant pointed at by `LATEST_FALLBACK_MODEL`
620    /// must actually exist in the built-in table; otherwise `get_price` would
621    /// silently fall through to `None` and `PriceSource::Unknown`.
622    #[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    /// `calculate_turn_cost` must propagate the `PriceSource` from `get_price`
634    /// onto the `CostBreakdown`, so downstream code can surface fallback warnings.
635    #[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}