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::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        const M: f64 = 1_000_000.0;
34        usage.input as f64 * self.input / M
35            + usage.cache_write_5m as f64 * self.cache_write_5m / M
36            + usage.cache_write_1h as f64 * self.cache_write_1h / M
37            + usage.cache_read as f64 * self.cache_read / M
38            + usage.output as f64 * self.output / M
39    }
40}
41
42/// Where an effective price came from, so `--prices` can show a user which of
43/// their overrides actually took effect.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Origin {
46    Builtin,
47    User,
48}
49
50#[derive(Debug, Clone)]
51pub struct Entry {
52    pub prefix: String,
53    pub price: Price,
54    pub origin: Origin,
55}
56
57#[derive(Debug, Deserialize)]
58struct FileTable {
59    #[serde(default)]
60    updated: Option<String>,
61    #[serde(default)]
62    model: Vec<FileModel>,
63    #[serde(default)]
64    server_tools: Option<FileServerTools>,
65}
66
67/// Server-side tools billed per call rather than per token.
68#[derive(Debug, Deserialize)]
69struct FileServerTools {
70    /// USD per 1,000 web searches.
71    web_search: Option<f64>,
72}
73
74#[derive(Debug, Deserialize)]
75struct FileModel {
76    prefix: String,
77    input: f64,
78    output: f64,
79    cache_read: f64,
80    /// Anthropic charges 1.25x input for the 5 minute TTL and 2x for the hour.
81    /// A vendor that prices cache writes differently sets these.
82    cache_write_5m: Option<f64>,
83    cache_write_1h: Option<f64>,
84}
85
86impl FileModel {
87    fn price(&self) -> Price {
88        Price {
89            input: self.input,
90            output: self.output,
91            cache_read: self.cache_read,
92            cache_write_5m: self.cache_write_5m.unwrap_or(self.input * 1.25),
93            cache_write_1h: self.cache_write_1h.unwrap_or(self.input * 2.0),
94        }
95    }
96}
97
98/// The effective table, plus anything the user should be told about how it was
99/// built. A bad user file must never take the built-in prices down with it, and
100/// must never be swallowed either: it is reported and the built-ins stand.
101#[derive(Debug, Clone, Default)]
102pub struct Table {
103    pub entries: Vec<Entry>,
104    pub updated: Option<String>,
105    pub user_path: Option<PathBuf>,
106    pub warnings: Vec<String>,
107    /// USD per 1,000 web searches, when the table prices them.
108    pub web_search_per_1k: Option<f64>,
109    pub web_search_origin: Option<Origin>,
110}
111
112impl Table {
113    /// USD for `n` web searches, or zero when the table does not price them.
114    /// Anthropic bills web search per search on top of the tokens it produces;
115    /// web fetch and code execution alongside it are free (checked 2026-09-04).
116    pub fn web_search_cost(&self, n: u64) -> f64 {
117        self.web_search_per_1k.map(|p| n as f64 * p / 1_000.0).unwrap_or(0.0)
118    }
119
120    pub fn lookup(&self, model: &str) -> Option<Price> {
121        let m = model.trim().to_ascii_lowercase();
122        let m = m.strip_prefix("anthropic.").unwrap_or(&m);
123        let m = m.strip_prefix("us.anthropic.").unwrap_or(m);
124        self.entries.iter().filter(|e| m.starts_with(&e.prefix)).max_by_key(|e| e.prefix.len()).map(|e| e.price)
125    }
126}
127
128fn parse(text: &str) -> Result<FileTable, toml::de::Error> {
129    toml::from_str(text)
130}
131
132/// Build a table from the embedded text and an optional user file. Pure, so the
133/// merge and every failure mode are testable without touching a real home
134/// directory.
135pub fn build(builtin: &str, user: Option<(&str, PathBuf)>) -> Table {
136    let mut table = Table::default();
137    match parse(builtin) {
138        Ok(f) => {
139            table.updated = f.updated;
140            table.entries = f
141                .model
142                .into_iter()
143                .map(|m| Entry { prefix: m.prefix.to_ascii_lowercase(), price: m.price(), origin: Origin::Builtin })
144                .collect();
145            if let Some(p) = f.server_tools.and_then(|t| t.web_search) {
146                table.web_search_per_1k = Some(p);
147                table.web_search_origin = Some(Origin::Builtin);
148            }
149        }
150        // Only reachable if the shipped file is broken, which is a bug here
151        // rather than anything a user can fix, but it must not panic.
152        Err(e) => table.warnings.push(format!("built-in price table is invalid: {e}")),
153    }
154
155    let Some((text, path)) = user else { return table };
156    table.user_path = Some(path.clone());
157    match parse(text) {
158        Ok(f) => {
159            for m in f.model {
160                let prefix = m.prefix.to_ascii_lowercase();
161                let entry = Entry { prefix: prefix.clone(), price: m.price(), origin: Origin::User };
162                match table.entries.iter().position(|e| e.prefix == prefix) {
163                    Some(i) => table.entries[i] = entry,
164                    None => table.entries.push(entry),
165                }
166            }
167            if let Some(p) = f.server_tools.and_then(|t| t.web_search) {
168                table.web_search_per_1k = Some(p);
169                table.web_search_origin = Some(Origin::User);
170            }
171        }
172        Err(e) => table.warnings.push(format!("{}: ignored, {}", path.display(), first_line(&e.to_string()))),
173    }
174    table
175}
176
177fn first_line(s: &str) -> String {
178    s.lines().next().unwrap_or(s).to_string()
179}
180
181/// `$AGENT_TOP_PRICES` wins, then the XDG config directory, then `~/.config`.
182pub fn user_price_path() -> Option<PathBuf> {
183    if let Some(p) = std::env::var_os("AGENT_TOP_PRICES") {
184        return Some(PathBuf::from(p));
185    }
186    let dir = match std::env::var_os("XDG_CONFIG_HOME") {
187        Some(d) => PathBuf::from(d),
188        None => PathBuf::from(std::env::var_os("HOME")?).join(".config"),
189    };
190    Some(dir.join("agent-top").join("prices.toml"))
191}
192
193/// The shipped table alone, with no user overrides merged in. Tests that
194/// assert costs use this: a suite whose results depend on the developer's home
195/// directory is worse than no suite.
196pub fn builtin_table() -> &'static Table {
197    static BUILTIN_TABLE: OnceLock<Table> = OnceLock::new();
198    BUILTIN_TABLE.get_or_init(|| build(BUILTIN, None))
199}
200
201pub fn table() -> &'static Table {
202    static TABLE: OnceLock<Table> = OnceLock::new();
203    TABLE.get_or_init(|| {
204        let user = user_price_path().and_then(|p| std::fs::read_to_string(&p).ok().map(|t| (t, p)));
205        build(BUILTIN, user.as_ref().map(|(t, p)| (t.as_str(), p.clone())))
206    })
207}
208
209/// Look up a price by model id. Date-suffixed ids (`claude-sonnet-4-6-20251114`)
210/// and vendor-prefixed ids (`anthropic.claude-opus-5`) resolve to the base model.
211pub fn price_for(model: &str) -> Option<Price> {
212    table().lookup(model)
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    fn builtin() -> Table {
220        build(BUILTIN, None)
221    }
222
223    #[test]
224    fn ships_a_valid_builtin_table() {
225        let t = builtin();
226        assert!(t.warnings.is_empty(), "{:?}", t.warnings);
227        assert_eq!(t.updated.as_deref(), Some("2026-06-24"));
228        assert_eq!(t.web_search_per_1k, Some(10.0));
229        assert!((t.web_search_cost(3) - 0.03).abs() < 1e-12);
230        assert!(t.entries.len() >= 11);
231        assert!(t.entries.iter().all(|e| e.origin == Origin::Builtin));
232    }
233
234    #[test]
235    fn longest_prefix_wins() {
236        let t = builtin();
237        assert_eq!(t.lookup("claude-fable-5-1").unwrap().cache_read, 0.25);
238        assert_eq!(t.lookup("claude-fable-5").unwrap().cache_read, 1.0);
239        assert_eq!(t.lookup("claude-sonnet-4-6-20251114").unwrap().input, 3.0);
240        assert_eq!(t.lookup("us.anthropic.claude-opus-5").unwrap().input, 5.0);
241        assert!(t.lookup("gpt-5-codex").is_none());
242        assert!(t.lookup("<synthetic>").is_none());
243    }
244
245    #[test]
246    fn cost_arithmetic() {
247        let t = builtin();
248        let p = t.lookup("claude-sonnet-5").unwrap();
249        let u = TokenUsage { input: 1_000_000, output: 1_000_000, ..Default::default() };
250        assert!((p.cost(&u) - 12.0).abs() < 1e-9);
251        // Cache writes default to Anthropic's multipliers: 2x input for an hour.
252        let u = TokenUsage { cache_write_1h: 1_000_000, ..Default::default() };
253        assert!((p.cost(&u) - 4.0).abs() < 1e-9);
254        let u = TokenUsage { cache_write_5m: 1_000_000, ..Default::default() };
255        assert!((p.cost(&u) - 2.5).abs() < 1e-9);
256    }
257
258    #[test]
259    fn a_user_file_prices_a_new_model_and_corrects_a_stale_one() {
260        let user = r#"
261            [[model]]
262            prefix = "gpt-5-codex"
263            input = 1.25
264            output = 10.0
265            cache_read = 0.125
266
267            [[model]]
268            prefix = "claude-sonnet-5"
269            input = 99.0
270            output = 99.0
271            cache_read = 9.0
272        "#;
273        let t = build(BUILTIN, Some((user, PathBuf::from("/tmp/prices.toml"))));
274        assert!(t.warnings.is_empty(), "{:?}", t.warnings);
275
276        // A model the built-in table has never heard of is now priced.
277        let p = t.lookup("gpt-5-codex-20260101").expect("new prefix is added");
278        assert_eq!(p.input, 1.25);
279        assert_eq!(p.cache_write_1h, 2.5, "cache writes still default off input");
280
281        // A stale built-in price is replaced, not duplicated.
282        assert_eq!(t.lookup("claude-sonnet-5").unwrap().input, 99.0);
283        assert_eq!(t.entries.iter().filter(|e| e.prefix == "claude-sonnet-5").count(), 1);
284        assert_eq!(t.entries.iter().filter(|e| e.origin == Origin::User).count(), 2);
285
286        // Everything not overridden is untouched.
287        assert_eq!(t.lookup("claude-opus-5").unwrap().input, 5.0);
288    }
289
290    #[test]
291    fn explicit_cache_write_prices_win_over_the_anthropic_default() {
292        let user = r#"
293            [[model]]
294            prefix = "some-vendor-model"
295            input = 4.0
296            output = 8.0
297            cache_read = 0.4
298            cache_write_5m = 0.0
299            cache_write_1h = 0.0
300        "#;
301        let t = build(BUILTIN, Some((user, PathBuf::from("/tmp/p.toml"))));
302        let p = t.lookup("some-vendor-model").unwrap();
303        assert_eq!(p.cache_write_5m, 0.0, "a vendor that does not charge for cache writes can say so");
304        assert_eq!(p.cache_write_1h, 0.0);
305    }
306
307    #[test]
308    fn a_broken_user_file_is_reported_and_the_builtins_survive() {
309        let t = build(BUILTIN, Some(("this is not toml {{{", PathBuf::from("/tmp/bad.toml"))));
310        assert_eq!(t.lookup("claude-opus-5").unwrap().input, 5.0, "built-in prices must not go down with it");
311        assert_eq!(t.warnings.len(), 1);
312        assert!(t.warnings[0].contains("/tmp/bad.toml"), "{:?}", t.warnings);
313
314        // A file that parses but is missing a required field is equally loud.
315        let t = build(BUILTIN, Some(("[[model]]\nprefix = \"x\"\ninput = 1.0\n", PathBuf::from("/tmp/partial.toml"))));
316        assert_eq!(t.warnings.len(), 1, "a missing price is not a zero price");
317        assert!(t.lookup("x").is_none());
318    }
319}