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_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#[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#[derive(Debug, Deserialize)]
82struct FileServerTools {
83 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 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#[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 pub web_search_per_1k: Option<f64>,
122 pub web_search_origin: Option<Origin>,
123}
124
125impl Table {
126 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 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
158pub 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 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
207pub 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
219pub 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
235pub 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 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 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 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 #[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 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 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 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 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 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 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 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}