use regex::Regex;
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::collections::HashMap;
use std::sync::LazyLock;
const MIN_MATCH_OVERLAP: usize = 3;
fn strip_version_tokens(s: &str) -> String {
static VER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\d+(?:[._]\d+)*").unwrap());
static SEP: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[-._]{2,}").unwrap());
let no_ver = VER.replace_all(s, "");
let collapsed = SEP.replace_all(&no_ver, "-");
collapsed
.trim_matches(|c| c == '-' || c == '.' || c == '_')
.to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PricingEntry {
pub prefix: String,
pub input_per_m: f64,
pub output_per_m: f64,
#[serde(default)]
pub cache_write_per_m: Option<f64>,
#[serde(default)]
pub cache_read_per_m: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderBlock {
#[serde(default)]
pub entries: Vec<PricingEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PricingConfig {
#[serde(default)]
pub providers: HashMap<String, ProviderBlock>,
}
impl PricingConfig {
pub fn calculate_cost(&self, model: &str, input_tokens: u32, output_tokens: u32) -> f64 {
self.calculate_cost_with_cache(model, input_tokens, output_tokens, 0, 0)
}
pub fn calculate_cost_with_cache(
&self,
model: &str,
input_tokens: u32,
output_tokens: u32,
cache_creation_tokens: u32,
cache_read_tokens: u32,
) -> f64 {
match self.resolve_entry(model) {
Some(entry) => {
let input = (input_tokens as f64 / 1_000_000.0) * entry.input_per_m;
let output = (output_tokens as f64 / 1_000_000.0) * entry.output_per_m;
let cache_write_rate = entry.cache_write_per_m.unwrap_or(entry.input_per_m * 1.25);
let cache_read_rate = entry.cache_read_per_m.unwrap_or(entry.input_per_m * 0.1);
let cache_write = (cache_creation_tokens as f64 / 1_000_000.0) * cache_write_rate;
let cache_read = (cache_read_tokens as f64 / 1_000_000.0) * cache_read_rate;
input + output + cache_write + cache_read
}
None => 0.0,
}
}
pub fn estimate_cost(&self, model: &str, token_count: i64) -> Option<f64> {
self.resolve_entry(model).map(|entry| {
let input = (token_count as f64 * 0.80 / 1_000_000.0) * entry.input_per_m;
let output = (token_count as f64 * 0.20 / 1_000_000.0) * entry.output_per_m;
input + output
})
}
fn resolve_entry(&self, model: &str) -> Option<&PricingEntry> {
let m = model.to_lowercase();
if let Some(e) = self.best_substring_match(&m, false) {
return Some(e);
}
if let Some(e) = self.best_substring_match(&m, true) {
return Some(e);
}
self.family_flagship(&strip_version_tokens(&m))
}
fn best_substring_match(&self, model: &str, versionless: bool) -> Option<&PricingEntry> {
let m = if versionless {
strip_version_tokens(model)
} else {
model.to_string()
};
if m.is_empty() {
return None;
}
self.providers
.values()
.flat_map(|b| b.entries.iter())
.filter_map(|e| {
let prefix = e.prefix.to_lowercase();
let key = if versionless {
strip_version_tokens(&prefix)
} else {
prefix
};
if key.is_empty() {
return None;
}
let overlap = if m.contains(&key) {
key.len()
} else if key.contains(&m) {
m.len()
} else {
return None;
};
(overlap >= MIN_MATCH_OVERLAP).then_some((overlap, e))
})
.max_by(|(la, ea), (lb, eb)| {
la.cmp(lb).then(
ea.input_per_m
.partial_cmp(&eb.input_per_m)
.unwrap_or(Ordering::Equal),
)
})
.map(|(_, e)| e)
}
fn family_flagship(&self, model_base: &str) -> Option<&PricingEntry> {
let root = model_base.split(['-', '.']).next().unwrap_or("");
if root.len() < 3 {
return None;
}
self.providers
.values()
.flat_map(|b| b.entries.iter())
.filter(|e| {
strip_version_tokens(&e.prefix.to_lowercase())
.split(['-', '.'])
.next()
.is_some_and(|t| t == root)
})
.max_by(|a, b| {
a.input_per_m
.partial_cmp(&b.input_per_m)
.unwrap_or(Ordering::Equal)
})
}
pub fn load() -> Result<Self, String> {
let path = crate::config::opencrabs_home().join("usage_pricing.toml");
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => {
let example = include_str!("../../usage_pricing.toml.example");
return Self::parse_content(example, "embedded example");
}
};
let mut user_cfg = Self::parse_content(&content, &format!("{:?}", path))?;
let baseline_example = include_str!("../../usage_pricing.toml.example");
if let Ok(baseline_cfg) = Self::parse_content(baseline_example, "embedded example baseline")
{
let added = user_cfg.merge_missing_from(&baseline_cfg);
if added > 0 {
tracing::info!(
target: "pricing",
added,
path = ?path,
"merged new baseline pricing entries into user pricing file (additive only)"
);
let new_content = Self::serialize_to_toml(&user_cfg);
if let Err(e) = std::fs::write(&path, &new_content) {
tracing::warn!(
target: "pricing",
error = %e,
path = ?path,
"failed to persist merged pricing back to disk — entries are in-memory only \
this session, will re-merge on next load"
);
}
}
}
Ok(user_cfg)
}
pub fn merge_missing_from(&mut self, other: &PricingConfig) -> usize {
let mut added = 0;
for (prov_name, baseline_block) in &other.providers {
let user_block = self.providers.entry(prov_name.clone()).or_default();
for baseline_entry in &baseline_block.entries {
let needle = baseline_entry.prefix.to_lowercase();
if !user_block
.entries
.iter()
.any(|e| e.prefix.to_lowercase() == needle)
{
user_block.entries.push(baseline_entry.clone());
added += 1;
}
}
}
added
}
fn parse_content(content: &str, source: &str) -> Result<Self, String> {
if let Ok(cfg) = toml::from_str::<PricingConfig>(content)
&& !cfg.providers.is_empty()
{
return Ok(cfg);
}
if let Ok(cfg) = Self::load_legacy(content)
&& !cfg.providers.is_empty()
{
tracing::warn!(
"usage_pricing.toml uses old format — please update it to the new schema. \
See usage_pricing.toml.example in the repo"
);
let new_content = Self::serialize_to_toml(&cfg);
if source != "embedded example" {
let path = crate::config::opencrabs_home().join("usage_pricing.toml");
let _ = std::fs::write(&path, new_content);
}
return Ok(cfg);
}
Err(format!(
"usage_pricing.toml from {} failed to parse with both schemas.\n\
Check the file syntax or re-copy from usage_pricing.toml.example",
source
))
}
fn load_legacy(content: &str) -> Result<Self, toml::de::Error> {
#[derive(serde::Deserialize)]
struct LegacyRoot {
usage: Option<LegacyUsage>,
}
#[derive(serde::Deserialize)]
struct LegacyUsage {
pricing: Option<toml::Value>,
}
let root: LegacyRoot = toml::from_str(content)?;
let pricing_val = root
.usage
.and_then(|u| u.pricing)
.unwrap_or(toml::Value::Table(toml::map::Map::new()));
let mut providers: HashMap<String, ProviderBlock> = HashMap::new();
if let toml::Value::Table(table) = pricing_val {
for (provider_name, entries_val) in table {
if let toml::Value::Array(arr) = entries_val {
let entries: Vec<PricingEntry> =
arr.into_iter().filter_map(|v| v.try_into().ok()).collect();
if !entries.is_empty() {
providers.insert(provider_name, ProviderBlock { entries });
}
}
}
}
Ok(PricingConfig { providers })
}
fn serialize_to_toml(cfg: &PricingConfig) -> String {
let mut out = String::from(
"# OpenCrabs Usage Pricing — auto-migrated to current schema.\n\
# Edit freely. Changes take effect immediately on next /usage open.\n\
# prefix is matched case-insensitively as a substring of the model name.\n\
# Costs are per 1 million tokens (USD).\n\n",
);
let mut providers: Vec<(&String, &ProviderBlock)> = cfg.providers.iter().collect();
providers.sort_by_key(|(k, _)| k.as_str());
for (name, block) in providers {
out.push_str(&format!("[providers.{}]\nentries = [\n", name));
for e in &block.entries {
let mut row = format!(
" {{ prefix = {:?}, input_per_m = {}, output_per_m = {}",
e.prefix, e.input_per_m, e.output_per_m
);
if let Some(cw) = e.cache_write_per_m {
row.push_str(&format!(", cache_write_per_m = {cw}"));
}
if let Some(cr) = e.cache_read_per_m {
row.push_str(&format!(", cache_read_per_m = {cr}"));
}
row.push_str(" },\n");
out.push_str(&row);
}
out.push_str("]\n\n");
}
out
}
pub fn seed_from_example() {
let path = crate::config::opencrabs_home().join("usage_pricing.toml");
if path.exists() {
return; }
let example_content = include_str!("../../usage_pricing.toml.example");
if let Err(e) = std::fs::write(&path, example_content) {
tracing::error!("Failed to seed usage_pricing.toml from example: {}", e);
} else {
tracing::info!("Seeded usage_pricing.toml from example");
}
}
}