Skip to main content

synaps_cli/
pricing.rs

1//! Centralised pricing logic for Anthropic models.
2//!
3//! All cost calculations live here so that the engine, TUI, and any future
4//! consumer share a single source of truth. Update this file — and only this
5//! file — whenever Anthropic changes its pricing.
6//!
7//! Prices are in USD per million tokens (as of 2026-06).
8//! Source: <https://www.anthropic.com/pricing>
9//!
10//! | Model   | Input  | Output |
11//! |---------|--------|--------|
12//! | Fable   | $10.00 | $50.00 |
13//! | Opus    | $5.00  | $25.00 |
14//! | Sonnet  | $3.00  | $15.00 |
15//! | Haiku   | $1.00  | $5.00  |
16//!
17//! Cache pricing (relative to input price):
18//! - Cache reads:    0.10× input price  (prompt-cache hit)
19//! - Cache creation: 1.25× input price  (5-minute TTL write)
20
21/// Returns `(input_price_per_mtok, output_price_per_mtok)` for the given model
22/// string. Matching is substring-based so it works with full model IDs like
23/// `claude-opus-4-5-20251101` as well as short names like `claude-opus`.
24///
25/// Falls back to Sonnet pricing for unknown models.
26#[inline]
27fn model_prices(model: &str) -> (f64, f64) {
28    match model {
29        m if m.contains("fable")  => (10.0, 50.0),
30        m if m.contains("opus")   => (5.0, 25.0),
31        m if m.contains("sonnet") => (3.0, 15.0),
32        m if m.contains("haiku")  => (1.0,  5.0),
33        _                         => (3.0, 15.0), // default: Sonnet pricing
34    }
35}
36
37/// Calculate the USD cost of a single model turn.
38///
39/// # Arguments
40/// * `model`           – Model identifier string (e.g. `"claude-sonnet-4-5"`).
41/// * `input_tokens`    – Uncached input tokens billed at full input rate.
42/// * `output_tokens`   – Output / generated tokens (includes adaptive thinking).
43/// * `cache_read`      – Tokens served from the prompt cache (0.10× input rate).
44/// * `cache_creation`  – Tokens written to the prompt cache (1.25× input rate).
45///
46/// # Returns
47/// Cost in USD for this turn.
48pub fn calculate_cost(
49    model: &str,
50    input_tokens: u64,
51    output_tokens: u64,
52    cache_read: u64,
53    cache_creation: u64,
54) -> f64 {
55    let (input_price, output_price) = model_prices(model);
56    (input_tokens    as f64 / 1_000_000.0) * input_price
57        + (cache_read     as f64 / 1_000_000.0) * input_price * 0.1
58        + (cache_creation as f64 / 1_000_000.0) * input_price * 1.25
59        + (output_tokens  as f64 / 1_000_000.0) * output_price
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65
66    #[test]
67    fn fable_pricing() {
68        // 1M input + 1M output, no cache → $10 + $50 = $60
69        let cost = calculate_cost("claude-fable-5", 1_000_000, 1_000_000, 0, 0);
70        assert!((cost - 60.0).abs() < 1e-9, "expected $60, got ${cost}");
71    }
72
73    #[test]
74    fn opus_pricing() {
75        // 1M input + 1M output, no cache → $5 + $25 = $30
76        let cost = calculate_cost("claude-opus-4-5", 1_000_000, 1_000_000, 0, 0);
77        assert!((cost - 30.0).abs() < 1e-9, "expected $30, got ${cost}");
78    }
79
80    #[test]
81    fn sonnet_pricing() {
82        // 1M input + 1M output → $3 + $15 = $18
83        let cost = calculate_cost("claude-sonnet-4-5", 1_000_000, 1_000_000, 0, 0);
84        assert!((cost - 18.0).abs() < 1e-9, "expected $18, got ${cost}");
85    }
86
87    #[test]
88    fn haiku_pricing() {
89        // 1M input + 1M output → $1 + $5 = $6
90        let cost = calculate_cost("claude-haiku-4-5", 1_000_000, 1_000_000, 0, 0);
91        assert!((cost - 6.0).abs() < 1e-9, "expected $6, got ${cost}");
92    }
93
94    #[test]
95    fn cache_read_bills_at_tenth_input_rate() {
96        // 1M cache-read tokens for Sonnet: 0.1 × $3 = $0.30
97        let cost = calculate_cost("claude-sonnet-4-5", 0, 0, 1_000_000, 0);
98        assert!((cost - 0.30).abs() < 1e-9, "expected $0.30, got ${cost}");
99    }
100
101    #[test]
102    fn cache_creation_bills_at_125_percent_input_rate() {
103        // 1M cache-write tokens for Sonnet: 1.25 × $3 = $3.75
104        let cost = calculate_cost("claude-sonnet-4-5", 0, 0, 0, 1_000_000);
105        assert!((cost - 3.75).abs() < 1e-9, "expected $3.75, got ${cost}");
106    }
107
108    #[test]
109    fn unknown_model_falls_back_to_sonnet() {
110        let cost_unknown = calculate_cost("gpt-99-turbo", 1_000_000, 0, 0, 0);
111        let cost_sonnet  = calculate_cost("claude-sonnet-4-5", 1_000_000, 0, 0, 0);
112        assert!((cost_unknown - cost_sonnet).abs() < 1e-9);
113    }
114
115    #[test]
116    fn zero_usage_is_zero_cost() {
117        assert_eq!(calculate_cost("claude-opus-4-5", 0, 0, 0, 0), 0.0);
118    }
119}