use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ModelProfile {
pub input_per_m: f64,
pub output_per_m: f64,
pub cache_read_per_m: f64,
pub cache_write_per_m: f64,
pub context_max: u64,
}
impl ModelProfile {
pub fn cache_write_1h_per_m(&self) -> f64 {
self.input_per_m * 2.0
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ModelOverride {
pub name: String,
pub profile: ModelProfile,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelProfileSource {
BuiltIn,
Override,
Fallback,
}
impl ModelProfileSource {
pub fn label(self) -> &'static str {
match self {
Self::BuiltIn => "built-in",
Self::Override => "override",
Self::Fallback => "fallback",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedModelProfile {
pub key: String,
pub profile: ModelProfile,
pub source: ModelProfileSource,
}
static MODEL_OVERRIDES: OnceLock<Mutex<HashMap<String, ModelProfile>>> = OnceLock::new();
pub fn shorten_model(model: &str) -> String {
let lower = model.trim().to_lowercase();
let family = ["opus", "sonnet", "haiku", "fable", "mythos"]
.into_iter()
.find(|f| lower.contains(f));
let Some(family) = family else {
return model.to_string();
};
let tail = &lower[lower.find(family).unwrap_or(0) + family.len()..];
let mut parts: Vec<String> = Vec::new();
for seg in tail.split('-') {
if seg.is_empty() {
continue;
}
if seg.len() >= 8 && seg.chars().all(|c| c.is_ascii_digit()) {
break;
}
if seg.contains('.') {
if seg.chars().all(|c| c.is_ascii_digit() || c == '.') {
parts = seg
.split('.')
.filter(|x| !x.is_empty())
.map(String::from)
.collect();
}
break;
}
if !seg.chars().all(|c| c.is_ascii_digit()) {
break;
}
parts.push(seg.to_string());
if parts.len() == 2 {
break;
}
}
if parts.is_empty() {
family.to_string()
} else {
format!("{family}-{}", parts.join("."))
}
}
pub fn set_overrides(overrides: Vec<ModelOverride>) {
let store = MODEL_OVERRIDES.get_or_init(|| Mutex::new(HashMap::new()));
let Ok(mut guard) = store.lock() else {
return;
};
guard.clear();
for override_ in overrides {
let raw = override_.name.trim().to_lowercase();
let shortened = shorten_model(&override_.name).to_lowercase();
guard.insert(raw, override_.profile);
guard.insert(shortened, override_.profile);
}
}
pub fn resolve(model: &str) -> ResolvedModelProfile {
let empty = HashMap::new();
let store = MODEL_OVERRIDES.get_or_init(|| Mutex::new(HashMap::new()));
let guard = store.lock().ok();
let overrides = guard.as_deref().unwrap_or(&empty);
resolve_with_overrides(model, overrides)
}
pub(crate) fn resolve_with_overrides(
model: &str,
overrides: &HashMap<String, ModelProfile>,
) -> ResolvedModelProfile {
let raw_key = model.trim().to_lowercase();
let short_key = shorten_model(model).to_lowercase();
if let Some(profile) = overrides
.get(&raw_key)
.or_else(|| overrides.get(&short_key))
.copied()
{
return ResolvedModelProfile {
key: if raw_key.is_empty() {
short_key
} else {
raw_key
},
profile,
source: ModelProfileSource::Override,
};
}
if let Some(profile) = built_in_profile(&short_key) {
return ResolvedModelProfile {
key: short_key,
profile,
source: ModelProfileSource::BuiltIn,
};
}
ResolvedModelProfile {
key: if short_key.is_empty() {
"unknown".into()
} else {
short_key
},
profile: fallback_profile(),
source: ModelProfileSource::Fallback,
}
}
fn built_in_profile(key: &str) -> Option<ModelProfile> {
let p = |input_per_m, output_per_m, cache_read_per_m, cache_write_per_m, context_max| {
Some(ModelProfile {
input_per_m,
output_per_m,
cache_read_per_m,
cache_write_per_m,
context_max,
})
};
match key {
"fable-5.1" | "mythos-5.1" => p(10.0, 50.0, 0.25, 12.50, 1_000_000),
"fable-5" | "mythos-5" => p(10.0, 50.0, 1.00, 12.50, 1_000_000),
"opus-5.5" => p(4.0, 20.0, 0.20, 5.00, 1_000_000),
"opus-5" | "opus-4.8" | "opus-4.7" | "opus-4.6" => p(5.0, 25.0, 0.50, 6.25, 1_000_000),
"opus-4.5" => p(5.0, 25.0, 0.50, 6.25, 200_000),
"opus-4.1" | "opus-4" => p(15.0, 75.0, 1.50, 18.75, 200_000),
"sonnet-5.5" => p(2.0, 10.0, 0.10, 2.50, 1_000_000),
"sonnet-5" => p(2.0, 10.0, 0.20, 2.50, 1_000_000),
"sonnet-4.6" => p(3.0, 15.0, 0.30, 3.75, 1_000_000),
"sonnet-4.5" | "sonnet-4" => p(3.0, 15.0, 0.30, 3.75, 200_000),
"haiku-4.5" => p(1.0, 5.0, 0.10, 1.25, 200_000),
"haiku-3.5" => p(0.80, 4.0, 0.08, 1.00, 200_000),
_ => None,
}
}
fn fallback_profile() -> ModelProfile {
ModelProfile {
input_per_m: 5.0,
output_per_m: 25.0,
cache_read_per_m: 0.50,
cache_write_per_m: 6.25,
context_max: 200_000,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_builtin_profile() {
let resolved = resolve_with_overrides("claude-opus-4-6-20260401", &HashMap::new());
assert_eq!(resolved.source, ModelProfileSource::BuiltIn);
assert_eq!(resolved.profile.context_max, 1_000_000);
}
#[test]
fn resolve_override_profile() {
let mut overrides = HashMap::new();
overrides.insert(
"gpt-4o".into(),
ModelProfile {
input_per_m: 1.0,
output_per_m: 2.0,
cache_read_per_m: 0.5,
cache_write_per_m: 1.5,
context_max: 128_000,
},
);
let resolved = resolve_with_overrides("gpt-4o", &overrides);
assert_eq!(resolved.source, ModelProfileSource::Override);
assert_eq!(resolved.profile.context_max, 128_000);
}
#[test]
fn resolve_fallback_profile() {
let resolved = resolve_with_overrides("mystery-model", &HashMap::new());
assert_eq!(resolved.source, ModelProfileSource::Fallback);
assert_eq!(resolved.profile.context_max, 200_000);
}
}