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)]
80mod api_tests {
81 use super::*;
82
83 #[test]
84 fn estimate_cost_on_bytes_with_missing_tables_errors() {
85 std::env::set_var("OBOL_PRICING_DIR", "/nonexistent/obol-xyz");
86 let tmp = std::env::temp_dir().join(format!("obol-t-{}-{}", std::process::id(), line!()));
87 std::fs::write(
88 &tmp,
89 include_bytes!("../tests/fixtures/claude-mini.jsonl").as_slice(),
90 )
91 .unwrap();
92 assert!(matches!(
93 estimate_cost(&tmp, Dialect::Claude),
94 Err(ObolError::PricingTablesMissing(_))
95 ));
96 std::fs::remove_file(&tmp).ok();
97 std::env::remove_var("OBOL_PRICING_DIR");
98 }
99
100 #[test]
101 fn estimate_cost_end_to_end_with_seeded_store() {
102 let dir = std::env::temp_dir().join(format!("obol-api-{}", std::process::id()));
103 std::env::set_var("OBOL_PRICING_DIR", &dir);
104 let store = pricing::refresh::normalize_litellm(
106 include_bytes!("../tests/fixtures/litellm-sample.json"),
107 "2026-06-04",
108 )
109 .unwrap();
110 store.save(&pricing::current_path()).unwrap();
111
112 let tmp = std::env::temp_dir().join(format!("obol-t-{}-{}", std::process::id(), line!()));
113 std::fs::write(
114 &tmp,
115 include_bytes!("../tests/fixtures/claude-mini.jsonl").as_slice(),
116 )
117 .unwrap();
118 let est = estimate_cost(&tmp, Dialect::Claude).unwrap();
119 assert!(est.total_usd > 0.0);
120 assert_eq!(est.pricing_as_of, "2026-06-04");
121 std::fs::remove_file(&tmp).ok();
122
123 std::fs::remove_dir_all(&dir).ok();
124 std::env::remove_var("OBOL_PRICING_DIR");
125 }
126
127 #[test]
128 fn estimate_cost_from_path_then_detect() {
129 let dir = std::env::temp_dir().join(format!("obol-path-{}", std::process::id()));
130 std::env::set_var("OBOL_PRICING_DIR", &dir);
131 let store = pricing::refresh::normalize_litellm(
132 include_bytes!("../tests/fixtures/litellm-sample.json"),
133 "2026-06-04",
134 )
135 .unwrap();
136 store.save(&pricing::current_path()).unwrap();
137
138 let transcript = dir.join("session.jsonl");
140 std::fs::write(
141 &transcript,
142 include_bytes!("../tests/fixtures/claude-mini.jsonl"),
143 )
144 .unwrap();
145 let bytes = std::fs::read(&transcript).unwrap();
146 let d = transcript::detect(&bytes).unwrap();
147 let est = estimate_cost(&transcript, d).unwrap();
148 assert!(est.total_usd > 0.0);
149
150 std::fs::remove_dir_all(&dir).ok();
151 std::env::remove_var("OBOL_PRICING_DIR");
152 }
153
154 #[test]
155 fn falls_back_to_embedded_when_no_local_snapshot() {
156 let xdg = std::env::temp_dir().join(format!("obol-xdg-{}", std::process::id()));
161 std::fs::create_dir_all(&xdg).unwrap();
162 std::env::remove_var("OBOL_PRICING_DIR");
163 std::env::set_var("XDG_DATA_HOME", &xdg);
164 let tmp = std::env::temp_dir().join(format!("obol-t-{}-{}", std::process::id(), line!()));
165 std::fs::write(
166 &tmp,
167 include_bytes!("../tests/fixtures/claude-mini.jsonl").as_slice(),
168 )
169 .unwrap();
170 let est = estimate_cost(&tmp, Dialect::Claude).unwrap();
171 assert_eq!(est.pricing_source, crate::model::PricingSource::Bundled);
172 assert!(est.total_usd > 0.0, "embedded snapshot should price claude");
173 std::fs::remove_file(&tmp).ok();
174 std::env::remove_var("XDG_DATA_HOME");
175 std::fs::remove_dir_all(&xdg).ok();
176 }
177
178 #[test]
179 fn explicit_override_uses_local_source() {
180 let dir = std::env::temp_dir().join(format!("obol-resolve-{}", std::process::id()));
181 std::env::set_var("OBOL_PRICING_DIR", &dir);
182 let store = pricing::refresh::normalize_litellm(
183 include_bytes!("../tests/fixtures/litellm-sample.json"),
184 "2099-01-01",
185 )
186 .unwrap();
187 store.save(&pricing::current_path()).unwrap();
188 let tmp = std::env::temp_dir().join(format!("obol-t-{}-{}", std::process::id(), line!()));
189 std::fs::write(
190 &tmp,
191 include_bytes!("../tests/fixtures/claude-mini.jsonl").as_slice(),
192 )
193 .unwrap();
194 let est = estimate_cost(&tmp, Dialect::Claude).unwrap();
195 assert_eq!(est.pricing_source, crate::model::PricingSource::Local);
196 std::fs::remove_file(&tmp).ok();
197 std::fs::remove_dir_all(&dir).ok();
198 std::env::remove_var("OBOL_PRICING_DIR");
199 }
200
201 #[test]
202 fn kimi_model_surfaces_unpriced_loudly() {
203 std::env::remove_var("OBOL_PRICING_DIR");
204 let tmp = std::env::temp_dir().join(format!("obol-t-{}-{}", std::process::id(), line!()));
205 std::fs::write(
206 &tmp,
207 include_bytes!("../tests/fixtures/kimi-mini.jsonl").as_slice(),
208 )
209 .unwrap();
210 let est = estimate_cost(&tmp, Dialect::Kimi).unwrap();
211 assert_eq!(est.total_usd, 0.0, "kimi-for-coding is unpriced -> $0");
212 assert!(
213 est.unpriced_models.contains(&"kimi-for-coding".to_string()),
214 "must name the unpriced model: {:?}",
215 est.unpriced_models
216 );
217 std::fs::remove_file(&tmp).ok();
218 }
219
220 #[test]
221 fn local_snapshot_with_invalid_as_of_loses_to_embedded() {
222 let xdg = std::env::temp_dir().join(format!("obol-xdg-junk-{}", std::process::id()));
225 std::fs::create_dir_all(&xdg).unwrap();
226 std::env::remove_var("OBOL_PRICING_DIR");
227 std::env::set_var("XDG_DATA_HOME", &xdg);
228 let store = pricing::refresh::normalize_litellm(
229 include_bytes!("../tests/fixtures/litellm-sample.json"),
230 "junk-zzzz",
231 )
232 .unwrap();
233 std::fs::create_dir_all(pricing::pricing_dir()).unwrap();
234 store.save(&pricing::current_path()).unwrap();
235 let tmp = std::env::temp_dir().join(format!("obol-t-{}-{}", std::process::id(), line!()));
236 std::fs::write(
237 &tmp,
238 include_bytes!("../tests/fixtures/claude-mini.jsonl").as_slice(),
239 )
240 .unwrap();
241 let est = estimate_cost(&tmp, Dialect::Claude).unwrap();
242 assert_eq!(
243 est.pricing_source,
244 crate::model::PricingSource::Bundled,
245 "a junk-stamped local snapshot must not beat the embedded floor"
246 );
247 std::fs::remove_file(&tmp).ok();
248 std::env::remove_var("XDG_DATA_HOME");
249 std::fs::remove_dir_all(&xdg).ok();
250 }
251
252 #[test]
253 fn local_datetime_stamp_beats_embedded_date() {
254 let xdg = std::env::temp_dir().join(format!("obol-xdg-dt-{}", std::process::id()));
255 std::fs::create_dir_all(&xdg).unwrap();
256 std::env::remove_var("OBOL_PRICING_DIR");
257 std::env::set_var("XDG_DATA_HOME", &xdg);
258 let store = pricing::refresh::normalize_litellm(
259 include_bytes!("../tests/fixtures/litellm-sample.json"),
260 "2099-01-01T08:30:00Z",
261 )
262 .unwrap();
263 std::fs::create_dir_all(pricing::pricing_dir()).unwrap();
264 store.save(&pricing::current_path()).unwrap();
265 let tmp = std::env::temp_dir().join(format!("obol-t-{}-{}", std::process::id(), line!()));
266 std::fs::write(
267 &tmp,
268 include_bytes!("../tests/fixtures/claude-mini.jsonl").as_slice(),
269 )
270 .unwrap();
271 let est = estimate_cost(&tmp, Dialect::Claude).unwrap();
272 assert_eq!(est.pricing_source, crate::model::PricingSource::Local);
273 std::fs::remove_file(&tmp).ok();
274 std::env::remove_var("XDG_DATA_HOME");
275 std::fs::remove_dir_all(&xdg).ok();
276 }
277
278 #[test]
279 fn refresh_rejects_invalid_as_of_before_any_network_or_disk_io() {
280 let xdg = std::env::temp_dir().join(format!("obol-xdg-rej-{}", std::process::id()));
281 std::fs::create_dir_all(&xdg).unwrap();
282 std::env::remove_var("OBOL_PRICING_DIR");
283 std::env::set_var("XDG_DATA_HOME", &xdg);
284 assert!(matches!(
285 refresh_pricing_tables("Apr-2027"),
286 Err(ObolError::InvalidAsOf(_))
287 ));
288 assert!(
289 !pricing::current_path().exists(),
290 "rejected refresh must not write a snapshot"
291 );
292 std::env::remove_var("XDG_DATA_HOME");
293 std::fs::remove_dir_all(&xdg).ok();
294 }
295
296 #[test]
297 fn refresh_report_serializes() {
298 let r = RefreshReport {
299 models: 7,
300 as_of: "2026-06-05".into(),
301 written_to: "/x/current.json".into(),
302 };
303 let v = serde_json::to_value(&r).unwrap();
304 assert_eq!(v["models"], 7);
305 assert_eq!(v["as_of"], "2026-06-05");
306 assert_eq!(v["written_to"], "/x/current.json");
307 }
308}