#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Pricing {
pub input_per_million: f64,
pub output_per_million: f64,
}
pub fn lookup(provider: &str, model: &str) -> Option<Pricing> {
match provider {
"anthropic" => anthropic_pricing(model),
"openai" => openai_pricing(model),
"openrouter" => None,
"ollama" | "lm-studio" | "lm_studio" | "claude-code" | "claude_code" => None,
_ => None,
}
}
fn anthropic_pricing(model: &str) -> Option<Pricing> {
if model.starts_with("claude-opus-4") {
return Some(Pricing {
input_per_million: 15.0,
output_per_million: 75.0,
});
}
if model.starts_with("claude-sonnet-4") {
return Some(Pricing {
input_per_million: 3.0,
output_per_million: 15.0,
});
}
if model.starts_with("claude-haiku-4") {
return Some(Pricing {
input_per_million: 0.80,
output_per_million: 4.00,
});
}
if model.starts_with("claude-3-5-sonnet") || model.starts_with("claude-3-7-sonnet") {
return Some(Pricing {
input_per_million: 3.0,
output_per_million: 15.0,
});
}
if model.starts_with("claude-3-opus") {
return Some(Pricing {
input_per_million: 15.0,
output_per_million: 75.0,
});
}
if model.starts_with("claude-3-haiku") {
return Some(Pricing {
input_per_million: 0.25,
output_per_million: 1.25,
});
}
None
}
fn openai_pricing(model: &str) -> Option<Pricing> {
if model.starts_with("gpt-4o-mini") {
return Some(Pricing {
input_per_million: 0.15,
output_per_million: 0.60,
});
}
if model.starts_with("gpt-4o") {
return Some(Pricing {
input_per_million: 2.50,
output_per_million: 10.0,
});
}
if model.starts_with("o1-mini") {
return Some(Pricing {
input_per_million: 3.0,
output_per_million: 12.0,
});
}
if model.starts_with("o1") {
return Some(Pricing {
input_per_million: 15.0,
output_per_million: 60.0,
});
}
if model.starts_with("gpt-4-turbo") {
return Some(Pricing {
input_per_million: 10.0,
output_per_million: 30.0,
});
}
if model.starts_with("gpt-4") {
return Some(Pricing {
input_per_million: 30.0,
output_per_million: 60.0,
});
}
if model.starts_with("gpt-3.5") {
return Some(Pricing {
input_per_million: 0.50,
output_per_million: 1.50,
});
}
None
}
pub fn known_models(provider: &str) -> &'static [&'static str] {
match provider {
"anthropic" => &[
"claude-opus-4-5-20250929",
"claude-sonnet-4-5-20250929",
"claude-haiku-4-5-20250929",
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
"claude-3-opus-20240229",
],
"openai" => &[
"gpt-4o",
"gpt-4o-mini",
"o1",
"o1-mini",
"gpt-4-turbo",
"gpt-3.5-turbo",
],
"openrouter" => &[
"anthropic/claude-sonnet-4.5",
"openai/gpt-4o",
"google/gemini-2.5-pro",
"meta-llama/llama-3.3-70b-instruct",
],
"ollama" => &[
"llama3.2",
"llama3.2-vision",
"llava",
"qwen2.5-coder",
"deepseek-r1",
],
"lm-studio" | "lm_studio" => &[],
_ => &[],
}
}
pub fn default_model(provider: &str) -> Option<&'static str> {
known_models(provider).first().copied()
}
pub fn model_matches_provider(provider: &str, model: &str) -> bool {
let known = known_models(provider);
known.is_empty() || known.contains(&model)
}
pub fn estimate_tokens(text: &str) -> u64 {
let chars = text.chars().count() as u64;
if chars == 0 {
0
} else {
chars.div_ceil(4)
}
}
pub fn estimate_cost(p: Pricing, input_tokens: u64, output_tokens: u64) -> f64 {
let in_cost = (input_tokens as f64 / 1_000_000.0) * p.input_per_million;
let out_cost = (output_tokens as f64 / 1_000_000.0) * p.output_per_million;
in_cost + out_cost
}
pub fn format_tokens(n: u64) -> String {
if n < 1_000 {
format!("{n}")
} else if n < 1_000_000 {
format!("{:.1}k", n as f64 / 1_000.0)
} else {
format!("{:.1}M", n as f64 / 1_000_000.0)
}
}
pub fn format_cost(usd: f64) -> String {
if usd < 0.01 {
"<$0.01".to_string()
} else if usd < 1.0 {
format!("${usd:.3}")
} else {
format!("${usd:.2}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lookup_returns_pricing_for_claude_opus() {
let p = lookup("anthropic", "claude-opus-4-5-20251201").expect("priced");
assert_eq!(p.input_per_million, 15.0);
assert_eq!(p.output_per_million, 75.0);
}
#[test]
fn lookup_returns_pricing_for_sonnet_legacy() {
let p = lookup("anthropic", "claude-3-5-sonnet-20241022").expect("priced");
assert_eq!(p.input_per_million, 3.0);
}
#[test]
fn lookup_returns_none_for_local_providers() {
assert!(lookup("ollama", "llama3.1:8b").is_none());
assert!(lookup("lm-studio", "whatever").is_none());
assert!(lookup("claude-code", "claude-sonnet-4").is_none());
}
#[test]
fn estimate_tokens_uses_four_char_heuristic() {
assert_eq!(estimate_tokens(""), 0);
assert_eq!(estimate_tokens("x"), 1);
assert_eq!(estimate_tokens("abcd"), 1);
assert_eq!(estimate_tokens("abcde"), 2);
assert_eq!(estimate_tokens(&"a".repeat(400)), 100);
}
#[test]
fn estimate_cost_combines_input_and_output() {
let p = Pricing {
input_per_million: 3.0,
output_per_million: 15.0,
};
let cost = estimate_cost(p, 1_000_000, 1_000_000);
assert!((cost - 18.0).abs() < 1e-9);
}
#[test]
fn format_tokens_scales_with_magnitude() {
assert_eq!(format_tokens(0), "0");
assert_eq!(format_tokens(999), "999");
assert_eq!(format_tokens(1_500), "1.5k");
assert_eq!(format_tokens(1_500_000), "1.5M");
}
#[test]
fn format_cost_collapses_sub_cent() {
assert_eq!(format_cost(0.0001), "<$0.01");
assert_eq!(format_cost(0.123), "$0.123");
assert_eq!(format_cost(12.34), "$12.34");
}
#[test]
fn default_model_is_head_of_known_list() {
assert_eq!(default_model("anthropic"), Some("claude-opus-4-5-20250929"));
assert_eq!(default_model("openai"), Some("gpt-4o"));
assert_eq!(default_model("lm-studio"), None);
assert_eq!(default_model("claude-code"), None);
}
#[test]
fn model_matches_provider_gates_remapping() {
assert!(!model_matches_provider(
"openai",
"claude-opus-4-5-20250929"
));
assert!(model_matches_provider("openai", "gpt-4o"));
assert!(model_matches_provider("lm-studio", "whatever-local-model"));
assert!(model_matches_provider("claude-code", "anything"));
}
}