use crate::config;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{LazyLock, RwLock};
#[derive(Debug, Clone, Copy, Default)]
pub struct ClaudePricing {
pub input: f64,
pub cache_write_5m: f64,
pub cache_write_1h: f64,
pub cache_read: f64,
pub output: f64,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct CodexPricing {
pub input: f64,
pub cached_input: f64,
pub output: f64,
}
const fn claude(input: f64, cw5m: f64, cw1h: f64, cr: f64, output: f64) -> ClaudePricing {
ClaudePricing {
input,
cache_write_5m: cw5m,
cache_write_1h: cw1h,
cache_read: cr,
output,
}
}
static CLAUDE_TABLE: LazyLock<HashMap<&'static str, ClaudePricing>> = LazyLock::new(|| {
let opus = claude(5.0, 6.25, 10.0, 0.5, 25.0);
let sonnet = claude(3.0, 3.75, 6.0, 0.3, 15.0);
let fable = claude(10.0, 12.5, 20.0, 1.0, 50.0);
HashMap::from([
("fable-5", fable),
("claude-fable-5", fable),
("claude-opus-5", opus),
("claude-opus-4-8", opus),
("claude-opus-4-7", opus),
("claude-opus-4-6", opus),
("claude-opus-4-5-20251101", opus),
("claude-sonnet-5", sonnet),
("claude-sonnet-4-6", sonnet),
("claude-sonnet-4-5-20250929", sonnet),
(
"claude-haiku-4-5-20251001",
claude(1.0, 1.25, 2.0, 0.1, 5.0),
),
])
});
static CODEX_TABLE: LazyLock<HashMap<&'static str, CodexPricing>> = LazyLock::new(|| {
let gpt_5_2 = CodexPricing {
input: 1.75,
cached_input: 0.175,
output: 14.0,
};
HashMap::from([
("gpt-5.2", gpt_5_2),
("gpt-5.2-codex", gpt_5_2),
("codex-auto-review", gpt_5_2),
(
"gpt-5.3-codex",
CodexPricing {
input: 1.75,
cached_input: 0.175,
output: 14.0,
},
),
(
"codex-mini-latest",
CodexPricing {
input: 1.5,
cached_input: 0.375,
output: 6.0,
},
),
])
});
#[derive(Debug, Clone, Deserialize, Default)]
pub struct LitellmEntry {
#[serde(default)]
pub input_cost_per_token: f64,
#[serde(default)]
pub output_cost_per_token: f64,
#[serde(default)]
pub cache_read_input_token_cost: f64,
#[serde(default)]
pub max_input_tokens: Option<u64>,
}
#[derive(Serialize, Deserialize)]
struct PricingCache {
#[serde(rename = "_fetchedAt")]
fetched_at: u64,
data: HashMap<String, serde_json::Value>,
}
static LITELLM: LazyLock<RwLock<Option<HashMap<String, LitellmEntry>>>> =
LazyLock::new(|| RwLock::new(None));
static PRICING_EPOCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
pub fn pricing_epoch() -> u64 {
PRICING_EPOCH.load(std::sync::atomic::Ordering::Relaxed)
}
fn unix_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn parse_entries(raw: &HashMap<String, serde_json::Value>) -> HashMap<String, LitellmEntry> {
raw.iter()
.filter_map(|(k, v)| {
serde_json::from_value::<LitellmEntry>(v.clone())
.ok()
.map(|e| (k.clone(), e))
})
.collect()
}
fn install(raw: HashMap<String, serde_json::Value>, epoch: u64) {
if let Ok(mut guard) = LITELLM.write() {
*guard = Some(parse_entries(&raw));
}
PRICING_EPOCH.store(epoch, std::sync::atomic::Ordering::Relaxed);
}
pub fn load_cached_pricing() -> bool {
let Ok(text) = std::fs::read_to_string(&*config::PRICING_CACHE_FILE) else {
return false;
};
let Ok(cache) = serde_json::from_str::<PricingCache>(&text) else {
return false;
};
let fresh = unix_secs().saturating_sub(cache.fetched_at) < config::PRICING_CACHE_MAX_AGE_SECS;
let epoch = cache.fetched_at;
install(cache.data, epoch);
fresh
}
pub fn refresh_pricing_blocking() {
if load_cached_pricing() {
return; }
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(std::time::Duration::from_secs(10)))
.build()
.into();
let fetched = agent
.get(config::LITELLM_URL)
.call()
.ok()
.and_then(|mut resp| resp.body_mut().read_to_string().ok())
.and_then(|text| serde_json::from_str::<HashMap<String, serde_json::Value>>(&text).ok());
let Some(data) = fetched else { return };
let now = unix_secs();
let _ = std::fs::create_dir_all(&*config::CACHE_DIR);
if let Ok(text) = serde_json::to_string(&PricingCache {
fetched_at: now,
data: data.clone(),
}) {
let _ = std::fs::write(&*config::PRICING_CACHE_FILE, text);
}
install(data, now);
}
fn names_model(key: &str, model: &str) -> bool {
let norm = |s: &str| s.to_ascii_lowercase().replace('.', "/");
let (key, model) = (norm(key), norm(model));
key == model || key.ends_with(&format!("/{model}"))
}
fn priced(e: &LitellmEntry) -> bool {
e.input_cost_per_token > 0.0 || e.output_cost_per_token > 0.0
}
fn best_match(
table: &HashMap<String, LitellmEntry>,
pred: impl Fn(&str) -> bool,
) -> Option<&LitellmEntry> {
table
.iter()
.filter(|(k, v)| priced(v) && pred(k))
.min_by(|a, b| (a.0.len(), a.0).cmp(&(b.0.len(), b.0)))
.map(|(_, v)| v)
}
fn lookup<'a>(table: &'a HashMap<String, LitellmEntry>, model: &str) -> Option<&'a LitellmEntry> {
let stem = model.rsplit('/').next().filter(|s| *s != model);
let names = || std::iter::once(model).chain(stem);
table
.get(model)
.or_else(|| table.get(&format!("anthropic.{model}")))
.or_else(|| names().find_map(|n| table.get(n).filter(|e| priced(e))))
.or_else(|| names().find_map(|n| best_match(table, |k| names_model(k, n))))
.or_else(|| names().find_map(|n| best_match(table, |k| k.contains(n))))
}
fn litellm_entry(model: &str) -> Option<LitellmEntry> {
let guard = LITELLM.read().ok()?;
lookup(guard.as_ref()?, model).cloned()
}
pub fn litellm_max_input_tokens(model: &str) -> Option<u64> {
litellm_entry(model).and_then(|e| e.max_input_tokens)
}
pub fn resolve_claude(model: &str) -> ClaudePricing {
if let Some(p) = CLAUDE_TABLE.get(model) {
return *p;
}
match litellm_entry(model) {
Some(e) => {
let input = e.input_cost_per_token * 1e6;
ClaudePricing {
input,
cache_write_5m: input * 1.25,
cache_write_1h: input * 2.0,
cache_read: e.cache_read_input_token_cost * 1e6,
output: e.output_cost_per_token * 1e6,
}
}
None => ClaudePricing::default(),
}
}
pub fn resolve_codex(model: &str) -> CodexPricing {
if let Some(p) = CODEX_TABLE.get(model) {
return *p;
}
match litellm_entry(model) {
Some(e) => CodexPricing {
input: e.input_cost_per_token * 1e6,
cached_input: e.cache_read_input_token_cost * 1e6,
output: e.output_cost_per_token * 1e6,
},
None => CodexPricing::default(),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Plan {
Retail,
Max,
Included,
}
impl Plan {
pub fn parse(s: &str) -> Option<Plan> {
match s.to_ascii_lowercase().as_str() {
"retail" | "default" => Some(Plan::Retail),
"max" | "claude-max" => Some(Plan::Max),
"included" | "enterprise" | "not-billed" => Some(Plan::Included),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Plan::Retail => "retail",
Plan::Max => "max",
Plan::Included => "included",
}
}
pub fn includes(&self, provider: Provider) -> bool {
matches!(
(self, provider),
(Plan::Included, _) | (Plan::Max, Provider::Claude)
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Provider {
Claude,
Codex,
Cursor,
OpenCode,
Pi,
}
impl Provider {
pub fn as_str(&self) -> &'static str {
match self {
Provider::Claude => "claude",
Provider::Codex => "codex",
Provider::Cursor => "cursor",
Provider::OpenCode => "opencode",
Provider::Pi => "pi",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builtin_table_hits_before_litellm() {
let p = resolve_claude("claude-opus-4-5-20251101");
assert_eq!(p.input, 5.0);
assert_eq!(p.output, 25.0);
assert_eq!(p.cache_write_1h, 10.0);
}
#[test]
fn unknown_model_prices_at_zero_not_panic() {
let p = resolve_claude("claude-does-not-exist-9");
assert_eq!(p.input, 0.0);
let c = resolve_codex("gpt-nope");
assert_eq!(c.output, 0.0);
}
#[test]
fn auto_review_uses_gpt_5_2_pricing_without_renaming() {
let auto = resolve_codex("codex-auto-review");
let gpt = resolve_codex("gpt-5.2");
assert_eq!(auto.input, gpt.input);
assert_eq!(auto.cached_input, gpt.cached_input);
assert_eq!(auto.output, gpt.output);
}
fn kimi_table() -> HashMap<String, LitellmEntry> {
let entry = |input: f64| LitellmEntry {
input_cost_per_token: input,
..Default::default()
};
[
("moonshotai.kimi-k2.5", 6e-7),
("moonshot/kimi-k2.5", 6e-7),
("openrouter/moonshotai/kimi-k2.5", 7e-7),
("bedrock/ap-south-1/moonshotai.kimi-k2.5", 7.2e-7),
("together_ai/moonshotai/Kimi-K2.5", 5e-7),
("claude-sonnet-4-5-20250929", 3e-6),
("gpt-5.2", 1.75e-6),
("perplexity/anthropic/claude-sonnet-4-5", 0.0),
]
.into_iter()
.map(|(k, v)| (k.to_string(), entry(v)))
.collect()
}
#[test]
fn a_routed_model_resolves_to_its_vendor_listing() {
let table = kimi_table();
for _ in 0..8 {
let e = lookup(&table, "moonshotai/kimi-k2.5").expect("no match");
assert_eq!(e.input_cost_per_token, 6e-7);
}
assert!(lookup(&table, "claude-sonnet-4-5").is_some());
assert!(lookup(&table, "gpt-nope").is_none());
}
#[test]
fn a_vendor_prefix_falls_back_to_the_bare_listing() {
let table = kimi_table();
let e = lookup(&table, "anthropic/claude-sonnet-4-5").expect("no match");
assert_eq!(e.input_cost_per_token, 3e-6);
assert_eq!(
lookup(&table, "openai/gpt-5.2").map(|e| e.input_cost_per_token),
Some(1.75e-6)
);
}
#[test]
fn plan_bundling() {
assert!(!Plan::Retail.includes(Provider::Claude));
assert!(Plan::Max.includes(Provider::Claude));
assert!(!Plan::Max.includes(Provider::Codex));
assert!(Plan::Included.includes(Provider::Codex));
}
#[test]
fn plan_aliases() {
assert_eq!(Plan::parse("MAX"), Some(Plan::Max));
assert_eq!(Plan::parse("not-billed"), Some(Plan::Included));
assert_eq!(Plan::parse("bogus"), None);
}
}