Skip to main content

agx_core/
pricing.rs

1//! Per-model pricing lookup. Converts a `Step`'s token counters into a USD
2//! cost estimate.
3//!
4//! Prices are hardcoded and WILL drift as providers change their rates. Each
5//! entry carries a `last_verified` date so maintainers can audit staleness
6//! without re-reading every source. See the comment at the top of the PRICES
7//! array for the source-of-truth pages.
8//!
9//! When a model name is unknown, `cost_usd` returns `None` rather than
10//! guessing — agx doesn't fabricate cost numbers.
11//!
12//! Cache pricing follows Anthropic's public model: cache reads are billed
13//! at ~10% of the input rate, cache creation at ~125% of the input rate.
14//! OpenAI and Google structure caching differently; for those providers
15//! agx treats `cache_read` as a flat input-rate discount and
16//! `cache_create` as zero until better data is available.
17//!
18//! `ModelPricing::last_verified` is audited by a dedicated test but not
19//! read at runtime; its field-level `#[allow(dead_code)]` is the only
20//! intentional allow in this module.
21
22/// USD per 1M tokens. Small struct so adding a new model is one row.
23#[derive(Debug, Clone, Copy)]
24pub struct ModelPricing {
25    pub name: &'static str,
26    pub input_per_mtoken: f64,
27    pub output_per_mtoken: f64,
28    /// Set this when the provider charges a separate cache-read rate
29    /// (e.g. Anthropic). When `None`, cache-read tokens are billed at the
30    /// input rate.
31    pub cache_read_per_mtoken: Option<f64>,
32    /// Set this when the provider charges a separate cache-create rate.
33    /// When `None`, cache-create tokens are billed at the input rate.
34    pub cache_create_per_mtoken: Option<f64>,
35    /// Last date a human verified this entry against the provider's pricing
36    /// page. Present for audit; not used at runtime.
37    #[allow(dead_code)]
38    pub last_verified: &'static str,
39}
40
41// Source pages at time of last verification:
42//   Anthropic: https://www.anthropic.com/pricing
43//   OpenAI:    https://platform.openai.com/docs/pricing
44//   Google:    https://ai.google.dev/gemini-api/docs/pricing
45//
46// Rates below are ESTIMATES. Treat cost output as a ballpark until a
47// maintainer verifies against the current pricing page.
48const PRICES: &[ModelPricing] = &[
49    // --- Anthropic Claude 4.8 family ---
50    ModelPricing {
51        name: "claude-opus-4-8",
52        input_per_mtoken: 15.0,
53        output_per_mtoken: 75.0,
54        cache_read_per_mtoken: Some(1.50),
55        cache_create_per_mtoken: Some(18.75),
56        last_verified: "2026-06-19 (estimate; unverified)",
57    },
58    // --- Anthropic Claude 4.6 family ---
59    ModelPricing {
60        name: "claude-opus-4-6",
61        input_per_mtoken: 15.0,
62        output_per_mtoken: 75.0,
63        cache_read_per_mtoken: Some(1.50),
64        cache_create_per_mtoken: Some(18.75),
65        last_verified: "2026-04-15 (estimate; unverified)",
66    },
67    ModelPricing {
68        name: "claude-sonnet-4-6",
69        input_per_mtoken: 3.0,
70        output_per_mtoken: 15.0,
71        cache_read_per_mtoken: Some(0.30),
72        cache_create_per_mtoken: Some(3.75),
73        last_verified: "2026-04-15 (estimate; unverified)",
74    },
75    ModelPricing {
76        name: "claude-haiku-4-5",
77        input_per_mtoken: 1.0,
78        output_per_mtoken: 5.0,
79        cache_read_per_mtoken: Some(0.10),
80        cache_create_per_mtoken: Some(1.25),
81        last_verified: "2026-04-15 (estimate; unverified)",
82    },
83    // --- OpenAI ---
84    ModelPricing {
85        name: "gpt-5",
86        input_per_mtoken: 10.0,
87        output_per_mtoken: 30.0,
88        cache_read_per_mtoken: Some(2.50),
89        cache_create_per_mtoken: None,
90        last_verified: "2026-04-15 (estimate; unverified)",
91    },
92    ModelPricing {
93        name: "gpt-5-mini",
94        input_per_mtoken: 0.5,
95        output_per_mtoken: 2.0,
96        cache_read_per_mtoken: Some(0.10),
97        cache_create_per_mtoken: None,
98        last_verified: "2026-04-15 (estimate; unverified)",
99    },
100    // --- Google Gemini ---
101    ModelPricing {
102        name: "gemini-2-5-pro",
103        input_per_mtoken: 2.50,
104        output_per_mtoken: 15.0,
105        cache_read_per_mtoken: Some(0.625),
106        cache_create_per_mtoken: None,
107        last_verified: "2026-04-15 (estimate; unverified)",
108    },
109    ModelPricing {
110        name: "gemini-2-5-flash",
111        input_per_mtoken: 0.30,
112        output_per_mtoken: 2.50,
113        cache_read_per_mtoken: Some(0.075),
114        cache_create_per_mtoken: None,
115        last_verified: "2026-04-15 (estimate; unverified)",
116    },
117];
118
119/// Look up pricing for a given model name. Returns `None` when the model is
120/// not in the table. Uses case-insensitive exact match — no fuzzy matching,
121/// no family fallback (avoids silent wrong numbers for new variants).
122#[must_use]
123pub fn lookup(model: &str) -> Option<&'static ModelPricing> {
124    PRICES.iter().find(|p| p.name.eq_ignore_ascii_case(model))
125}
126
127/// Compute USD cost for a single step given its token counters and model.
128/// Returns `None` when the model is unknown OR when there are no non-zero
129/// token counters (nothing to cost).
130#[must_use]
131pub fn cost_usd(
132    model: Option<&str>,
133    tokens_in: Option<u64>,
134    tokens_out: Option<u64>,
135    cache_read: Option<u64>,
136    cache_create: Option<u64>,
137) -> Option<f64> {
138    let pricing = lookup(model?)?;
139    let has_any = [tokens_in, tokens_out, cache_read, cache_create]
140        .iter()
141        .any(|v| v.is_some_and(|n| n > 0));
142    if !has_any {
143        return None;
144    }
145    #[allow(clippy::cast_precision_loss)]
146    let t_in = tokens_in.unwrap_or(0) as f64;
147    #[allow(clippy::cast_precision_loss)]
148    let t_out = tokens_out.unwrap_or(0) as f64;
149    #[allow(clippy::cast_precision_loss)]
150    let t_cr = cache_read.unwrap_or(0) as f64;
151    #[allow(clippy::cast_precision_loss)]
152    let t_cc = cache_create.unwrap_or(0) as f64;
153    let cost = t_in * pricing.input_per_mtoken
154        + t_out * pricing.output_per_mtoken
155        + t_cr
156            * pricing
157                .cache_read_per_mtoken
158                .unwrap_or(pricing.input_per_mtoken)
159        + t_cc
160            * pricing
161                .cache_create_per_mtoken
162                .unwrap_or(pricing.input_per_mtoken);
163    Some(cost / 1_000_000.0)
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn lookup_finds_known_model_case_insensitive() {
172        assert!(lookup("claude-opus-4-6").is_some());
173        assert!(lookup("Claude-Opus-4-6").is_some());
174        assert!(lookup("CLAUDE-OPUS-4-6").is_some());
175    }
176
177    #[test]
178    fn lookup_returns_none_for_unknown_model() {
179        assert!(lookup("llama-99-ultra").is_none());
180        assert!(lookup("").is_none());
181    }
182
183    #[test]
184    fn cost_unknown_model_returns_none() {
185        assert_eq!(
186            cost_usd(Some("unknown"), Some(100), Some(50), None, None),
187            None
188        );
189    }
190
191    #[test]
192    fn cost_none_model_returns_none() {
193        assert_eq!(cost_usd(None, Some(100), Some(50), None, None), None);
194    }
195
196    #[test]
197    fn cost_zero_tokens_returns_none() {
198        // No non-zero counter → nothing to cost → None (not 0.0).
199        // Matters because downstream code formats None as "—" and 0 as "$0.00".
200        assert_eq!(
201            cost_usd(Some("claude-opus-4-6"), Some(0), Some(0), Some(0), Some(0)),
202            None
203        );
204        assert_eq!(
205            cost_usd(Some("claude-opus-4-6"), None, None, None, None),
206            None
207        );
208    }
209
210    #[test]
211    fn cost_computes_input_plus_output() {
212        // opus-4-6: $15/Mtok input, $75/Mtok output.
213        // 1M input + 1M output = $15 + $75 = $90.
214        let c = cost_usd(
215            Some("claude-opus-4-6"),
216            Some(1_000_000),
217            Some(1_000_000),
218            None,
219            None,
220        )
221        .unwrap();
222        assert!((c - 90.0).abs() < 1e-6, "expected 90.0, got {c}");
223    }
224
225    #[test]
226    fn cost_cache_read_uses_discounted_rate_when_provider_sets_one() {
227        // opus-4-6 cache_read rate is $1.50/Mtok (10% of $15 input).
228        // 1M cache_read → $1.50 alone.
229        let c = cost_usd(Some("claude-opus-4-6"), None, None, Some(1_000_000), None).unwrap();
230        assert!((c - 1.50).abs() < 1e-6, "expected 1.50, got {c}");
231    }
232
233    #[test]
234    fn cost_falls_back_to_input_rate_when_cache_rate_missing() {
235        // gpt-5 has no cache_create rate, so cache_create is billed at
236        // input rate ($10/Mtok).
237        let c = cost_usd(Some("gpt-5"), None, None, None, Some(1_000_000)).unwrap();
238        assert!((c - 10.0).abs() < 1e-6, "expected 10.0, got {c}");
239    }
240
241    #[test]
242    fn every_entry_has_last_verified_date() {
243        for p in PRICES {
244            assert!(
245                !p.last_verified.is_empty(),
246                "{} missing last_verified",
247                p.name
248            );
249        }
250    }
251
252    #[test]
253    fn no_duplicate_model_names() {
254        use std::collections::HashSet;
255        let mut seen = HashSet::new();
256        for p in PRICES {
257            assert!(seen.insert(p.name), "duplicate pricing entry: {}", p.name);
258        }
259    }
260
261    #[test]
262    fn claude_opus_4_8_is_priced() {
263        // Regression: agx_session_summary returned cost_usd:null for the
264        // current flagship because this row was missing.
265        let c = cost_usd(
266            Some("claude-opus-4-8"),
267            Some(1_000_000),
268            Some(1_000_000),
269            None,
270            None,
271        )
272        .expect("claude-opus-4-8 must be in the pricing table");
273        assert!((c - 90.0).abs() < 1e-6, "expected 90.0, got {c}");
274    }
275}