use super::ModelPrice;
use crate::error::ObolError;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PriceStore {
pub as_of: String,
pub namespaces: HashMap<String, HashMap<String, ModelPrice>>,
}
impl PriceStore {
pub fn lookup(&self, namespace: &str, model: &str) -> Option<&ModelPrice> {
self.namespaces.get(namespace)?.get(model)
}
pub fn from_json(bytes: &[u8]) -> Result<Self, ObolError> {
Ok(serde_json::from_slice(bytes)?)
}
pub fn load(path: &Path) -> Result<Self, ObolError> {
if !path.exists() {
return Err(ObolError::PricingTablesMissing(path.to_path_buf()));
}
Self::from_json(&std::fs::read(path)?)
}
pub fn save(&self, path: &Path) -> Result<(), ObolError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, serde_json::to_vec_pretty(self)?)?;
Ok(())
}
}
pub fn pricing_dir() -> PathBuf {
if let Ok(d) = std::env::var("OBOL_PRICING_DIR") {
return PathBuf::from(d);
}
let base = std::env::var("XDG_DATA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join(".local").join("share")
});
base.join("obol")
}
pub fn current_path() -> PathBuf {
pricing_dir().join("current.json")
}
pub fn embedded() -> Result<PriceStore, ObolError> {
const BYTES: &[u8] = include_bytes!("../../prices/bundled.json");
PriceStore::from_json(BYTES)
}
#[cfg(test)]
mod tests {
use super::*;
fn store() -> PriceStore {
let mut litellm = HashMap::new();
litellm.insert(
"claude-opus-4-8".to_string(),
ModelPrice {
input: 5.0,
output: 25.0,
cache_read: 0.5,
cache_write: 6.25,
cache_write_1h: Some(10.0),
tier_boundary: None,
input_above: None,
output_above: None,
cache_read_above: None,
cache_write_above: None,
},
);
let mut namespaces = HashMap::new();
namespaces.insert("litellm".to_string(), litellm);
PriceStore {
as_of: "2026-06-04".into(),
namespaces,
}
}
#[test]
fn lookup_finds_model_in_namespace() {
let s = store();
assert!(s.lookup("litellm", "claude-opus-4-8").is_some());
assert!(s.lookup("litellm", "nonsense").is_none());
assert!(s.lookup("openrouter", "claude-opus-4-8").is_none());
}
#[test]
fn save_then_load_roundtrips() {
let dir = std::env::temp_dir().join(format!("obol-test-{}", std::process::id()));
let path = dir.join("current.json");
store().save(&path).unwrap();
let loaded = PriceStore::load(&path).unwrap();
assert_eq!(loaded, store());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn load_missing_is_pricing_tables_missing() {
let path = PathBuf::from("/nonexistent/obol/current.json");
match PriceStore::load(&path) {
Err(ObolError::PricingTablesMissing(_)) => {}
other => panic!("expected PricingTablesMissing, got {other:?}"),
}
}
#[test]
fn pricing_dir_honors_env_override() {
std::env::set_var("OBOL_PRICING_DIR", "/tmp/obol-x");
assert_eq!(pricing_dir(), PathBuf::from("/tmp/obol-x"));
std::env::remove_var("OBOL_PRICING_DIR");
}
#[test]
fn embedded_snapshot_loads_and_has_models() {
let s = embedded().expect("embedded snapshot parses");
assert!(!s.as_of.is_empty(), "embedded snapshot must carry an as_of");
assert!(
s.lookup("litellm", "claude-opus-4-8").is_some(),
"embedded snapshot should price a known model"
);
}
}