Skip to main content

agent_top_core/
pricing.rs

1//! Model prices, loaded from data rather than compiled in as code.
2//!
3//! `prices.toml` next to this crate is embedded in the binary and is the
4//! built-in table. A file at `$XDG_CONFIG_HOME/agent-top/prices.toml`, or
5//! `~/.config/agent-top/prices.toml`, is merged over it at startup: an entry
6//! with the same prefix replaces a built-in one, a new prefix is added. That
7//! means a stale price can be corrected, and a model this project has never
8//! heard of can be priced, without a release and without a Rust toolchain.
9//!
10//! A model with no entry is never guessed at. Its tokens are counted and
11//! reported as unpriced, and any total containing them is shown as a floor.
12
13use crate::model::{CostBreakdown, PriceSource, TokenUsage};
14use serde::Deserialize;
15use std::path::PathBuf;
16use std::sync::OnceLock;
17
18/// The table shipped with the binary. See that file for the format.
19const BUILTIN: &str = include_str!("../prices.toml");
20
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub struct Price {
23    pub input: f64,
24    pub output: f64,
25    pub cache_write_5m: f64,
26    pub cache_write_1h: f64,
27    pub cache_read: f64,
28}
29
30impl Price {
31    /// Cost in USD of `usage` at this price.
32    pub fn cost(&self, usage: &TokenUsage) -> f64 {
33        self.breakdown(usage).total()
34    }
35
36    /// The same cost, one line per kind of token.
37    pub fn breakdown(&self, usage: &TokenUsage) -> CostBreakdown {
38        const M: f64 = 1_000_000.0;
39        CostBreakdown {
40            input: usage.input as f64 * self.input / M,
41            cache_write_5m: usage.cache_write_5m as f64 * self.cache_write_5m / M,
42            cache_write_1h: usage.cache_write_1h as f64 * self.cache_write_1h / M,
43            // `cache_write_unsplit` has no rate here and never gets one: the
44            // TTL that picks between the two above was not recorded. A harness
45            // that sets it either reports its own cost or counts the tokens as
46            // unpriced, so that the total is shown as a floor.
47            cache_write_unsplit: 0.0,
48            cache_read: usage.cache_read as f64 * self.cache_read / M,
49            output: usage.output as f64 * self.output / M,
50            web_search: 0.0,
51        }
52    }
53}
54
55/// Where an effective price came from, so `--prices` can show a user which of
56/// their overrides actually took effect.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Origin {
59    Builtin,
60    User,
61}
62
63#[derive(Debug, Clone)]
64pub struct Entry {
65    pub prefix: String,
66    pub price: Price,
67    pub origin: Origin,
68}
69
70#[derive(Debug, Deserialize)]
71struct FileTable {
72    #[serde(default)]
73    updated: Option<String>,
74    #[serde(default)]
75    model: Vec<FileModel>,
76    #[serde(default)]
77    server_tools: Option<FileServerTools>,
78}
79
80/// Server-side tools billed per call rather than per token.
81#[derive(Debug, Deserialize)]
82struct FileServerTools {
83    /// USD per 1,000 web searches.
84    web_search: Option<f64>,
85}
86
87#[derive(Debug, Deserialize)]
88struct FileModel {
89    prefix: String,
90    input: f64,
91    output: f64,
92    cache_read: f64,
93    /// Anthropic charges 1.25x input for the 5 minute TTL and 2x for the hour.
94    /// A vendor that prices cache writes differently sets these.
95    cache_write_5m: Option<f64>,
96    cache_write_1h: Option<f64>,
97}
98
99impl FileModel {
100    fn price(&self) -> Price {
101        Price {
102            input: self.input,
103            output: self.output,
104            cache_read: self.cache_read,
105            cache_write_5m: self.cache_write_5m.unwrap_or(self.input * 1.25),
106            cache_write_1h: self.cache_write_1h.unwrap_or(self.input * 2.0),
107        }
108    }
109}
110
111/// The effective table, plus anything the user should be told about how it was
112/// built. A bad user file must never take the built-in prices down with it, and
113/// must never be swallowed either: it is reported and the built-ins stand.
114#[derive(Debug, Clone, Default)]
115pub struct Table {
116    pub entries: Vec<Entry>,
117    pub updated: Option<String>,
118    pub user_path: Option<PathBuf>,
119    pub warnings: Vec<String>,
120    /// USD per 1,000 web searches, when the table prices them.
121    pub web_search_per_1k: Option<f64>,
122    pub web_search_origin: Option<Origin>,
123}
124
125impl Table {
126    /// USD for `n` web searches, or zero when the table does not price them.
127    /// Anthropic bills web search per search on top of the tokens it produces;
128    /// web fetch and code execution alongside it are free (checked 2026-09-04).
129    pub fn web_search_cost(&self, n: u64) -> f64 {
130        self.web_search_per_1k.map(|p| n as f64 * p / 1_000.0).unwrap_or(0.0)
131    }
132
133    pub fn lookup(&self, model: &str) -> Option<Price> {
134        self.entry_for(model).map(|e| e.price)
135    }
136
137    /// Whether the model's effective price is the built-in list price or the
138    /// user's own.
139    pub fn source_for(&self, model: &str) -> Option<PriceSource> {
140        self.entry_for(model).map(|e| match e.origin {
141            Origin::Builtin => PriceSource::Builtin,
142            Origin::User => PriceSource::UserFile,
143        })
144    }
145
146    fn entry_for(&self, model: &str) -> Option<&Entry> {
147        let m = model.trim().to_ascii_lowercase();
148        let m = m.strip_prefix("anthropic.").unwrap_or(&m);
149        let m = m.strip_prefix("us.anthropic.").unwrap_or(m);
150        self.entries.iter().filter(|e| m.starts_with(&e.prefix)).max_by_key(|e| e.prefix.len())
151    }
152}
153
154fn parse(text: &str) -> Result<FileTable, toml::de::Error> {
155    toml::from_str(text)
156}
157
158/// Build a table from the embedded text and an optional user file. Pure, so the
159/// merge and every failure mode are testable without touching a real home
160/// directory.
161pub fn build(builtin: &str, user: Option<(&str, PathBuf)>) -> Table {
162    let mut table = Table::default();
163    match parse(builtin) {
164        Ok(f) => {
165            table.updated = f.updated;
166            table.entries = f
167                .model
168                .into_iter()
169                .map(|m| Entry { prefix: m.prefix.to_ascii_lowercase(), price: m.price(), origin: Origin::Builtin })
170                .collect();
171            if let Some(p) = f.server_tools.and_then(|t| t.web_search) {
172                table.web_search_per_1k = Some(p);
173                table.web_search_origin = Some(Origin::Builtin);
174            }
175        }
176        // Only reachable if the shipped file is broken, which is a bug here
177        // rather than anything a user can fix, but it must not panic.
178        Err(e) => table.warnings.push(format!("built-in price table is invalid: {e}")),
179    }
180
181    let Some((text, path)) = user else { return table };
182    table.user_path = Some(path.clone());
183    match parse(text) {
184        Ok(f) => {
185            for m in f.model {
186                let prefix = m.prefix.to_ascii_lowercase();
187                let entry = Entry { prefix: prefix.clone(), price: m.price(), origin: Origin::User };
188                match table.entries.iter().position(|e| e.prefix == prefix) {
189                    Some(i) => table.entries[i] = entry,
190                    None => table.entries.push(entry),
191                }
192            }
193            if let Some(p) = f.server_tools.and_then(|t| t.web_search) {
194                table.web_search_per_1k = Some(p);
195                table.web_search_origin = Some(Origin::User);
196            }
197        }
198        Err(e) => table.warnings.push(format!("{}: ignored, {}", path.display(), first_line(&e.to_string()))),
199    }
200    table
201}
202
203fn first_line(s: &str) -> String {
204    s.lines().next().unwrap_or(s).to_string()
205}
206
207/// `$AGENT_TOP_PRICES` wins, then the XDG config directory, then `~/.config`.
208pub fn user_price_path() -> Option<PathBuf> {
209    if let Some(p) = std::env::var_os("AGENT_TOP_PRICES") {
210        return Some(PathBuf::from(p));
211    }
212    let dir = match std::env::var_os("XDG_CONFIG_HOME") {
213        Some(d) => PathBuf::from(d),
214        None => PathBuf::from(std::env::var_os("HOME")?).join(".config"),
215    };
216    Some(dir.join("agent-top").join("prices.toml"))
217}
218
219/// The shipped table alone, with no user overrides merged in. Tests that
220/// assert costs use this: a suite whose results depend on the developer's home
221/// directory is worse than no suite.
222pub fn builtin_table() -> &'static Table {
223    static BUILTIN_TABLE: OnceLock<Table> = OnceLock::new();
224    BUILTIN_TABLE.get_or_init(|| build(BUILTIN, None))
225}
226
227pub fn table() -> &'static Table {
228    static TABLE: OnceLock<Table> = OnceLock::new();
229    TABLE.get_or_init(|| {
230        let user = user_price_path().and_then(|p| std::fs::read_to_string(&p).ok().map(|t| (t, p)));
231        build(BUILTIN, user.as_ref().map(|(t, p)| (t.as_str(), p.clone())))
232    })
233}
234
235/// Look up a price by model id. Date-suffixed ids (`claude-sonnet-4-6-20251114`)
236/// and vendor-prefixed ids (`anthropic.claude-opus-5`) resolve to the base model.
237pub fn price_for(model: &str) -> Option<Price> {
238    table().lookup(model)
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    fn builtin() -> Table {
246        build(BUILTIN, None)
247    }
248
249    #[test]
250    fn ships_a_valid_builtin_table() {
251        let t = builtin();
252        assert!(t.warnings.is_empty(), "{:?}", t.warnings);
253        assert_eq!(t.updated.as_deref(), Some("2026-10-01"));
254        assert_eq!(t.web_search_per_1k, Some(10.0));
255        assert!((t.web_search_cost(3) - 0.03).abs() < 1e-12);
256        assert!(t.entries.len() >= 11);
257        assert!(t.entries.iter().all(|e| e.origin == Origin::Builtin));
258    }
259
260    #[test]
261    fn longest_prefix_wins() {
262        let t = builtin();
263        assert_eq!(t.lookup("claude-fable-5-1").unwrap().cache_read, 0.25);
264        assert_eq!(t.lookup("claude-fable-5").unwrap().cache_read, 1.0);
265        assert_eq!(t.lookup("claude-sonnet-4-6-20251114").unwrap().input, 3.0);
266        assert_eq!(t.lookup("us.anthropic.claude-opus-5").unwrap().input, 5.0);
267        // Opus 5.5 is cheaper than Opus 5, whose prefix it extends.
268        let opus = t.lookup("claude-opus-5-5").unwrap();
269        assert_eq!((opus.input, opus.output, opus.cache_read), (4.0, 20.0, 0.20));
270        assert_eq!((opus.cache_write_5m, opus.cache_write_1h), (5.0, 8.0));
271        assert_eq!(t.lookup("claude-opus-4-1-20250805").unwrap().input, 15.0);
272        assert_eq!(t.lookup("claude-opus-4-20250514").unwrap().input, 15.0);
273        assert_eq!(t.lookup("claude-3-5-haiku-20241022").unwrap().output, 4.0);
274        // OpenAI's rows: a named variant is exact, a versioned prefix beats the
275        // bare one, and a codex variant with no entry resolves to its base.
276        assert_eq!(t.lookup("gpt-5.6-luna").unwrap().output, 1.20);
277        assert_eq!(t.lookup("gpt-5.4-mini").unwrap().input, 0.75);
278        assert_eq!(t.lookup("gpt-5.4").unwrap().input, 2.50);
279        assert_eq!(t.lookup("gpt-5-codex").unwrap().input, 1.25, "resolves to gpt-5 by longest prefix");
280        assert!(t.lookup("llama-3-70b").is_none(), "a model from no vendor in the table is still unpriced");
281        // Google's rows: the lite variant beats its parent prefix, and cache
282        // writes are the input price rather than Anthropic's multipliers.
283        assert_eq!(t.lookup("gemini-2.5-flash-lite").unwrap().output, 0.40);
284        assert_eq!(t.lookup("gemini-2.5-flash").unwrap().output, 2.50);
285        assert_eq!(t.lookup("gemini-2.5-pro").unwrap().cache_write_1h, 1.25);
286        assert_eq!(t.lookup("gemini-3.1-pro-preview").unwrap().input, 2.0);
287        assert!(t.lookup("<synthetic>").is_none());
288    }
289
290    /// Every Claude model on the pricing page, with the prefix that must price
291    /// it. A new model whose id extends an older one's (`claude-opus-5-5` and
292    /// `claude-opus-5`) silently takes the older price unless it has its own
293    /// row; this catches that when the list here is updated from the page.
294    #[test]
295    fn every_listed_claude_model_has_its_own_row() {
296        let t = builtin();
297        for (id, prefix) in [
298            ("claude-fable-5-1", "claude-fable-5-1"),
299            ("claude-mythos-5-1", "claude-mythos-5-1"),
300            ("claude-fable-5", "claude-fable-5"),
301            ("claude-mythos-5", "claude-mythos-5"),
302            ("claude-opus-5-5", "claude-opus-5-5"),
303            ("claude-opus-5", "claude-opus-5"),
304            ("claude-opus-4-8", "claude-opus-4-8"),
305            ("claude-opus-4-7", "claude-opus-4-7"),
306            ("claude-opus-4-6", "claude-opus-4-6"),
307            ("claude-opus-4-5-20251101", "claude-opus-4-5"),
308            ("claude-opus-4-1-20250805", "claude-opus-4-1"),
309            ("claude-opus-4-20250514", "claude-opus-4-2025"),
310            ("claude-sonnet-5-5", "claude-sonnet-5-5"),
311            ("claude-sonnet-5", "claude-sonnet-5"),
312            ("claude-sonnet-4-6", "claude-sonnet-4-6"),
313            ("claude-sonnet-4-5-20250929", "claude-sonnet-4-5"),
314            ("claude-sonnet-4-20250514", "claude-sonnet-4-2025"),
315            ("claude-haiku-4-5-20251001", "claude-haiku-4-5"),
316            ("claude-3-5-haiku-20241022", "claude-3-5-haiku"),
317        ] {
318            let e = t.entry_for(id).unwrap_or_else(|| panic!("{id} is unpriced"));
319            assert_eq!(e.prefix, prefix, "{id} resolved to another model's row");
320        }
321    }
322
323    #[test]
324    fn a_write_with_no_recorded_ttl_is_never_priced_from_the_table() {
325        let t = builtin();
326        let p = t.lookup("claude-sonnet-5").unwrap();
327        // The two rates below differ, and the TTL is what chooses between
328        // them, so a write that never recorded one cannot be charged either.
329        assert_ne!(p.cache_write_5m, p.cache_write_1h);
330        let u = TokenUsage { cache_write_unsplit: 1_000_000, ..Default::default() };
331        assert_eq!(p.cost(&u), 0.0);
332        assert_eq!(p.breakdown(&u).cache_write_unsplit, 0.0);
333        // A recorded TTL still prices as before.
334        let u = TokenUsage { cache_write_5m: 1_000_000, ..Default::default() };
335        assert!(p.cost(&u) > 0.0);
336    }
337
338    #[test]
339    fn cost_arithmetic() {
340        let t = builtin();
341        let p = t.lookup("claude-sonnet-5").unwrap();
342        let u = TokenUsage { input: 1_000_000, output: 1_000_000, ..Default::default() };
343        assert!((p.cost(&u) - 12.0).abs() < 1e-9);
344        // Cache writes default to Anthropic's multipliers: 2x input for an hour.
345        let u = TokenUsage { cache_write_1h: 1_000_000, ..Default::default() };
346        assert!((p.cost(&u) - 4.0).abs() < 1e-9);
347        let u = TokenUsage { cache_write_5m: 1_000_000, ..Default::default() };
348        assert!((p.cost(&u) - 2.5).abs() < 1e-9);
349    }
350
351    #[test]
352    fn a_user_file_prices_a_new_model_and_corrects_a_stale_one() {
353        let user = r#"
354            [[model]]
355            prefix = "gpt-5-codex"
356            input = 1.25
357            output = 10.0
358            cache_read = 0.125
359
360            [[model]]
361            prefix = "claude-sonnet-5"
362            input = 99.0
363            output = 99.0
364            cache_read = 9.0
365        "#;
366        let t = build(BUILTIN, Some((user, PathBuf::from("/tmp/prices.toml"))));
367        assert!(t.warnings.is_empty(), "{:?}", t.warnings);
368
369        // A model the built-in table has never heard of is now priced.
370        let p = t.lookup("gpt-5-codex-20260101").expect("new prefix is added");
371        assert_eq!(p.input, 1.25);
372        assert_eq!(p.cache_write_1h, 2.5, "cache writes still default off input");
373
374        // A stale built-in price is replaced, not duplicated.
375        assert_eq!(t.lookup("claude-sonnet-5").unwrap().input, 99.0);
376        assert_eq!(t.entries.iter().filter(|e| e.prefix == "claude-sonnet-5").count(), 1);
377        assert_eq!(t.entries.iter().filter(|e| e.origin == Origin::User).count(), 2);
378
379        // Everything not overridden is untouched.
380        assert_eq!(t.lookup("claude-opus-5").unwrap().input, 5.0);
381    }
382
383    #[test]
384    fn explicit_cache_write_prices_win_over_the_anthropic_default() {
385        let user = r#"
386            [[model]]
387            prefix = "some-vendor-model"
388            input = 4.0
389            output = 8.0
390            cache_read = 0.4
391            cache_write_5m = 0.0
392            cache_write_1h = 0.0
393        "#;
394        let t = build(BUILTIN, Some((user, PathBuf::from("/tmp/p.toml"))));
395        let p = t.lookup("some-vendor-model").unwrap();
396        assert_eq!(p.cache_write_5m, 0.0, "a vendor that does not charge for cache writes can say so");
397        assert_eq!(p.cache_write_1h, 0.0);
398    }
399
400    #[test]
401    fn a_broken_user_file_is_reported_and_the_builtins_survive() {
402        let t = build(BUILTIN, Some(("this is not toml {{{", PathBuf::from("/tmp/bad.toml"))));
403        assert_eq!(t.lookup("claude-opus-5").unwrap().input, 5.0, "built-in prices must not go down with it");
404        assert_eq!(t.warnings.len(), 1);
405        assert!(t.warnings[0].contains("/tmp/bad.toml"), "{:?}", t.warnings);
406
407        // A file that parses but is missing a required field is equally loud.
408        let t = build(BUILTIN, Some(("[[model]]\nprefix = \"x\"\ninput = 1.0\n", PathBuf::from("/tmp/partial.toml"))));
409        assert_eq!(t.warnings.len(), 1, "a missing price is not a zero price");
410        assert!(t.lookup("x").is_none());
411    }
412}