Skip to main content

skiagram_core/
pricing.rs

1//! Model pricing: embedded snapshot, USD per **million** tokens.
2//!
3//! Source: Anthropic public pricing as mirrored by LiteLLM's
4//! `model_prices_and_context_window.json` — manually curated subset, snapshot
5//! taken 2026-06. Cache rates are Anthropic's published multipliers materialized
6//! as absolute numbers: read = 0.1x input, 5m write = 1.25x input, 1h write = 2x
7//! input.
8//!
9//! RULES (CLAUDE.md §8):
10//! - cache-read and cache-creation are priced separately (§8.4), and the 5m/1h
11//!   write TTLs differ too — never lump them.
12//! - models NOT in this table are never guessed at; they surface as "unpriced"
13//!   (e.g. non-Anthropic models like `gpt-*`/`gemini-*`, or a future Claude
14//!   generation past this snapshot — a bare numeric suffix like `claude-opus-4-9`
15//!   must NOT inherit `claude-opus-4`'s price).
16//! - every cost figure traces to (model, token type, unit price) via this table —
17//!   no magic numbers anywhere else (§8.7).
18//!
19//! TODO(scope): optional refresh from LiteLLM behind a `--refresh-pricing` flag /
20//! `network` cargo feature, OFF by default (local-first, CLAUDE.md §12).
21
22use crate::model::Usage;
23
24/// USD per 1,000,000 tokens.
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct ModelPricing {
27    pub input: f64,
28    pub output: f64,
29    pub cache_read: f64,
30    pub cache_write_5m: f64,
31    pub cache_write_1h: f64,
32}
33
34/// Embedded pricing snapshot (2026-06). Keys are model-id prefixes; a dated
35/// release suffix (`-20250929` / `@20250929`) is accepted, a minor-version
36/// suffix is not (so `claude-opus-4-8` does NOT silently price as
37/// `claude-opus-4`).
38pub const SNAPSHOT: &[(&str, ModelPricing)] = &[
39    (
40        "claude-fable-5",
41        ModelPricing {
42            input: 10.0,
43            output: 50.0,
44            cache_read: 1.0,
45            cache_write_5m: 12.50,
46            cache_write_1h: 20.0,
47        },
48    ),
49    (
50        "claude-mythos-5",
51        ModelPricing {
52            input: 10.0,
53            output: 50.0,
54            cache_read: 1.0,
55            cache_write_5m: 12.50,
56            cache_write_1h: 20.0,
57        },
58    ),
59    (
60        "claude-sonnet-4-6",
61        ModelPricing {
62            input: 3.0,
63            output: 15.0,
64            cache_read: 0.30,
65            cache_write_5m: 3.75,
66            cache_write_1h: 6.0,
67        },
68    ),
69    (
70        "claude-sonnet-4-5",
71        ModelPricing {
72            input: 3.0,
73            output: 15.0,
74            cache_read: 0.30,
75            cache_write_5m: 3.75,
76            cache_write_1h: 6.0,
77        },
78    ),
79    (
80        "claude-sonnet-4",
81        ModelPricing {
82            input: 3.0,
83            output: 15.0,
84            cache_read: 0.30,
85            cache_write_5m: 3.75,
86            cache_write_1h: 6.0,
87        },
88    ),
89    (
90        "claude-3-7-sonnet",
91        ModelPricing {
92            input: 3.0,
93            output: 15.0,
94            cache_read: 0.30,
95            cache_write_5m: 3.75,
96            cache_write_1h: 6.0,
97        },
98    ),
99    (
100        "claude-opus-4-8",
101        ModelPricing {
102            input: 5.0,
103            output: 25.0,
104            cache_read: 0.50,
105            cache_write_5m: 6.25,
106            cache_write_1h: 10.0,
107        },
108    ),
109    (
110        "claude-opus-4-7",
111        ModelPricing {
112            input: 5.0,
113            output: 25.0,
114            cache_read: 0.50,
115            cache_write_5m: 6.25,
116            cache_write_1h: 10.0,
117        },
118    ),
119    (
120        "claude-opus-4-6",
121        ModelPricing {
122            input: 5.0,
123            output: 25.0,
124            cache_read: 0.50,
125            cache_write_5m: 6.25,
126            cache_write_1h: 10.0,
127        },
128    ),
129    (
130        "claude-opus-4-5",
131        ModelPricing {
132            input: 5.0,
133            output: 25.0,
134            cache_read: 0.50,
135            cache_write_5m: 6.25,
136            cache_write_1h: 10.0,
137        },
138    ),
139    (
140        "claude-opus-4-1",
141        ModelPricing {
142            input: 15.0,
143            output: 75.0,
144            cache_read: 1.50,
145            cache_write_5m: 18.75,
146            cache_write_1h: 30.0,
147        },
148    ),
149    (
150        "claude-opus-4",
151        ModelPricing {
152            input: 15.0,
153            output: 75.0,
154            cache_read: 1.50,
155            cache_write_5m: 18.75,
156            cache_write_1h: 30.0,
157        },
158    ),
159    (
160        "claude-haiku-4-5",
161        ModelPricing {
162            input: 1.0,
163            output: 5.0,
164            cache_read: 0.10,
165            cache_write_5m: 1.25,
166            cache_write_1h: 2.0,
167        },
168    ),
169    (
170        "claude-3-5-haiku",
171        ModelPricing {
172            input: 0.80,
173            output: 4.0,
174            cache_read: 0.08,
175            cache_write_5m: 1.0,
176            cache_write_1h: 1.6,
177        },
178    ),
179];
180
181/// Find the price for a model id, tolerating provider prefixes
182/// (`anthropic/...`) and dated release suffixes (`-20250929`, `@20250929`).
183/// Returns `None` for unknown models — callers must surface that, not guess.
184pub fn lookup(model: &str) -> Option<&'static ModelPricing> {
185    let m = normalize_model(model);
186    SNAPSHOT
187        .iter()
188        .filter(|(key, _)| key_matches(&m, key))
189        .max_by_key(|(key, _)| key.len()) // longest prefix wins (sonnet-4-5 over sonnet-4)
190        .map(|(_, p)| p)
191}
192
193/// Lower-case and strip provider prefixes (`anthropic/`, `us.anthropic.`,
194/// `anthropic.`) so matching is provider-agnostic. Shared by [`lookup`] and
195/// [`PricingTable`] so embedded and override matching behave identically.
196fn normalize_model(model: &str) -> String {
197    let mut m = model.trim().to_ascii_lowercase();
198    for prefix in ["anthropic/", "us.anthropic.", "anthropic."] {
199        if let Some(rest) = m.strip_prefix(prefix) {
200            m = rest.to_string();
201            break;
202        }
203    }
204    m
205}
206
207/// Exact match, or `<key>-YYYYMMDD` / `<key>@YYYYMMDD`. A bare numeric suffix
208/// like `-8` is a DIFFERENT model generation and must not match.
209/// TODO(scope): Bedrock-style `...-v1:0` suffixes are not recognized yet.
210fn key_matches(model: &str, key: &str) -> bool {
211    match model.strip_prefix(key) {
212        None => false,
213        Some("") => true,
214        Some(rest) => {
215            (rest.starts_with('-') || rest.starts_with('@'))
216                && rest.len() >= 9
217                && rest[1..].chars().all(|c| c.is_ascii_digit())
218        }
219    }
220}
221
222/// Cost of one request's usage in USD, or `None` when the model is unknown /
223/// unpriced. Unknown usage fields contribute nothing (absence ≠ zero — the
224/// result is a lower bound, and aggregation reports incompleteness separately).
225pub fn cost_usd(model: Option<&str>, usage: &Usage) -> Option<f64> {
226    Some(price_usage(lookup(model?)?, usage))
227}
228
229/// USD for one request's `usage` at the given unit prices. Pure arithmetic shared
230/// by the free [`cost_usd`] and [`PricingTable::cost_usd`] so embedded and
231/// overridden pricing never diverge in how a request is costed (CLAUDE.md §8.7).
232fn price_usage(p: &ModelPricing, usage: &Usage) -> f64 {
233    let per_m = |tokens: Option<u64>, rate: f64| tokens.unwrap_or(0) as f64 * rate / 1e6;
234
235    let mut cost = per_m(usage.input, p.input)
236        + per_m(usage.output, p.output)
237        // Thinking tokens bill at the output rate when an agent reports them.
238        + per_m(usage.thinking, p.output)
239        + per_m(usage.cache_read, p.cache_read);
240
241    // Cache writes: use the per-TTL split when reported; otherwise assume the
242    // 5m (default) rate — the cheaper one, so unsplit totals stay a lower bound.
243    cost += match (usage.cache_creation_5m, usage.cache_creation_1h) {
244        (None, None) => per_m(usage.cache_creation, p.cache_write_5m),
245        (m5, h1) => per_m(m5, p.cache_write_5m) + per_m(h1, p.cache_write_1h),
246    };
247    cost
248}
249
250/// A pricing lookup layering optional runtime `overrides` over the embedded
251/// [`SNAPSHOT`]. Pure and owned — no global state (CLAUDE.md §9). The binary builds
252/// one (from the `--refresh-pricing` cache / config) and threads it into the
253/// analysis passes; [`PricingTable::embedded`] is byte-for-byte identical to the
254/// free [`lookup`] / [`cost_usd`], so an empty table never changes a number.
255#[derive(Debug, Clone, Default)]
256pub struct PricingTable {
257    /// `(model-id-prefix, price)`, matched with the same prefix / longest-wins rule
258    /// as the snapshot and consulted BEFORE it (so an override wins on a tie).
259    overrides: Vec<(String, ModelPricing)>,
260}
261
262impl PricingTable {
263    /// Snapshot only — no overrides (identical to the free functions).
264    pub fn embedded() -> Self {
265        Self::default()
266    }
267
268    /// Snapshot plus the given overrides (keys lower-cased to match
269    /// [`normalize_model`]). Overrides take precedence over the snapshot.
270    pub fn with_overrides(overrides: Vec<(String, ModelPricing)>) -> Self {
271        let overrides = overrides
272            .into_iter()
273            .map(|(k, p)| (k.trim().to_ascii_lowercase(), p))
274            .collect();
275        Self { overrides }
276    }
277
278    /// Number of override entries (for the refresh report).
279    pub fn override_count(&self) -> usize {
280        self.overrides.len()
281    }
282
283    /// Look up a model: overrides first (longest-prefix wins), else the embedded
284    /// snapshot. `None` is still surfaced for unknown models, never guessed.
285    pub fn lookup(&self, model: &str) -> Option<&ModelPricing> {
286        if !self.overrides.is_empty() {
287            let m = normalize_model(model);
288            if let Some((_, p)) = self
289                .overrides
290                .iter()
291                .filter(|(key, _)| key_matches(&m, key))
292                .max_by_key(|(key, _)| key.len())
293            {
294                return Some(p);
295            }
296        }
297        lookup(model)
298    }
299
300    /// Cost of one request's usage under this table, or `None` when unpriced.
301    pub fn cost_usd(&self, model: Option<&str>, usage: &Usage) -> Option<f64> {
302        Some(price_usage(self.lookup(model?)?, usage))
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    fn dated_release_suffixes_match() {
312        assert!(lookup("claude-sonnet-4-5-20250929").is_some());
313        assert!(lookup("claude-haiku-4-5-20251001").is_some());
314        assert!(lookup("anthropic/claude-sonnet-4-5").is_some());
315        // Longest prefix wins: 4-5 pricing, not 4.
316        assert_eq!(lookup("claude-sonnet-4-5").map(|p| p.input), Some(3.0));
317    }
318
319    #[test]
320    fn unknown_models_are_never_guessed() {
321        // A newer generation must NOT silently take an existing generation's price
322        // (a bare numeric suffix is a different model, even when the base exists).
323        assert!(lookup("claude-opus-4-9").is_none()); // not claude-opus-4
324        assert!(lookup("claude-opus-5-0").is_none());
325        assert!(lookup("claude-fable-6").is_none());
326        assert!(lookup("<synthetic>").is_none());
327        assert!(lookup("gpt-yolo").is_none());
328    }
329
330    #[test]
331    fn current_models_from_official_snapshot_are_priced() {
332        // The 2026-06 lineup pasted from Anthropic's published table. Asserting the
333        // exact input also guards the generation boundary: opus-4-8 is $5 input, so
334        // if it wrongly matched claude-opus-4 it would read $15 instead.
335        assert_eq!(lookup("claude-opus-4-8").map(|p| p.input), Some(5.0));
336        assert_eq!(lookup("claude-opus-4-7").map(|p| p.input), Some(5.0));
337        assert_eq!(lookup("claude-opus-4-6").map(|p| p.input), Some(5.0));
338        assert_eq!(lookup("claude-sonnet-4-6").map(|p| p.output), Some(15.0));
339        assert_eq!(lookup("claude-fable-5").map(|p| p.output), Some(50.0));
340        assert_eq!(lookup("claude-mythos-5").map(|p| p.input), Some(10.0));
341        // Cache rates follow Anthropic's multipliers (fable-5: 0.1x/1.25x/2x of $10).
342        let fable = lookup("claude-fable-5").unwrap();
343        assert_eq!(fable.cache_read, 1.0);
344        assert_eq!(fable.cache_write_5m, 12.50);
345        assert_eq!(fable.cache_write_1h, 20.0);
346    }
347
348    #[test]
349    fn cache_ttls_price_differently() {
350        let split = Usage {
351            cache_creation: Some(1_000_000),
352            cache_creation_5m: Some(0),
353            cache_creation_1h: Some(1_000_000),
354            ..Usage::default()
355        };
356        // 1h write on sonnet-4-5 = $6/M, not the 5m $3.75/M.
357        let cost = cost_usd(Some("claude-sonnet-4-5"), &split).unwrap();
358        assert!((cost - 6.0).abs() < 1e-9, "got {cost}");
359
360        let unsplit = Usage {
361            cache_creation: Some(1_000_000),
362            ..Usage::default()
363        };
364        let cost = cost_usd(Some("claude-sonnet-4-5"), &unsplit).unwrap();
365        assert!(
366            (cost - 3.75).abs() < 1e-9,
367            "unsplit assumes 5m rate, got {cost}"
368        );
369    }
370
371    #[test]
372    fn cost_traces_to_unit_prices() {
373        let usage = Usage {
374            input: Some(1_000_000),
375            output: Some(1_000_000),
376            cache_read: Some(1_000_000),
377            ..Usage::default()
378        };
379        let cost = cost_usd(Some("claude-haiku-4-5"), &usage).unwrap();
380        assert!((cost - (1.0 + 5.0 + 0.10)).abs() < 1e-9);
381        assert_eq!(cost_usd(None, &usage), None);
382    }
383}