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