1pub mod cost;
4pub mod error;
5pub mod model;
6pub mod pricing;
7pub mod transcript;
8
9pub use error::ObolError;
10pub use model::{Approximation, CostEstimate, MessageUsage, ModelCost, Provider, TokenBuckets};
11pub use transcript::Dialect;
12
13use std::path::{Path, PathBuf};
14
15pub enum Source<'a> {
18 Path(&'a Path),
19 Bytes(&'a [u8]),
20}
21
22#[derive(Debug, serde::Serialize)]
24pub struct RefreshReport {
25 pub models: usize,
26 pub as_of: String,
27 pub written_to: PathBuf,
28}
29
30pub fn estimate_cost(source: Source, dialect: Option<Dialect>) -> Result<CostEstimate, ObolError> {
34 let store = pricing::PriceStore::load(&pricing::current_path())?;
35 let bytes: Vec<u8> = match source {
36 Source::Path(p) => std::fs::read(p)?,
37 Source::Bytes(b) => b.to_vec(),
38 };
39 let dialect = match dialect {
40 Some(d) => d,
41 None => transcript::detect(&bytes)?,
42 };
43 let usages = transcript::parse(&bytes, dialect)?;
44 Ok(cost::estimate(&usages, &store))
45}
46
47pub fn refresh_pricing_tables(as_of: &str) -> Result<RefreshReport, ObolError> {
50 let mut store = pricing::refresh::fetch_litellm(as_of)?; let openrouter = pricing::refresh::fetch_openrouter()?;
52 store
53 .namespaces
54 .insert("openrouter".to_string(), openrouter);
55 let models: usize = store.namespaces.values().map(|m| m.len()).sum();
56 let dir = pricing::pricing_dir();
57 store.save(&dir.join(format!("prices-{as_of}.json")))?;
58 let current = pricing::current_path();
59 store.save(¤t)?;
60 Ok(RefreshReport {
61 models,
62 as_of: as_of.to_string(),
63 written_to: current,
64 })
65}
66
67#[cfg(test)]
68mod api_tests {
69 use super::*;
70
71 #[test]
72 fn estimate_cost_on_bytes_with_missing_tables_errors() {
73 std::env::set_var("OBOL_PRICING_DIR", "/nonexistent/obol-xyz");
74 let data = include_bytes!("../tests/fixtures/claude-mini.jsonl");
75 let r = estimate_cost(Source::Bytes(data), Some(Dialect::Claude));
76 assert!(matches!(r, Err(ObolError::PricingTablesMissing(_))));
77 std::env::remove_var("OBOL_PRICING_DIR");
78 }
79
80 #[test]
81 fn estimate_cost_end_to_end_with_seeded_store() {
82 let dir = std::env::temp_dir().join(format!("obol-api-{}", std::process::id()));
83 std::env::set_var("OBOL_PRICING_DIR", &dir);
84 let store = pricing::refresh::normalize_litellm(
86 include_bytes!("../tests/fixtures/litellm-sample.json"),
87 "2026-06-04",
88 )
89 .unwrap();
90 store.save(&pricing::current_path()).unwrap();
91
92 let data = include_bytes!("../tests/fixtures/claude-mini.jsonl");
93 let est = estimate_cost(Source::Bytes(data), Some(Dialect::Claude)).unwrap();
94 assert!(est.total_usd > 0.0);
95 assert_eq!(est.pricing_as_of, "2026-06-04");
96
97 std::fs::remove_dir_all(&dir).ok();
98 std::env::remove_var("OBOL_PRICING_DIR");
99 }
100
101 #[test]
102 fn estimate_cost_from_path_with_autodetect() {
103 let dir = std::env::temp_dir().join(format!("obol-path-{}", std::process::id()));
104 std::env::set_var("OBOL_PRICING_DIR", &dir);
105 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 transcript = dir.join("session.jsonl");
115 std::fs::write(
116 &transcript,
117 include_bytes!("../tests/fixtures/claude-mini.jsonl"),
118 )
119 .unwrap();
120 let est = estimate_cost(Source::Path(&transcript), None).unwrap();
121 assert!(est.total_usd > 0.0);
122
123 std::fs::remove_dir_all(&dir).ok();
124 std::env::remove_var("OBOL_PRICING_DIR");
125 }
126
127 #[test]
128 fn refresh_report_serializes() {
129 let r = RefreshReport {
130 models: 7,
131 as_of: "2026-06-05".into(),
132 written_to: "/x/current.json".into(),
133 };
134 let v = serde_json::to_value(&r).unwrap();
135 assert_eq!(v["models"], 7);
136 assert_eq!(v["as_of"], "2026-06-05");
137 assert_eq!(v["written_to"], "/x/current.json");
138 }
139}