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