pub mod cost;
pub mod error;
pub mod model;
pub mod pricing;
pub mod transcript;
pub use error::ObolError;
pub use model::{
Approximation, CostEstimate, MessageUsage, ModelCost, PricingSource, Provider, TokenBuckets,
};
pub use transcript::Dialect;
use std::path::{Path, PathBuf};
#[derive(Debug, serde::Serialize)]
pub struct RefreshReport {
pub models: usize,
pub as_of: String,
pub written_to: PathBuf,
}
fn resolve_store() -> Result<(pricing::PriceStore, PricingSource), ObolError> {
if std::env::var_os("OBOL_PRICING_DIR").is_some() {
let store = pricing::PriceStore::load(&pricing::current_path())?;
return Ok((store, PricingSource::Local));
}
let embedded = pricing::embedded()?;
let embedded_key = pricing::as_of::sort_key(&embedded.as_of)?;
let local_path = pricing::current_path();
if local_path.exists() {
if let Ok(local) = pricing::PriceStore::load(&local_path) {
if pricing::as_of::sort_key(&local.as_of).is_ok_and(|k| k >= embedded_key) {
return Ok((local, PricingSource::Local));
}
}
}
Ok((embedded, PricingSource::Bundled))
}
pub fn estimate_cost(path: &Path, dialect: Dialect) -> Result<CostEstimate, ObolError> {
let (store, source_kind) = resolve_store()?;
let bytes = std::fs::read(path)?;
let usages = transcript::parse(&bytes, dialect)?;
Ok(cost::estimate(&usages, &store, source_kind))
}
pub fn refresh_pricing_tables(as_of: &str) -> Result<RefreshReport, ObolError> {
pricing::as_of::validate(as_of)?;
let mut store = pricing::refresh::fetch_litellm(as_of)?; let openrouter = pricing::refresh::fetch_openrouter()?;
store
.namespaces
.insert("openrouter".to_string(), openrouter);
let models: usize = store.namespaces.values().map(|m| m.len()).sum();
let dir = pricing::pricing_dir();
store.save(&dir.join(pricing::as_of::archive_file_name(as_of)))?;
let current = pricing::current_path();
store.save(¤t)?;
Ok(RefreshReport {
models,
as_of: as_of.to_string(),
written_to: current,
})
}
#[cfg(test)]
mod api_tests {
use super::*;
#[test]
fn estimate_cost_on_bytes_with_missing_tables_errors() {
std::env::set_var("OBOL_PRICING_DIR", "/nonexistent/obol-xyz");
let tmp = std::env::temp_dir().join(format!("obol-t-{}-{}", std::process::id(), line!()));
std::fs::write(
&tmp,
include_bytes!("../tests/fixtures/claude-mini.jsonl").as_slice(),
)
.unwrap();
assert!(matches!(
estimate_cost(&tmp, Dialect::Claude),
Err(ObolError::PricingTablesMissing(_))
));
std::fs::remove_file(&tmp).ok();
std::env::remove_var("OBOL_PRICING_DIR");
}
#[test]
fn estimate_cost_end_to_end_with_seeded_store() {
let dir = std::env::temp_dir().join(format!("obol-api-{}", std::process::id()));
std::env::set_var("OBOL_PRICING_DIR", &dir);
let store = pricing::refresh::normalize_litellm(
include_bytes!("../tests/fixtures/litellm-sample.json"),
"2026-06-04",
)
.unwrap();
store.save(&pricing::current_path()).unwrap();
let tmp = std::env::temp_dir().join(format!("obol-t-{}-{}", std::process::id(), line!()));
std::fs::write(
&tmp,
include_bytes!("../tests/fixtures/claude-mini.jsonl").as_slice(),
)
.unwrap();
let est = estimate_cost(&tmp, Dialect::Claude).unwrap();
assert!(est.total_usd > 0.0);
assert_eq!(est.pricing_as_of, "2026-06-04");
std::fs::remove_file(&tmp).ok();
std::fs::remove_dir_all(&dir).ok();
std::env::remove_var("OBOL_PRICING_DIR");
}
#[test]
fn estimate_cost_from_path_then_detect() {
let dir = std::env::temp_dir().join(format!("obol-path-{}", std::process::id()));
std::env::set_var("OBOL_PRICING_DIR", &dir);
let store = pricing::refresh::normalize_litellm(
include_bytes!("../tests/fixtures/litellm-sample.json"),
"2026-06-04",
)
.unwrap();
store.save(&pricing::current_path()).unwrap();
let transcript = dir.join("session.jsonl");
std::fs::write(
&transcript,
include_bytes!("../tests/fixtures/claude-mini.jsonl"),
)
.unwrap();
let bytes = std::fs::read(&transcript).unwrap();
let d = transcript::detect(&bytes).unwrap();
let est = estimate_cost(&transcript, d).unwrap();
assert!(est.total_usd > 0.0);
std::fs::remove_dir_all(&dir).ok();
std::env::remove_var("OBOL_PRICING_DIR");
}
#[test]
fn falls_back_to_embedded_when_no_local_snapshot() {
let xdg = std::env::temp_dir().join(format!("obol-xdg-{}", std::process::id()));
std::fs::create_dir_all(&xdg).unwrap();
std::env::remove_var("OBOL_PRICING_DIR");
std::env::set_var("XDG_DATA_HOME", &xdg);
let tmp = std::env::temp_dir().join(format!("obol-t-{}-{}", std::process::id(), line!()));
std::fs::write(
&tmp,
include_bytes!("../tests/fixtures/claude-mini.jsonl").as_slice(),
)
.unwrap();
let est = estimate_cost(&tmp, Dialect::Claude).unwrap();
assert_eq!(est.pricing_source, crate::model::PricingSource::Bundled);
assert!(est.total_usd > 0.0, "embedded snapshot should price claude");
std::fs::remove_file(&tmp).ok();
std::env::remove_var("XDG_DATA_HOME");
std::fs::remove_dir_all(&xdg).ok();
}
#[test]
fn explicit_override_uses_local_source() {
let dir = std::env::temp_dir().join(format!("obol-resolve-{}", std::process::id()));
std::env::set_var("OBOL_PRICING_DIR", &dir);
let store = pricing::refresh::normalize_litellm(
include_bytes!("../tests/fixtures/litellm-sample.json"),
"2099-01-01",
)
.unwrap();
store.save(&pricing::current_path()).unwrap();
let tmp = std::env::temp_dir().join(format!("obol-t-{}-{}", std::process::id(), line!()));
std::fs::write(
&tmp,
include_bytes!("../tests/fixtures/claude-mini.jsonl").as_slice(),
)
.unwrap();
let est = estimate_cost(&tmp, Dialect::Claude).unwrap();
assert_eq!(est.pricing_source, crate::model::PricingSource::Local);
std::fs::remove_file(&tmp).ok();
std::fs::remove_dir_all(&dir).ok();
std::env::remove_var("OBOL_PRICING_DIR");
}
#[test]
fn kimi_model_surfaces_unpriced_loudly() {
std::env::remove_var("OBOL_PRICING_DIR");
let tmp = std::env::temp_dir().join(format!("obol-t-{}-{}", std::process::id(), line!()));
std::fs::write(
&tmp,
include_bytes!("../tests/fixtures/kimi-mini.jsonl").as_slice(),
)
.unwrap();
let est = estimate_cost(&tmp, Dialect::Kimi).unwrap();
assert_eq!(est.total_usd, 0.0, "kimi-for-coding is unpriced -> $0");
assert!(
est.unpriced_models.contains(&"kimi-for-coding".to_string()),
"must name the unpriced model: {:?}",
est.unpriced_models
);
std::fs::remove_file(&tmp).ok();
}
#[test]
fn local_snapshot_with_invalid_as_of_loses_to_embedded() {
let xdg = std::env::temp_dir().join(format!("obol-xdg-junk-{}", std::process::id()));
std::fs::create_dir_all(&xdg).unwrap();
std::env::remove_var("OBOL_PRICING_DIR");
std::env::set_var("XDG_DATA_HOME", &xdg);
let store = pricing::refresh::normalize_litellm(
include_bytes!("../tests/fixtures/litellm-sample.json"),
"junk-zzzz",
)
.unwrap();
std::fs::create_dir_all(pricing::pricing_dir()).unwrap();
store.save(&pricing::current_path()).unwrap();
let tmp = std::env::temp_dir().join(format!("obol-t-{}-{}", std::process::id(), line!()));
std::fs::write(
&tmp,
include_bytes!("../tests/fixtures/claude-mini.jsonl").as_slice(),
)
.unwrap();
let est = estimate_cost(&tmp, Dialect::Claude).unwrap();
assert_eq!(
est.pricing_source,
crate::model::PricingSource::Bundled,
"a junk-stamped local snapshot must not beat the embedded floor"
);
std::fs::remove_file(&tmp).ok();
std::env::remove_var("XDG_DATA_HOME");
std::fs::remove_dir_all(&xdg).ok();
}
#[test]
fn local_datetime_stamp_beats_embedded_date() {
let xdg = std::env::temp_dir().join(format!("obol-xdg-dt-{}", std::process::id()));
std::fs::create_dir_all(&xdg).unwrap();
std::env::remove_var("OBOL_PRICING_DIR");
std::env::set_var("XDG_DATA_HOME", &xdg);
let store = pricing::refresh::normalize_litellm(
include_bytes!("../tests/fixtures/litellm-sample.json"),
"2099-01-01T08:30:00Z",
)
.unwrap();
std::fs::create_dir_all(pricing::pricing_dir()).unwrap();
store.save(&pricing::current_path()).unwrap();
let tmp = std::env::temp_dir().join(format!("obol-t-{}-{}", std::process::id(), line!()));
std::fs::write(
&tmp,
include_bytes!("../tests/fixtures/claude-mini.jsonl").as_slice(),
)
.unwrap();
let est = estimate_cost(&tmp, Dialect::Claude).unwrap();
assert_eq!(est.pricing_source, crate::model::PricingSource::Local);
std::fs::remove_file(&tmp).ok();
std::env::remove_var("XDG_DATA_HOME");
std::fs::remove_dir_all(&xdg).ok();
}
#[test]
fn refresh_rejects_invalid_as_of_before_any_network_or_disk_io() {
let xdg = std::env::temp_dir().join(format!("obol-xdg-rej-{}", std::process::id()));
std::fs::create_dir_all(&xdg).unwrap();
std::env::remove_var("OBOL_PRICING_DIR");
std::env::set_var("XDG_DATA_HOME", &xdg);
assert!(matches!(
refresh_pricing_tables("Apr-2027"),
Err(ObolError::InvalidAsOf(_))
));
assert!(
!pricing::current_path().exists(),
"rejected refresh must not write a snapshot"
);
std::env::remove_var("XDG_DATA_HOME");
std::fs::remove_dir_all(&xdg).ok();
}
#[test]
fn refresh_report_serializes() {
let r = RefreshReport {
models: 7,
as_of: "2026-06-05".into(),
written_to: "/x/current.json".into(),
};
let v = serde_json::to_value(&r).unwrap();
assert_eq!(v["models"], 7);
assert_eq!(v["as_of"], "2026-06-05");
assert_eq!(v["written_to"], "/x/current.json");
}
}