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-7";
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-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
185// ─── Calculator ──────────────────────────────────────────────────────────────
186
187/// Pricing calculator with built-in prices and optional config overrides.
188pub 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    /// Create a new calculator initialised with built-in prices.
201    pub fn new() -> Self {
202        Self {
203            prices: builtin_prices(),
204            overrides: HashMap::new(),
205        }
206    }
207
208    /// Set config-file price overrides. These take priority over built-in prices.
209    pub fn with_overrides(mut self, overrides: HashMap<String, ModelPrice>) -> Self {
210        self.overrides = overrides;
211        self
212    }
213
214    /// Look up the price for a model.
215    ///
216    /// Resolution order:
217    /// 1. Exact match in overrides
218    /// 2. Prefix match in overrides
219    /// 3. Exact match in built-in prices
220    /// 4. Prefix match in built-in prices
221    /// 5. Fallback to the latest built-in Claude (returns `PriceSource::Fallback`)
222    pub fn get_price(&self, model: &str) -> Option<(&ModelPrice, PriceSource)> {
223        // 1. Exact override
224        if let Some(p) = self.overrides.get(model) {
225            return Some((p, PriceSource::Config));
226        }
227        // 2. Prefix override (longest prefix wins)
228        if let Some(p) = Self::prefix_lookup(&self.overrides, model) {
229            return Some((p, PriceSource::Config));
230        }
231        // 3. Exact built-in
232        if let Some(p) = self.prices.get(model) {
233            return Some((p, PriceSource::Builtin));
234        }
235        // 4. Prefix built-in
236        if let Some(p) = Self::prefix_lookup(&self.prices, model) {
237            return Some((p, PriceSource::Builtin));
238        }
239        // 5. Fallback to latest built-in Claude so unknown models don't
240        // silently produce $0 costs. Caller can detect via `PriceSource::Fallback`.
241        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    /// Find the entry whose key is the longest prefix of `model`.
254    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    /// Look up the built-in entry used as the unknown-model fallback.
265    ///
266    /// Returns `None` only if `LATEST_FALLBACK_MODEL` is somehow missing from
267    /// the built-in table — guarded by a unit test.
268    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    /// Calculate the cost of a single assistant turn.
275    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        // Distinguish 5m and 1h cache write buckets
296        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                // No breakdown available – treat everything as 5m (conservative estimate)
303                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    /// Number of days since the built-in pricing data was fetched.
329    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    /// Returns `true` if the built-in pricing data is older than 90 days.
337    pub fn is_pricing_stale() -> bool {
338        Self::pricing_age_days() > 90
339    }
340}
341
342// ─── Tests ───────────────────────────────────────────────────────────────────
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use crate::data::models::{CacheCreationDetail, TokenUsage};
348
349    /// Helper to build a `TokenUsage` for testing.
350    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        // 1M input + 1M output + 1M cache_write_5m + 1M cache_read
384        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        // 500k 5m-cache + 500k 1h-cache for opus-4-6
420        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        // 0.5 MTok * $6.25 = $3.125
424        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        // 0.5 MTok * $10.0 = $5.0
430        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        // Should match claude-opus-4-5 → base_input = $5.0
445        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        // With no built-in entries at all, an unknown model has no fallback and
456        // produces zero cost with `PriceSource::Unknown`. This guards the path
457        // taken when `calculate_turn_cost` cannot resolve a price.
458        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    /// `claude-opus-4-7` must use the same pricing as the opus-4-6 generation
496    /// ($5 input / $25 output), not the older opus-4 generation ($15/$75).
497    /// Previously opus-4-7 fell through the prefix chain to `claude-opus-4`,
498    /// inflating its cost ~3x.
499    #[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        // Same total as opus-4-6 with the same usage: 5 + 6.25 + 0.50 + 25 = 36.75
506        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    /// An unknown model name with no prefix overlap with any built-in entry
531    /// must fall back to `LATEST_FALLBACK_MODEL` (currently claude-opus-4-7)
532    /// with a `PriceSource::Fallback` so the cost is not silently $0.
533    ///
534    /// Note: we pick "claude-future-x-1" deliberately — names like
535    /// `claude-opus-4-999` would be eaten by the `claude-opus-4` prefix.
536    #[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        // Priced at LATEST_FALLBACK_MODEL (opus-4-7) rates: $5 input + $25 output = $30.
543        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    /// Guard against typos: the constant pointed at by `LATEST_FALLBACK_MODEL`
557    /// must actually exist in the built-in table; otherwise `get_price` would
558    /// silently fall through to `None` and `PriceSource::Unknown`.
559    #[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    /// `calculate_turn_cost` must propagate the `PriceSource` from `get_price`
571    /// onto the `CostBreakdown`, so downstream code can surface fallback warnings.
572    #[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}