Skip to main content

obol_core/
lib.rs

1//! obol-core: parse agent transcripts and estimate token cost.
2
3pub 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/// Report from a pricing refresh.
18#[derive(Debug, serde::Serialize)]
19pub struct RefreshReport {
20    pub models: usize,
21    pub as_of: String,
22    pub written_to: PathBuf,
23}
24
25/// Resolve the price snapshot. Explicit OBOL_PRICING_DIR wins absolutely; otherwise
26/// pick whichever of {on-disk current.json, embedded} has the newer `as_of`, on-disk
27/// winning ties; embedded is the floor.
28fn 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            // Compare parsed stamps, never raw strings; a local snapshot with an
39            // unparseable as_of (pre-validation era) loses to the embedded floor.
40            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
48/// Estimate the cost of a transcript file under the given dialect. Loads the active
49/// price snapshot (bundled fallback) and prices the parsed usage.
50pub 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
57/// Fetch the LiteLLM sheet and write it as the active snapshot. `as_of` is the
58/// caller's stamp — `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SSZ` (the library has no
59/// clock) — validated before any network or disk I/O.
60pub 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)?; // {litellm: …}
63    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(&current)?;
72    Ok(RefreshReport {
73        models,
74        as_of: as_of.to_string(),
75        written_to: current,
76    })
77}
78
79/// Test-only serialization for env-var-mutating tests, shared crate-wide.
80///
81/// Several tests mutate process-global env vars (OBOL_PRICING_DIR, XDG_DATA_HOME)
82/// and the on-disk snapshot they resolve to. Cargo runs them on multiple threads
83/// in one process, so without serialization they race across module boundaries:
84/// one test's `set_var` is torn down by another's `remove_var` mid-body, and a
85/// `store.save(current_path())` then lands in the developer's REAL
86/// ~/.local/share/obol — leaking a fixture snapshot (e.g. the 2099 stamp) that
87/// out-ranks every real one. Every test touching env holds this lock for its whole
88/// body. Because it spans modules (lib.rs + pricing/store.rs), it must live at the
89/// crate root, not inside one test module.
90#[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        // Recover rather than propagate if a prior test panicked while holding it;
96        // a poisoned lock must not cascade unrelated failures.
97        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        // seed the store from the sample sheet
130        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        // Write the Claude fixture to a real file, detect dialect, then price.
165        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        // Force "no on-disk snapshot" hermetically: point XDG at an empty dir so
184        // `current_path()` resolves to a nonexistent file and the embedded snapshot
185        // is used. (Setting OBOL_PRICING_DIR instead would take the explicit-override
186        // branch and error PricingTablesMissing rather than fall back to embedded.)
187        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        // Hermetic: point XDG at an empty dir so we price against the embedded
233        // snapshot, not whatever the developer happens to have refreshed locally.
234        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        // "junk-zzzz" sorts lexicographically above any ISO date, which is exactly
260        // the bug: precedence must be decided by parsed stamps, not raw strings.
261        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}