1use crate::model::{CostBreakdown, PriceSource, TokenUsage};
14use serde::Deserialize;
15use std::path::PathBuf;
16use std::sync::OnceLock;
17
18const 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 pub fn cost(&self, usage: &TokenUsage) -> f64 {
33 self.breakdown(usage).total()
34 }
35
36 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#[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#[derive(Debug, Deserialize)]
77struct FileServerTools {
78 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 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#[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 pub web_search_per_1k: Option<f64>,
117 pub web_search_origin: Option<Origin>,
118}
119
120impl Table {
121 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 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
153pub 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 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
202pub 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
214pub 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
230pub 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_eq!(t.lookup("gpt-5.6-luna").unwrap().output, 1.20);
265 assert_eq!(t.lookup("gpt-5.4-mini").unwrap().input, 0.75);
266 assert_eq!(t.lookup("gpt-5.4").unwrap().input, 2.50);
267 assert_eq!(t.lookup("gpt-5-codex").unwrap().input, 1.25, "resolves to gpt-5 by longest prefix");
268 assert!(t.lookup("llama-3-70b").is_none(), "a model from no vendor in the table is still unpriced");
269 assert_eq!(t.lookup("gemini-2.5-flash-lite").unwrap().output, 0.40);
272 assert_eq!(t.lookup("gemini-2.5-flash").unwrap().output, 2.50);
273 assert_eq!(t.lookup("gemini-2.5-pro").unwrap().cache_write_1h, 1.25);
274 assert_eq!(t.lookup("gemini-3.1-pro-preview").unwrap().input, 2.0);
275 assert!(t.lookup("<synthetic>").is_none());
276 }
277
278 #[test]
279 fn cost_arithmetic() {
280 let t = builtin();
281 let p = t.lookup("claude-sonnet-5").unwrap();
282 let u = TokenUsage { input: 1_000_000, output: 1_000_000, ..Default::default() };
283 assert!((p.cost(&u) - 12.0).abs() < 1e-9);
284 let u = TokenUsage { cache_write_1h: 1_000_000, ..Default::default() };
286 assert!((p.cost(&u) - 4.0).abs() < 1e-9);
287 let u = TokenUsage { cache_write_5m: 1_000_000, ..Default::default() };
288 assert!((p.cost(&u) - 2.5).abs() < 1e-9);
289 }
290
291 #[test]
292 fn a_user_file_prices_a_new_model_and_corrects_a_stale_one() {
293 let user = r#"
294 [[model]]
295 prefix = "gpt-5-codex"
296 input = 1.25
297 output = 10.0
298 cache_read = 0.125
299
300 [[model]]
301 prefix = "claude-sonnet-5"
302 input = 99.0
303 output = 99.0
304 cache_read = 9.0
305 "#;
306 let t = build(BUILTIN, Some((user, PathBuf::from("/tmp/prices.toml"))));
307 assert!(t.warnings.is_empty(), "{:?}", t.warnings);
308
309 let p = t.lookup("gpt-5-codex-20260101").expect("new prefix is added");
311 assert_eq!(p.input, 1.25);
312 assert_eq!(p.cache_write_1h, 2.5, "cache writes still default off input");
313
314 assert_eq!(t.lookup("claude-sonnet-5").unwrap().input, 99.0);
316 assert_eq!(t.entries.iter().filter(|e| e.prefix == "claude-sonnet-5").count(), 1);
317 assert_eq!(t.entries.iter().filter(|e| e.origin == Origin::User).count(), 2);
318
319 assert_eq!(t.lookup("claude-opus-5").unwrap().input, 5.0);
321 }
322
323 #[test]
324 fn explicit_cache_write_prices_win_over_the_anthropic_default() {
325 let user = r#"
326 [[model]]
327 prefix = "some-vendor-model"
328 input = 4.0
329 output = 8.0
330 cache_read = 0.4
331 cache_write_5m = 0.0
332 cache_write_1h = 0.0
333 "#;
334 let t = build(BUILTIN, Some((user, PathBuf::from("/tmp/p.toml"))));
335 let p = t.lookup("some-vendor-model").unwrap();
336 assert_eq!(p.cache_write_5m, 0.0, "a vendor that does not charge for cache writes can say so");
337 assert_eq!(p.cache_write_1h, 0.0);
338 }
339
340 #[test]
341 fn a_broken_user_file_is_reported_and_the_builtins_survive() {
342 let t = build(BUILTIN, Some(("this is not toml {{{", PathBuf::from("/tmp/bad.toml"))));
343 assert_eq!(t.lookup("claude-opus-5").unwrap().input, 5.0, "built-in prices must not go down with it");
344 assert_eq!(t.warnings.len(), 1);
345 assert!(t.warnings[0].contains("/tmp/bad.toml"), "{:?}", t.warnings);
346
347 let t = build(BUILTIN, Some(("[[model]]\nprefix = \"x\"\ninput = 1.0\n", PathBuf::from("/tmp/partial.toml"))));
349 assert_eq!(t.warnings.len(), 1, "a missing price is not a zero price");
350 assert!(t.lookup("x").is_none());
351 }
352}