1pub mod cost;
4pub mod error;
5pub mod model;
6pub mod pricing;
7pub mod transcript;
8
9pub use error::ObolError;
10pub use model::{
11 Approximation, CostEstimate, MessageUsage, ModelCost, PricingSource, Provider, TokenBuckets,
12};
13pub use transcript::Dialect;
14
15use std::path::{Path, PathBuf};
16
17#[derive(Debug, serde::Serialize)]
19pub struct RefreshReport {
20 pub models: usize,
21 pub as_of: String,
22 pub written_to: PathBuf,
23}
24
25fn resolve_store() -> Result<(pricing::PriceStore, PricingSource), ObolError> {
29 if std::env::var_os("OBOL_PRICING_DIR").is_some() {
30 let store = pricing::PriceStore::load(&pricing::current_path())?;
31 return Ok((store, PricingSource::Local));
32 }
33 let embedded = pricing::embedded()?;
34 let embedded_key = pricing::as_of::sort_key(&embedded.as_of)?;
35 let local_path = pricing::current_path();
36 if local_path.exists() {
37 if let Ok(local) = pricing::PriceStore::load(&local_path) {
38 if pricing::as_of::sort_key(&local.as_of).is_ok_and(|k| k >= embedded_key) {
41 return Ok((local, PricingSource::Local));
42 }
43 }
44 }
45 Ok((embedded, PricingSource::Bundled))
46}
47
48pub fn estimate_cost(path: &Path, dialect: Dialect) -> Result<CostEstimate, ObolError> {
51 let (store, source_kind) = resolve_store()?;
52 let bytes = std::fs::read(path)?;
53 let usages = transcript::parse(&bytes, dialect)?;
54 Ok(cost::estimate(&usages, &store, source_kind))
55}
56
57pub fn refresh_pricing_tables(as_of: &str) -> Result<RefreshReport, ObolError> {
61 pricing::as_of::validate(as_of)?;
62 let mut store = pricing::refresh::fetch_litellm(as_of)?; let openrouter = pricing::refresh::fetch_openrouter()?;
64 store
65 .namespaces
66 .insert("openrouter".to_string(), openrouter);
67 let models: usize = store.namespaces.values().map(|m| m.len()).sum();
68 let dir = pricing::pricing_dir();
69 store.save(&dir.join(pricing::as_of::archive_file_name(as_of)))?;
70 let current = pricing::current_path();
71 store.save(¤t)?;
72 Ok(RefreshReport {
73 models,
74 as_of: as_of.to_string(),
75 written_to: current,
76 })
77}
78
79#[cfg(test)]
91pub(crate) mod test_env {
92 use std::sync::{Mutex, MutexGuard};
93 static ENV_LOCK: Mutex<()> = Mutex::new(());
94 pub(crate) fn env_lock() -> MutexGuard<'static, ()> {
95 ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
98 }
99}
100
101#[cfg(test)]
102mod api_tests {
103 use super::*;
104 use crate::test_env::env_lock;
105
106 #[test]
107 fn estimate_cost_on_bytes_with_missing_tables_errors() {
108 let _env = env_lock();
109 std::env::set_var("OBOL_PRICING_DIR", "/nonexistent/obol-xyz");
110 let tmp = std::env::temp_dir().join(format!("obol-t-{}-{}", std::process::id(), line!()));
111 std::fs::write(
112 &tmp,
113 include_bytes!("../tests/fixtures/claude-mini.jsonl").as_slice(),
114 )
115 .unwrap();
116 assert!(matches!(
117 estimate_cost(&tmp, Dialect::Claude),
118 Err(ObolError::PricingTablesMissing(_))
119 ));
120 std::fs::remove_file(&tmp).ok();
121 std::env::remove_var("OBOL_PRICING_DIR");
122 }
123
124 #[test]
125 fn estimate_cost_end_to_end_with_seeded_store() {
126 let _env = env_lock();
127 let dir = std::env::temp_dir().join(format!("obol-api-{}", std::process::id()));
128 std::env::set_var("OBOL_PRICING_DIR", &dir);
129 let store = pricing::refresh::normalize_litellm(
131 include_bytes!("../tests/fixtures/litellm-sample.json"),
132 "2026-06-04",
133 )
134 .unwrap();
135 store.save(&pricing::current_path()).unwrap();
136
137 let tmp = std::env::temp_dir().join(format!("obol-t-{}-{}", std::process::id(), line!()));
138 std::fs::write(
139 &tmp,
140 include_bytes!("../tests/fixtures/claude-mini.jsonl").as_slice(),
141 )
142 .unwrap();
143 let est = estimate_cost(&tmp, Dialect::Claude).unwrap();
144 assert!(est.total_usd > 0.0);
145 assert_eq!(est.pricing_as_of, "2026-06-04");
146 std::fs::remove_file(&tmp).ok();
147
148 std::fs::remove_dir_all(&dir).ok();
149 std::env::remove_var("OBOL_PRICING_DIR");
150 }
151
152 #[test]
153 fn estimate_cost_from_path_then_detect() {
154 let _env = env_lock();
155 let dir = std::env::temp_dir().join(format!("obol-path-{}", std::process::id()));
156 std::env::set_var("OBOL_PRICING_DIR", &dir);
157 let store = pricing::refresh::normalize_litellm(
158 include_bytes!("../tests/fixtures/litellm-sample.json"),
159 "2026-06-04",
160 )
161 .unwrap();
162 store.save(&pricing::current_path()).unwrap();
163
164 let transcript = dir.join("session.jsonl");
166 std::fs::write(
167 &transcript,
168 include_bytes!("../tests/fixtures/claude-mini.jsonl"),
169 )
170 .unwrap();
171 let bytes = std::fs::read(&transcript).unwrap();
172 let d = transcript::detect(&bytes).unwrap();
173 let est = estimate_cost(&transcript, d).unwrap();
174 assert!(est.total_usd > 0.0);
175
176 std::fs::remove_dir_all(&dir).ok();
177 std::env::remove_var("OBOL_PRICING_DIR");
178 }
179
180 #[test]
181 fn falls_back_to_embedded_when_no_local_snapshot() {
182 let _env = env_lock();
183 let xdg = std::env::temp_dir().join(format!("obol-xdg-{}", std::process::id()));
188 std::fs::create_dir_all(&xdg).unwrap();
189 std::env::remove_var("OBOL_PRICING_DIR");
190 std::env::set_var("XDG_DATA_HOME", &xdg);
191 let tmp = std::env::temp_dir().join(format!("obol-t-{}-{}", std::process::id(), line!()));
192 std::fs::write(
193 &tmp,
194 include_bytes!("../tests/fixtures/claude-mini.jsonl").as_slice(),
195 )
196 .unwrap();
197 let est = estimate_cost(&tmp, Dialect::Claude).unwrap();
198 assert_eq!(est.pricing_source, crate::model::PricingSource::Bundled);
199 assert!(est.total_usd > 0.0, "embedded snapshot should price claude");
200 std::fs::remove_file(&tmp).ok();
201 std::env::remove_var("XDG_DATA_HOME");
202 std::fs::remove_dir_all(&xdg).ok();
203 }
204
205 #[test]
206 fn explicit_override_uses_local_source() {
207 let _env = env_lock();
208 let dir = std::env::temp_dir().join(format!("obol-resolve-{}", std::process::id()));
209 std::env::set_var("OBOL_PRICING_DIR", &dir);
210 let store = pricing::refresh::normalize_litellm(
211 include_bytes!("../tests/fixtures/litellm-sample.json"),
212 "2099-01-01",
213 )
214 .unwrap();
215 store.save(&pricing::current_path()).unwrap();
216 let tmp = std::env::temp_dir().join(format!("obol-t-{}-{}", std::process::id(), line!()));
217 std::fs::write(
218 &tmp,
219 include_bytes!("../tests/fixtures/claude-mini.jsonl").as_slice(),
220 )
221 .unwrap();
222 let est = estimate_cost(&tmp, Dialect::Claude).unwrap();
223 assert_eq!(est.pricing_source, crate::model::PricingSource::Local);
224 std::fs::remove_file(&tmp).ok();
225 std::fs::remove_dir_all(&dir).ok();
226 std::env::remove_var("OBOL_PRICING_DIR");
227 }
228
229 #[test]
230 fn kimi_model_surfaces_unpriced_loudly() {
231 let _env = env_lock();
232 let xdg = std::env::temp_dir().join(format!("obol-xdg-kimi-{}", std::process::id()));
235 std::fs::create_dir_all(&xdg).unwrap();
236 std::env::remove_var("OBOL_PRICING_DIR");
237 std::env::set_var("XDG_DATA_HOME", &xdg);
238 let tmp = std::env::temp_dir().join(format!("obol-t-{}-{}", std::process::id(), line!()));
239 std::fs::write(
240 &tmp,
241 include_bytes!("../tests/fixtures/kimi-mini.jsonl").as_slice(),
242 )
243 .unwrap();
244 let est = estimate_cost(&tmp, Dialect::Kimi).unwrap();
245 assert_eq!(est.total_usd, 0.0, "kimi-for-coding is unpriced -> $0");
246 assert!(
247 est.unpriced_models.contains(&"kimi-for-coding".to_string()),
248 "must name the unpriced model: {:?}",
249 est.unpriced_models
250 );
251 std::fs::remove_file(&tmp).ok();
252 std::env::remove_var("XDG_DATA_HOME");
253 std::fs::remove_dir_all(&xdg).ok();
254 }
255
256 #[test]
257 fn local_snapshot_with_invalid_as_of_loses_to_embedded() {
258 let _env = env_lock();
259 let xdg = std::env::temp_dir().join(format!("obol-xdg-junk-{}", std::process::id()));
262 std::fs::create_dir_all(&xdg).unwrap();
263 std::env::remove_var("OBOL_PRICING_DIR");
264 std::env::set_var("XDG_DATA_HOME", &xdg);
265 let store = pricing::refresh::normalize_litellm(
266 include_bytes!("../tests/fixtures/litellm-sample.json"),
267 "junk-zzzz",
268 )
269 .unwrap();
270 std::fs::create_dir_all(pricing::pricing_dir()).unwrap();
271 store.save(&pricing::current_path()).unwrap();
272 let tmp = std::env::temp_dir().join(format!("obol-t-{}-{}", std::process::id(), line!()));
273 std::fs::write(
274 &tmp,
275 include_bytes!("../tests/fixtures/claude-mini.jsonl").as_slice(),
276 )
277 .unwrap();
278 let est = estimate_cost(&tmp, Dialect::Claude).unwrap();
279 assert_eq!(
280 est.pricing_source,
281 crate::model::PricingSource::Bundled,
282 "a junk-stamped local snapshot must not beat the embedded floor"
283 );
284 std::fs::remove_file(&tmp).ok();
285 std::env::remove_var("XDG_DATA_HOME");
286 std::fs::remove_dir_all(&xdg).ok();
287 }
288
289 #[test]
290 fn local_datetime_stamp_beats_embedded_date() {
291 let _env = env_lock();
292 let xdg = std::env::temp_dir().join(format!("obol-xdg-dt-{}", std::process::id()));
293 std::fs::create_dir_all(&xdg).unwrap();
294 std::env::remove_var("OBOL_PRICING_DIR");
295 std::env::set_var("XDG_DATA_HOME", &xdg);
296 let store = pricing::refresh::normalize_litellm(
297 include_bytes!("../tests/fixtures/litellm-sample.json"),
298 "2099-01-01T08:30:00Z",
299 )
300 .unwrap();
301 std::fs::create_dir_all(pricing::pricing_dir()).unwrap();
302 store.save(&pricing::current_path()).unwrap();
303 let tmp = std::env::temp_dir().join(format!("obol-t-{}-{}", std::process::id(), line!()));
304 std::fs::write(
305 &tmp,
306 include_bytes!("../tests/fixtures/claude-mini.jsonl").as_slice(),
307 )
308 .unwrap();
309 let est = estimate_cost(&tmp, Dialect::Claude).unwrap();
310 assert_eq!(est.pricing_source, crate::model::PricingSource::Local);
311 std::fs::remove_file(&tmp).ok();
312 std::env::remove_var("XDG_DATA_HOME");
313 std::fs::remove_dir_all(&xdg).ok();
314 }
315
316 #[test]
317 fn refresh_rejects_invalid_as_of_before_any_network_or_disk_io() {
318 let _env = env_lock();
319 let xdg = std::env::temp_dir().join(format!("obol-xdg-rej-{}", std::process::id()));
320 std::fs::create_dir_all(&xdg).unwrap();
321 std::env::remove_var("OBOL_PRICING_DIR");
322 std::env::set_var("XDG_DATA_HOME", &xdg);
323 assert!(matches!(
324 refresh_pricing_tables("Apr-2027"),
325 Err(ObolError::InvalidAsOf(_))
326 ));
327 assert!(
328 !pricing::current_path().exists(),
329 "rejected refresh must not write a snapshot"
330 );
331 std::env::remove_var("XDG_DATA_HOME");
332 std::fs::remove_dir_all(&xdg).ok();
333 }
334
335 #[test]
336 fn refresh_report_serializes() {
337 let r = RefreshReport {
338 models: 7,
339 as_of: "2026-06-05".into(),
340 written_to: "/x/current.json".into(),
341 };
342 let v = serde_json::to_value(&r).unwrap();
343 assert_eq!(v["models"], 7);
344 assert_eq!(v["as_of"], "2026-06-05");
345 assert_eq!(v["written_to"], "/x/current.json");
346 }
347}