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 local_path = pricing::current_path();
35 if local_path.exists() {
36 if let Ok(local) = pricing::PriceStore::load(&local_path) {
37 if local.as_of >= embedded.as_of {
38 return Ok((local, PricingSource::Local));
39 }
40 }
41 }
42 Ok((embedded, PricingSource::Bundled))
43}
44
45pub fn estimate_cost(path: &Path, dialect: Dialect) -> Result<CostEstimate, ObolError> {
48 let (store, source_kind) = resolve_store()?;
49 let bytes = std::fs::read(path)?;
50 let usages = transcript::parse(&bytes, dialect)?;
51 Ok(cost::estimate(&usages, &store, source_kind))
52}
53
54pub fn refresh_pricing_tables(as_of: &str) -> Result<RefreshReport, ObolError> {
57 let mut store = pricing::refresh::fetch_litellm(as_of)?; let openrouter = pricing::refresh::fetch_openrouter()?;
59 store
60 .namespaces
61 .insert("openrouter".to_string(), openrouter);
62 let models: usize = store.namespaces.values().map(|m| m.len()).sum();
63 let dir = pricing::pricing_dir();
64 store.save(&dir.join(format!("prices-{as_of}.json")))?;
65 let current = pricing::current_path();
66 store.save(¤t)?;
67 Ok(RefreshReport {
68 models,
69 as_of: as_of.to_string(),
70 written_to: current,
71 })
72}
73
74#[cfg(test)]
75mod api_tests {
76 use super::*;
77
78 #[test]
79 fn estimate_cost_on_bytes_with_missing_tables_errors() {
80 std::env::set_var("OBOL_PRICING_DIR", "/nonexistent/obol-xyz");
81 let tmp = std::env::temp_dir().join(format!("obol-t-{}-{}", std::process::id(), line!()));
82 std::fs::write(
83 &tmp,
84 include_bytes!("../tests/fixtures/claude-mini.jsonl").as_slice(),
85 )
86 .unwrap();
87 assert!(matches!(
88 estimate_cost(&tmp, Dialect::Claude),
89 Err(ObolError::PricingTablesMissing(_))
90 ));
91 std::fs::remove_file(&tmp).ok();
92 std::env::remove_var("OBOL_PRICING_DIR");
93 }
94
95 #[test]
96 fn estimate_cost_end_to_end_with_seeded_store() {
97 let dir = std::env::temp_dir().join(format!("obol-api-{}", std::process::id()));
98 std::env::set_var("OBOL_PRICING_DIR", &dir);
99 let store = pricing::refresh::normalize_litellm(
101 include_bytes!("../tests/fixtures/litellm-sample.json"),
102 "2026-06-04",
103 )
104 .unwrap();
105 store.save(&pricing::current_path()).unwrap();
106
107 let tmp = std::env::temp_dir().join(format!("obol-t-{}-{}", std::process::id(), line!()));
108 std::fs::write(
109 &tmp,
110 include_bytes!("../tests/fixtures/claude-mini.jsonl").as_slice(),
111 )
112 .unwrap();
113 let est = estimate_cost(&tmp, Dialect::Claude).unwrap();
114 assert!(est.total_usd > 0.0);
115 assert_eq!(est.pricing_as_of, "2026-06-04");
116 std::fs::remove_file(&tmp).ok();
117
118 std::fs::remove_dir_all(&dir).ok();
119 std::env::remove_var("OBOL_PRICING_DIR");
120 }
121
122 #[test]
123 fn estimate_cost_from_path_then_detect() {
124 let dir = std::env::temp_dir().join(format!("obol-path-{}", std::process::id()));
125 std::env::set_var("OBOL_PRICING_DIR", &dir);
126 let store = pricing::refresh::normalize_litellm(
127 include_bytes!("../tests/fixtures/litellm-sample.json"),
128 "2026-06-04",
129 )
130 .unwrap();
131 store.save(&pricing::current_path()).unwrap();
132
133 let transcript = dir.join("session.jsonl");
135 std::fs::write(
136 &transcript,
137 include_bytes!("../tests/fixtures/claude-mini.jsonl"),
138 )
139 .unwrap();
140 let bytes = std::fs::read(&transcript).unwrap();
141 let d = transcript::detect(&bytes).unwrap();
142 let est = estimate_cost(&transcript, d).unwrap();
143 assert!(est.total_usd > 0.0);
144
145 std::fs::remove_dir_all(&dir).ok();
146 std::env::remove_var("OBOL_PRICING_DIR");
147 }
148
149 #[test]
150 fn falls_back_to_embedded_when_no_local_snapshot() {
151 let xdg = std::env::temp_dir().join(format!("obol-xdg-{}", std::process::id()));
156 std::fs::create_dir_all(&xdg).unwrap();
157 std::env::remove_var("OBOL_PRICING_DIR");
158 std::env::set_var("XDG_DATA_HOME", &xdg);
159 let tmp = std::env::temp_dir().join(format!("obol-t-{}-{}", std::process::id(), line!()));
160 std::fs::write(
161 &tmp,
162 include_bytes!("../tests/fixtures/claude-mini.jsonl").as_slice(),
163 )
164 .unwrap();
165 let est = estimate_cost(&tmp, Dialect::Claude).unwrap();
166 assert_eq!(est.pricing_source, crate::model::PricingSource::Bundled);
167 assert!(est.total_usd > 0.0, "embedded snapshot should price claude");
168 std::fs::remove_file(&tmp).ok();
169 std::env::remove_var("XDG_DATA_HOME");
170 std::fs::remove_dir_all(&xdg).ok();
171 }
172
173 #[test]
174 fn explicit_override_uses_local_source() {
175 let dir = std::env::temp_dir().join(format!("obol-resolve-{}", std::process::id()));
176 std::env::set_var("OBOL_PRICING_DIR", &dir);
177 let store = pricing::refresh::normalize_litellm(
178 include_bytes!("../tests/fixtures/litellm-sample.json"),
179 "2099-01-01",
180 )
181 .unwrap();
182 store.save(&pricing::current_path()).unwrap();
183 let tmp = std::env::temp_dir().join(format!("obol-t-{}-{}", std::process::id(), line!()));
184 std::fs::write(
185 &tmp,
186 include_bytes!("../tests/fixtures/claude-mini.jsonl").as_slice(),
187 )
188 .unwrap();
189 let est = estimate_cost(&tmp, Dialect::Claude).unwrap();
190 assert_eq!(est.pricing_source, crate::model::PricingSource::Local);
191 std::fs::remove_file(&tmp).ok();
192 std::fs::remove_dir_all(&dir).ok();
193 std::env::remove_var("OBOL_PRICING_DIR");
194 }
195
196 #[test]
197 fn kimi_model_surfaces_unpriced_loudly() {
198 std::env::remove_var("OBOL_PRICING_DIR");
199 let tmp = std::env::temp_dir().join(format!("obol-t-{}-{}", std::process::id(), line!()));
200 std::fs::write(
201 &tmp,
202 include_bytes!("../tests/fixtures/kimi-mini.jsonl").as_slice(),
203 )
204 .unwrap();
205 let est = estimate_cost(&tmp, Dialect::Kimi).unwrap();
206 assert_eq!(est.total_usd, 0.0, "kimi-for-coding is unpriced -> $0");
207 assert!(
208 est.unpriced_models.contains(&"kimi-for-coding".to_string()),
209 "must name the unpriced model: {:?}",
210 est.unpriced_models
211 );
212 std::fs::remove_file(&tmp).ok();
213 }
214
215 #[test]
216 fn refresh_report_serializes() {
217 let r = RefreshReport {
218 models: 7,
219 as_of: "2026-06-05".into(),
220 written_to: "/x/current.json".into(),
221 };
222 let v = serde_json::to_value(&r).unwrap();
223 assert_eq!(v["models"], 7);
224 assert_eq!(v["as_of"], "2026-06-05");
225 assert_eq!(v["written_to"], "/x/current.json");
226 }
227}