Skip to main content

agent_core/
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 write (5m):  1.25× input price  (5-minute TTL, the default)
20//! - Cache write (1h):  2.00× input price  (1-hour TTL, opt-in via `cache_ttl`)
21
22/// Returns `(input_price_per_mtok, output_price_per_mtok)` for the given model
23/// string. Matching is substring-based so it works with full model IDs like
24/// `claude-opus-4-5-20251101` as well as short names like `claude-opus`.
25///
26/// Falls back to Sonnet pricing for unknown models.
27#[inline]
28fn model_prices(model: &str) -> (f64, f64) {
29    match model {
30        m if m.contains("fable")  => (10.0, 50.0),
31        m if m.contains("opus")   => (5.0, 25.0),
32        m if m.contains("sonnet") => (3.0, 15.0),
33        m if m.contains("haiku")  => (1.0,  5.0),
34        _                         => (3.0, 15.0), // default: Sonnet pricing
35    }
36}
37
38/// Calculate the USD cost of a single model turn (no cache-write TTL split).
39///
40/// Thin wrapper over [`calculate_cost_split`]: the aggregate `cache_creation`
41/// count is billed at the 5m write rate (1.25×). When the user opted into 1h
42/// caching but the TTL split didn't arrive, this under-bills (fail-cheap, not
43/// fail-expensive — cost display is informational, not invoiced).
44///
45/// # Arguments
46/// * `model`           – Model identifier string (e.g. `"claude-sonnet-4-5"`).
47/// * `input_tokens`    – Uncached input tokens billed at full input rate.
48/// * `output_tokens`   – Output / generated tokens (includes adaptive thinking).
49/// * `cache_read`      – Tokens served from the prompt cache (0.10× input rate).
50/// * `cache_creation`  – Tokens written to the prompt cache (1.25× input rate).
51///
52/// # Returns
53/// Cost in USD for this turn.
54pub fn calculate_cost(
55    model: &str,
56    input_tokens: u64,
57    output_tokens: u64,
58    cache_read: u64,
59    cache_creation: u64,
60) -> f64 {
61    calculate_cost_split(model, input_tokens, output_tokens, cache_read, cache_creation, 0)
62}
63
64/// Calculate the USD cost of a single model turn with the cache-write TTL
65/// split made first-class.
66///
67/// Cache pricing relative to input price:
68/// reads 0.10× | 5m write 1.25× | 1h write 2.0×.
69pub fn calculate_cost_split(
70    model: &str,
71    input_tokens: u64,
72    output_tokens: u64,
73    cache_read: u64,
74    cache_write_5m: u64,
75    cache_write_1h: u64,
76) -> f64 {
77    let (input_price, output_price) = model_prices(model);
78    (input_tokens     as f64 / 1_000_000.0) * input_price
79        + (cache_read     as f64 / 1_000_000.0) * input_price * 0.1
80        + (cache_write_5m as f64 / 1_000_000.0) * input_price * 1.25
81        + (cache_write_1h as f64 / 1_000_000.0) * input_price * 2.0
82        + (output_tokens  as f64 / 1_000_000.0) * output_price
83}
84
85/// Split-aware cost for callers holding an aggregate plus an *optional* TTL
86/// split (the shape of `SessionEvent::Usage`). When either split bucket is
87/// present the split rates apply; when both are `None`, the aggregate is
88/// billed at the 5m rate — fail-cheap, never fail-expensive.
89#[allow(clippy::too_many_arguments)]
90pub fn calculate_cost_optional_split(
91    model: &str,
92    input_tokens: u64,
93    output_tokens: u64,
94    cache_read: u64,
95    cache_creation: u64,
96    cache_creation_5m: Option<u64>,
97    cache_creation_1h: Option<u64>,
98) -> f64 {
99    match (cache_creation_5m, cache_creation_1h) {
100        (None, None) => calculate_cost(model, input_tokens, output_tokens, cache_read, cache_creation),
101        (c5, c1) => calculate_cost_split(
102            model, input_tokens, output_tokens, cache_read,
103            c5.unwrap_or(0), c1.unwrap_or(0),
104        ),
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn fable_pricing() {
114        // 1M input + 1M output, no cache → $10 + $50 = $60
115        let cost = calculate_cost("claude-fable-5", 1_000_000, 1_000_000, 0, 0);
116        assert!((cost - 60.0).abs() < 1e-9, "expected $60, got ${cost}");
117    }
118
119    #[test]
120    fn opus_pricing() {
121        // 1M input + 1M output, no cache → $5 + $25 = $30
122        let cost = calculate_cost("claude-opus-4-5", 1_000_000, 1_000_000, 0, 0);
123        assert!((cost - 30.0).abs() < 1e-9, "expected $30, got ${cost}");
124    }
125
126    #[test]
127    fn sonnet_pricing() {
128        // 1M input + 1M output → $3 + $15 = $18
129        let cost = calculate_cost("claude-sonnet-4-5", 1_000_000, 1_000_000, 0, 0);
130        assert!((cost - 18.0).abs() < 1e-9, "expected $18, got ${cost}");
131    }
132
133    #[test]
134    fn haiku_pricing() {
135        // 1M input + 1M output → $1 + $5 = $6
136        let cost = calculate_cost("claude-haiku-4-5", 1_000_000, 1_000_000, 0, 0);
137        assert!((cost - 6.0).abs() < 1e-9, "expected $6, got ${cost}");
138    }
139
140    #[test]
141    fn cache_read_bills_at_tenth_input_rate() {
142        // 1M cache-read tokens for Sonnet: 0.1 × $3 = $0.30
143        let cost = calculate_cost("claude-sonnet-4-5", 0, 0, 1_000_000, 0);
144        assert!((cost - 0.30).abs() < 1e-9, "expected $0.30, got ${cost}");
145    }
146
147    #[test]
148    fn cache_creation_bills_at_125_percent_input_rate() {
149        // 1M cache-write tokens for Sonnet: 1.25 × $3 = $3.75
150        let cost = calculate_cost("claude-sonnet-4-5", 0, 0, 0, 1_000_000);
151        assert!((cost - 3.75).abs() < 1e-9, "expected $3.75, got ${cost}");
152    }
153
154    #[test]
155    fn cache_write_1h_bills_at_double_input_rate() {
156        // 1M 1h cache-write tokens for Sonnet: 2.0 × $3 = $6.00 (spec §5)
157        let cost = calculate_cost_split("claude-sonnet-4-5", 0, 0, 0, 0, 1_000_000);
158        assert!((cost - 6.0).abs() < 1e-9, "expected $6.00, got ${cost}");
159    }
160
161    #[test]
162    fn split_mixes_5m_and_1h_rates() {
163        // Sonnet: 1M @ 1.25× ($3.75) + 1M @ 2.0× ($6.00) = $9.75
164        let cost = calculate_cost_split("claude-sonnet-4-5", 0, 0, 0, 1_000_000, 1_000_000);
165        assert!((cost - 9.75).abs() < 1e-9, "expected $9.75, got ${cost}");
166    }
167
168    #[test]
169    fn wrapper_equals_split_with_zero_1h() {
170        // calculate_cost(m,i,o,r,w) == calculate_cost_split(m,i,o,r,w,0)
171        let cases: &[(&str, u64, u64, u64, u64)] = &[
172            ("claude-sonnet-4-5", 1000, 2000, 3000, 4000),
173            ("claude-opus-4-5", 0, 0, 0, 1_000_000),
174            ("claude-haiku-4-5", 123, 456, 789, 1011),
175            ("gpt-99-turbo", 50, 60, 70, 80),
176            ("claude-fable-5", 0, 0, 0, 0),
177        ];
178        for &(m, i, o, r, w) in cases {
179            let a = calculate_cost(m, i, o, r, w);
180            let b = calculate_cost_split(m, i, o, r, w, 0);
181            assert!((a - b).abs() < 1e-12, "{m}: {a} != {b}");
182        }
183    }
184
185    #[test]
186    fn unknown_model_falls_back_to_sonnet() {
187        let cost_unknown = calculate_cost("gpt-99-turbo", 1_000_000, 0, 0, 0);
188        let cost_sonnet  = calculate_cost("claude-sonnet-4-5", 1_000_000, 0, 0, 0);
189        assert!((cost_unknown - cost_sonnet).abs() < 1e-9);
190    }
191
192    #[test]
193    fn zero_usage_is_zero_cost() {
194        assert_eq!(calculate_cost("claude-opus-4-5", 0, 0, 0, 0), 0.0);
195    }
196}