1use crate::model::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 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#[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#[derive(Debug, Deserialize)]
69struct FileServerTools {
70 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 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#[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 pub web_search_per_1k: Option<f64>,
109 pub web_search_origin: Option<Origin>,
110}
111
112impl Table {
113 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
132pub 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 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
181pub 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
193pub 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
209pub 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 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 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 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 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 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}