use crate::backends::BackendName;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ModelInfo {
pub id: &'static str,
pub context_tokens: u32,
pub input_per_mtoken_usd: f32,
pub output_per_mtoken_usd: f32,
pub vision: bool,
}
const OPENAI_MODELS: &[ModelInfo] = &[
ModelInfo {
id: "gpt-4o",
context_tokens: 128_000,
input_per_mtoken_usd: 2.50,
output_per_mtoken_usd: 10.00,
vision: true,
},
ModelInfo {
id: "gpt-4o-mini",
context_tokens: 128_000,
input_per_mtoken_usd: 0.15,
output_per_mtoken_usd: 0.60,
vision: true,
},
ModelInfo {
id: "gpt-4-turbo",
context_tokens: 128_000,
input_per_mtoken_usd: 10.00,
output_per_mtoken_usd: 30.00,
vision: true,
},
ModelInfo {
id: "gpt-4",
context_tokens: 8_192,
input_per_mtoken_usd: 30.00,
output_per_mtoken_usd: 60.00,
vision: false,
},
ModelInfo {
id: "gpt-3.5-turbo",
context_tokens: 16_385,
input_per_mtoken_usd: 0.50,
output_per_mtoken_usd: 1.50,
vision: false,
},
ModelInfo {
id: "o1",
context_tokens: 200_000,
input_per_mtoken_usd: 15.00,
output_per_mtoken_usd: 60.00,
vision: true,
},
ModelInfo {
id: "o1-mini",
context_tokens: 128_000,
input_per_mtoken_usd: 3.00,
output_per_mtoken_usd: 12.00,
vision: false,
},
ModelInfo {
id: "o1-preview",
context_tokens: 128_000,
input_per_mtoken_usd: 15.00,
output_per_mtoken_usd: 60.00,
vision: false,
},
ModelInfo {
id: "o3-mini",
context_tokens: 200_000,
input_per_mtoken_usd: 1.10,
output_per_mtoken_usd: 4.40,
vision: false,
},
];
const ANTHROPIC_MODELS: &[ModelInfo] = &[
ModelInfo {
id: "claude-fable-5",
context_tokens: 1_000_000,
input_per_mtoken_usd: 10.00,
output_per_mtoken_usd: 50.00,
vision: true,
},
ModelInfo {
id: "claude-opus-5",
context_tokens: 1_000_000,
input_per_mtoken_usd: 5.00,
output_per_mtoken_usd: 25.00,
vision: true,
},
ModelInfo {
id: "claude-sonnet-5",
context_tokens: 1_000_000,
input_per_mtoken_usd: 3.00,
output_per_mtoken_usd: 15.00,
vision: true,
},
ModelInfo {
id: "claude-haiku-4-5",
context_tokens: 200_000,
input_per_mtoken_usd: 1.00,
output_per_mtoken_usd: 5.00,
vision: true,
},
ModelInfo {
id: "claude-opus-4-8",
context_tokens: 1_000_000,
input_per_mtoken_usd: 5.00,
output_per_mtoken_usd: 25.00,
vision: true,
},
ModelInfo {
id: "claude-opus-4-7",
context_tokens: 1_000_000,
input_per_mtoken_usd: 5.00,
output_per_mtoken_usd: 25.00,
vision: true,
},
ModelInfo {
id: "claude-opus-4-6",
context_tokens: 1_000_000,
input_per_mtoken_usd: 5.00,
output_per_mtoken_usd: 25.00,
vision: true,
},
ModelInfo {
id: "claude-sonnet-4-6",
context_tokens: 1_000_000,
input_per_mtoken_usd: 3.00,
output_per_mtoken_usd: 15.00,
vision: true,
},
];
fn table_for(backend: BackendName) -> &'static [ModelInfo] {
match backend {
BackendName::OpenAi => OPENAI_MODELS,
BackendName::Anthropic => ANTHROPIC_MODELS,
_ => &[],
}
}
pub fn lookup(backend: BackendName, model_id: &str) -> Option<&'static ModelInfo> {
let table = table_for(backend);
if let Some(exact) = table.iter().find(|m| m.id == model_id) {
return Some(exact);
}
table
.iter()
.filter(|m| {
model_id == m.id
|| model_id
.strip_prefix(m.id)
.map(|rest| rest.starts_with('-'))
.unwrap_or(false)
})
.max_by_key(|m| m.id.len())
}
pub fn format_cost_label(info: &ModelInfo) -> String {
let ctx = format_context(info.context_tokens);
let today = chrono::Utc::now().date_naive();
let (input_rate, output_rate, promotional) = effective_rates_at(info, today);
if input_rate == 0.0 && output_rate == 0.0 {
format!("{ctx} ctx")
} else {
let mut label = format!(
"{ctx} ctx · ${:.2}/${:.2} per Mtoken",
input_rate, output_rate
);
if promotional {
label.push_str(" · promo through 2026-08-31");
}
label
}
}
fn effective_rates_at(info: &ModelInfo, date: chrono::NaiveDate) -> (f32, f32, bool) {
let sonnet_5_promo_end =
chrono::NaiveDate::from_ymd_opt(2026, 8, 31).expect("valid Sonnet 5 promotional end date");
if info.id == "claude-sonnet-5" && date <= sonnet_5_promo_end {
(2.00, 10.00, true)
} else {
(info.input_per_mtoken_usd, info.output_per_mtoken_usd, false)
}
}
pub fn turn_cost_usd(
backend: BackendName,
model_id: &str,
tokens_in: u32,
tokens_out: u32,
) -> Option<f64> {
turn_cost_with_cache_usd(backend, model_id, tokens_in, 0, 0, tokens_out)
}
pub fn turn_cost_with_cache_usd(
backend: BackendName,
model_id: &str,
tokens_in: u32,
cached_input_tokens: u32,
cache_creation_input_tokens: u32,
tokens_out: u32,
) -> Option<f64> {
let info = lookup(backend, model_id)?;
let (input_rate, output_rate, _) = effective_rates_at(info, chrono::Utc::now().date_naive());
let cached = cached_input_tokens.min(tokens_in);
let cache_creation = cache_creation_input_tokens.min(tokens_in.saturating_sub(cached));
let regular = tokens_in
.saturating_sub(cached)
.saturating_sub(cache_creation);
let input_units = if matches!(backend, BackendName::Anthropic) {
regular as f64 + cached as f64 * 0.1 + cache_creation as f64 * 1.25
} else {
tokens_in as f64
};
let in_cost = input_units * input_rate as f64 / 1_000_000.0;
let out_cost = tokens_out as f64 * output_rate as f64 / 1_000_000.0;
Some(in_cost + out_cost)
}
pub fn format_usd(amount: f64) -> String {
if amount >= 0.01 {
format!("${amount:.2}")
} else if amount > 0.0 {
format!("${amount:.4}")
} else {
"$0.00".into()
}
}
fn format_context(tokens: u32) -> String {
if tokens >= 1_000_000 {
format!("{:.1}m", tokens as f32 / 1_000_000.0)
} else if tokens >= 1_000 {
format!("{}k", tokens / 1_000)
} else {
tokens.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_match_returns_entry() {
let info = lookup(BackendName::OpenAi, "gpt-4o-mini").unwrap();
assert_eq!(info.id, "gpt-4o-mini");
assert_eq!(info.context_tokens, 128_000);
}
#[test]
fn versioned_id_resolves_to_base_entry() {
let info = lookup(BackendName::OpenAi, "gpt-4o-2024-11-20").unwrap();
assert_eq!(info.id, "gpt-4o");
}
#[test]
fn longest_prefix_wins_for_mini_variants() {
let info = lookup(BackendName::OpenAi, "gpt-4o-mini-2024-07-18").unwrap();
assert_eq!(info.id, "gpt-4o-mini");
}
#[test]
fn prefix_must_break_on_dash_not_substring() {
assert!(lookup(BackendName::OpenAi, "gpt-4omega").is_none());
}
#[test]
fn unknown_model_returns_none() {
assert!(lookup(BackendName::OpenAi, "nonexistent-model").is_none());
}
#[test]
fn local_backends_have_no_catalog() {
assert!(lookup(BackendName::Ollama, "qwen2.5-coder:7b").is_none());
assert!(lookup(BackendName::LmStudio, "qwen2.5-coder-7b-instruct").is_none());
}
#[test]
fn openrouter_has_no_catalog() {
assert!(lookup(BackendName::Openrouter, "qwen/qwen-2.5-coder-32b-instruct").is_none());
}
#[test]
fn cost_label_renders_context_and_pricing() {
let info = lookup(BackendName::OpenAi, "gpt-4o-mini").unwrap();
let label = format_cost_label(info);
assert!(label.contains("128k ctx"));
assert!(label.contains("$0.15"));
assert!(label.contains("$0.60"));
assert!(label.contains("per Mtoken"));
}
#[test]
fn context_formatting_uses_k_and_m_suffixes() {
assert_eq!(format_context(8_192), "8k");
assert_eq!(format_context(128_000), "128k");
assert_eq!(format_context(1_500_000), "1.5m");
assert_eq!(format_context(500), "500");
}
#[test]
fn turn_cost_uses_catalog_rates() {
let cost = turn_cost_usd(BackendName::OpenAi, "gpt-4o-mini", 1_000_000, 100_000).unwrap();
assert!((cost - 0.21).abs() < 0.0001, "got {cost}");
}
#[test]
fn turn_cost_is_none_for_uncataloged_model() {
assert!(turn_cost_usd(BackendName::OpenAi, "future-model-9001", 1000, 100).is_none());
assert!(turn_cost_usd(BackendName::Ollama, "qwen2.5-coder:7b", 1000, 100).is_none());
}
#[test]
fn turn_cost_zero_for_zero_tokens() {
let cost = turn_cost_usd(BackendName::OpenAi, "gpt-4o", 0, 0).unwrap();
assert_eq!(cost, 0.0);
}
#[test]
fn anthropic_catalog_exposes_current_models() {
let info = lookup(BackendName::Anthropic, "claude-sonnet-5").unwrap();
assert_eq!(info.context_tokens, 1_000_000);
assert!(info.vision);
assert!(lookup(BackendName::Anthropic, "claude-haiku-4-5-20251001").is_some());
}
#[test]
fn sonnet_5_promotional_pricing_expires_on_schedule() {
let info = lookup(BackendName::Anthropic, "claude-sonnet-5").unwrap();
let during = chrono::NaiveDate::from_ymd_opt(2026, 8, 31).unwrap();
let after = chrono::NaiveDate::from_ymd_opt(2026, 9, 1).unwrap();
assert_eq!(effective_rates_at(info, during), (2.0, 10.0, true));
assert_eq!(effective_rates_at(info, after), (3.0, 15.0, false));
}
#[test]
fn anthropic_cache_cost_uses_published_multipliers() {
let cost = turn_cost_with_cache_usd(
BackendName::Anthropic,
"claude-haiku-4-5",
300,
100,
100,
100,
)
.unwrap();
let expected = (100.0 + 10.0 + 125.0) / 1_000_000.0 + 500.0 / 1_000_000.0;
assert!((cost - expected).abs() < 0.0000001, "got {cost}");
}
#[test]
fn format_usd_renders_sub_cent_with_extra_precision() {
assert_eq!(format_usd(0.0), "$0.00");
assert_eq!(format_usd(0.0003), "$0.0003");
assert_eq!(format_usd(0.01), "$0.01");
assert_eq!(format_usd(1.234), "$1.23");
assert_eq!(format_usd(42.0), "$42.00");
}
}