use std::collections::BTreeMap;
use std::fmt;
use std::io;
use std::path::{Path, PathBuf};
use serde::Deserialize;
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct Config {
pub general: General,
pub sources: BTreeMap<String, Source>,
pub pricing: BTreeMap<String, BTreeMap<String, ModelPrice>>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct General {
pub data_dir: Option<PathBuf>,
pub index_prompt_text: bool,
}
impl Default for General {
fn default() -> Self {
Self {
data_dir: None,
index_prompt_text: true,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct Source {
pub enabled: bool,
pub path: Option<PathBuf>,
}
impl Default for Source {
fn default() -> Self {
Self {
enabled: true,
path: None,
}
}
}
#[derive(Debug, Clone, Copy, Deserialize)]
pub struct ModelPrice {
#[serde(default)]
pub input: f64,
#[serde(default)]
pub output: f64,
#[serde(default)]
pub cache_read: f64,
#[serde(default)]
pub cache_write: Option<f64>,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct TokenCounts {
pub input: Option<u64>,
pub output: Option<u64>,
pub cache_read: Option<u64>,
pub cache_write: Option<u64>,
}
impl Config {
pub fn load_from_dir(dir: &Path) -> Result<Self, ConfigError> {
Self::load_file(&dir.join("config.toml"))
}
pub fn load_file(path: &Path) -> Result<Self, ConfigError> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
Err(err) => return Err(ConfigError::Read(path.to_path_buf(), err)),
};
toml::from_str(&text).map_err(|err| ConfigError::Parse(path.to_path_buf(), err))
}
pub fn source(&self, adapter: &str) -> Source {
self.sources.get(adapter).cloned().unwrap_or_default()
}
pub fn price(&self, provider: &str, model: &str) -> Option<ModelPrice> {
self.pricing.get(provider)?.get(model).copied()
}
pub fn estimate_cost(&self, provider: &str, model: &str, tokens: TokenCounts) -> Option<f64> {
self.pricing().estimate_cost(provider, model, tokens)
}
pub fn pricing(&self) -> Pricing {
Pricing {
table: self.pricing.clone(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Pricing {
table: BTreeMap<String, BTreeMap<String, ModelPrice>>,
}
impl Pricing {
pub fn price(&self, provider: &str, model: &str) -> Option<ModelPrice> {
self.table.get(provider)?.get(model).copied()
}
pub fn estimate_cost(&self, provider: &str, model: &str, tokens: TokenCounts) -> Option<f64> {
let price = self.price(provider, model)?;
let per_million = |count: Option<u64>, rate: f64| count.unwrap_or(0) as f64 * rate / 1e6;
let cache_write_rate = price.cache_write.unwrap_or(price.input);
Some(
per_million(tokens.input, price.input)
+ per_million(tokens.output, price.output)
+ per_million(tokens.cache_read, price.cache_read)
+ per_million(tokens.cache_write, cache_write_rate),
)
}
}
#[derive(Debug)]
pub enum ConfigError {
Read(PathBuf, io::Error),
Parse(PathBuf, toml::de::Error),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::Read(path, err) => write!(f, "reading {}: {err}", path.display()),
ConfigError::Parse(path, err) => write!(f, "parsing {}: {err}", path.display()),
}
}
}
impl std::error::Error for ConfigError {}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"
[general]
index_prompt_text = false
[sources.claude-code]
enabled = true
path = "/logs/claude"
[pricing.anthropic]
"claude-sonnet-4-6" = { input = 3.0, output = 15.0, cache_read = 0.3 }
"#;
fn write(dir: &Path, text: &str) {
std::fs::write(dir.join("config.toml"), text).unwrap();
}
#[test]
fn missing_file_yields_defaults() {
let dir = tempfile::tempdir().unwrap();
let cfg = Config::load_from_dir(dir.path()).unwrap();
assert!(cfg.general.index_prompt_text);
assert!(cfg.general.data_dir.is_none());
assert!(cfg.pricing.is_empty());
let source = cfg.source("claude-code");
assert!(source.enabled);
assert!(source.path.is_none());
}
#[test]
fn parses_all_sections() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), SAMPLE);
let cfg = Config::load_from_dir(dir.path()).unwrap();
assert!(!cfg.general.index_prompt_text);
assert_eq!(
cfg.source("claude-code").path.as_deref(),
Some(Path::new("/logs/claude"))
);
let price = cfg.price("anthropic", "claude-sonnet-4-6").unwrap();
assert_eq!(price.input, 3.0);
assert_eq!(price.cache_write, None);
}
#[test]
fn estimates_cost_from_config() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), SAMPLE);
let cfg = Config::load_from_dir(dir.path()).unwrap();
let tokens = TokenCounts {
input: Some(1_000_000),
output: Some(1_000_000),
cache_read: Some(1_000_000),
cache_write: None,
};
let cost = cfg
.estimate_cost("anthropic", "claude-sonnet-4-6", tokens)
.unwrap();
assert!((cost - 18.3).abs() < 1e-9, "got {cost}");
}
#[test]
fn unpriced_model_returns_none_not_zero() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), SAMPLE);
let cfg = Config::load_from_dir(dir.path()).unwrap();
let tokens = TokenCounts {
input: Some(1_000),
..TokenCounts::default()
};
assert!(cfg
.estimate_cost("anthropic", "some-unlisted-model", tokens)
.is_none());
assert!(cfg.estimate_cost("openai", "gpt-x", tokens).is_none());
}
#[test]
fn empty_config_prices_nothing() {
let cfg = Config::default();
assert!(cfg
.estimate_cost("anthropic", "claude-sonnet-4-6", TokenCounts::default())
.is_none());
}
}