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}
64
65#[derive(Debug, Deserialize)]
66struct FileModel {
67 prefix: String,
68 input: f64,
69 output: f64,
70 cache_read: f64,
71 cache_write_5m: Option<f64>,
74 cache_write_1h: Option<f64>,
75}
76
77impl FileModel {
78 fn price(&self) -> Price {
79 Price {
80 input: self.input,
81 output: self.output,
82 cache_read: self.cache_read,
83 cache_write_5m: self.cache_write_5m.unwrap_or(self.input * 1.25),
84 cache_write_1h: self.cache_write_1h.unwrap_or(self.input * 2.0),
85 }
86 }
87}
88
89#[derive(Debug, Clone, Default)]
93pub struct Table {
94 pub entries: Vec<Entry>,
95 pub updated: Option<String>,
96 pub user_path: Option<PathBuf>,
97 pub warnings: Vec<String>,
98}
99
100impl Table {
101 pub fn lookup(&self, model: &str) -> Option<Price> {
102 let m = model.trim().to_ascii_lowercase();
103 let m = m.strip_prefix("anthropic.").unwrap_or(&m);
104 let m = m.strip_prefix("us.anthropic.").unwrap_or(m);
105 self.entries.iter().filter(|e| m.starts_with(&e.prefix)).max_by_key(|e| e.prefix.len()).map(|e| e.price)
106 }
107}
108
109fn parse(text: &str) -> Result<FileTable, toml::de::Error> {
110 toml::from_str(text)
111}
112
113pub fn build(builtin: &str, user: Option<(&str, PathBuf)>) -> Table {
117 let mut table = Table::default();
118 match parse(builtin) {
119 Ok(f) => {
120 table.updated = f.updated;
121 table.entries = f
122 .model
123 .into_iter()
124 .map(|m| Entry { prefix: m.prefix.to_ascii_lowercase(), price: m.price(), origin: Origin::Builtin })
125 .collect();
126 }
127 Err(e) => table.warnings.push(format!("built-in price table is invalid: {e}")),
130 }
131
132 let Some((text, path)) = user else { return table };
133 table.user_path = Some(path.clone());
134 match parse(text) {
135 Ok(f) => {
136 for m in f.model {
137 let prefix = m.prefix.to_ascii_lowercase();
138 let entry = Entry { prefix: prefix.clone(), price: m.price(), origin: Origin::User };
139 match table.entries.iter().position(|e| e.prefix == prefix) {
140 Some(i) => table.entries[i] = entry,
141 None => table.entries.push(entry),
142 }
143 }
144 }
145 Err(e) => table.warnings.push(format!("{}: ignored, {}", path.display(), first_line(&e.to_string()))),
146 }
147 table
148}
149
150fn first_line(s: &str) -> String {
151 s.lines().next().unwrap_or(s).to_string()
152}
153
154pub fn user_price_path() -> Option<PathBuf> {
156 if let Some(p) = std::env::var_os("AGENT_TOP_PRICES") {
157 return Some(PathBuf::from(p));
158 }
159 let dir = match std::env::var_os("XDG_CONFIG_HOME") {
160 Some(d) => PathBuf::from(d),
161 None => PathBuf::from(std::env::var_os("HOME")?).join(".config"),
162 };
163 Some(dir.join("agent-top").join("prices.toml"))
164}
165
166pub fn builtin_table() -> &'static Table {
170 static BUILTIN_TABLE: OnceLock<Table> = OnceLock::new();
171 BUILTIN_TABLE.get_or_init(|| build(BUILTIN, None))
172}
173
174pub fn table() -> &'static Table {
175 static TABLE: OnceLock<Table> = OnceLock::new();
176 TABLE.get_or_init(|| {
177 let user = user_price_path().and_then(|p| std::fs::read_to_string(&p).ok().map(|t| (t, p)));
178 build(BUILTIN, user.as_ref().map(|(t, p)| (t.as_str(), p.clone())))
179 })
180}
181
182pub fn price_for(model: &str) -> Option<Price> {
185 table().lookup(model)
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 fn builtin() -> Table {
193 build(BUILTIN, None)
194 }
195
196 #[test]
197 fn ships_a_valid_builtin_table() {
198 let t = builtin();
199 assert!(t.warnings.is_empty(), "{:?}", t.warnings);
200 assert_eq!(t.updated.as_deref(), Some("2026-06-24"));
201 assert!(t.entries.len() >= 11);
202 assert!(t.entries.iter().all(|e| e.origin == Origin::Builtin));
203 }
204
205 #[test]
206 fn longest_prefix_wins() {
207 let t = builtin();
208 assert_eq!(t.lookup("claude-fable-5-1").unwrap().cache_read, 0.25);
209 assert_eq!(t.lookup("claude-fable-5").unwrap().cache_read, 1.0);
210 assert_eq!(t.lookup("claude-sonnet-4-6-20251114").unwrap().input, 3.0);
211 assert_eq!(t.lookup("us.anthropic.claude-opus-5").unwrap().input, 5.0);
212 assert!(t.lookup("gpt-5-codex").is_none());
213 assert!(t.lookup("<synthetic>").is_none());
214 }
215
216 #[test]
217 fn cost_arithmetic() {
218 let t = builtin();
219 let p = t.lookup("claude-sonnet-5").unwrap();
220 let u = TokenUsage { input: 1_000_000, output: 1_000_000, ..Default::default() };
221 assert!((p.cost(&u) - 12.0).abs() < 1e-9);
222 let u = TokenUsage { cache_write_1h: 1_000_000, ..Default::default() };
224 assert!((p.cost(&u) - 4.0).abs() < 1e-9);
225 let u = TokenUsage { cache_write_5m: 1_000_000, ..Default::default() };
226 assert!((p.cost(&u) - 2.5).abs() < 1e-9);
227 }
228
229 #[test]
230 fn a_user_file_prices_a_new_model_and_corrects_a_stale_one() {
231 let user = r#"
232 [[model]]
233 prefix = "gpt-5-codex"
234 input = 1.25
235 output = 10.0
236 cache_read = 0.125
237
238 [[model]]
239 prefix = "claude-sonnet-5"
240 input = 99.0
241 output = 99.0
242 cache_read = 9.0
243 "#;
244 let t = build(BUILTIN, Some((user, PathBuf::from("/tmp/prices.toml"))));
245 assert!(t.warnings.is_empty(), "{:?}", t.warnings);
246
247 let p = t.lookup("gpt-5-codex-20260101").expect("new prefix is added");
249 assert_eq!(p.input, 1.25);
250 assert_eq!(p.cache_write_1h, 2.5, "cache writes still default off input");
251
252 assert_eq!(t.lookup("claude-sonnet-5").unwrap().input, 99.0);
254 assert_eq!(t.entries.iter().filter(|e| e.prefix == "claude-sonnet-5").count(), 1);
255 assert_eq!(t.entries.iter().filter(|e| e.origin == Origin::User).count(), 2);
256
257 assert_eq!(t.lookup("claude-opus-5").unwrap().input, 5.0);
259 }
260
261 #[test]
262 fn explicit_cache_write_prices_win_over_the_anthropic_default() {
263 let user = r#"
264 [[model]]
265 prefix = "some-vendor-model"
266 input = 4.0
267 output = 8.0
268 cache_read = 0.4
269 cache_write_5m = 0.0
270 cache_write_1h = 0.0
271 "#;
272 let t = build(BUILTIN, Some((user, PathBuf::from("/tmp/p.toml"))));
273 let p = t.lookup("some-vendor-model").unwrap();
274 assert_eq!(p.cache_write_5m, 0.0, "a vendor that does not charge for cache writes can say so");
275 assert_eq!(p.cache_write_1h, 0.0);
276 }
277
278 #[test]
279 fn a_broken_user_file_is_reported_and_the_builtins_survive() {
280 let t = build(BUILTIN, Some(("this is not toml {{{", PathBuf::from("/tmp/bad.toml"))));
281 assert_eq!(t.lookup("claude-opus-5").unwrap().input, 5.0, "built-in prices must not go down with it");
282 assert_eq!(t.warnings.len(), 1);
283 assert!(t.warnings[0].contains("/tmp/bad.toml"), "{:?}", t.warnings);
284
285 let t = build(BUILTIN, Some(("[[model]]\nprefix = \"x\"\ninput = 1.0\n", PathBuf::from("/tmp/partial.toml"))));
287 assert_eq!(t.warnings.len(), 1, "a missing price is not a zero price");
288 assert!(t.lookup("x").is_none());
289 }
290}