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