use hotl_types::TokenUsage;
pub const DEFAULT_MODEL: &str = "claude-opus-4-8";
pub const FALLBACK_CONTEXT_WINDOW: u64 = 200_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Caps {
pub thinking: bool,
pub images: bool,
pub effort: bool,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ModelInfo {
pub id: &'static str,
pub context_window: u64,
pub max_output_tokens: u32,
pub input_usd_per_mtok: f64,
pub output_usd_per_mtok: f64,
pub cache_read_usd_per_mtok: f64,
pub cache_write_usd_per_mtok: f64,
pub cache_write_1h_usd_per_mtok: f64,
pub min_cacheable_prefix: u64,
pub ascii_chars_per_token: f32,
pub caps: Caps,
}
const FULL: Caps = Caps {
thinking: true,
images: true,
effort: true,
};
const NO_EFFORT: Caps = Caps {
thinking: true,
images: true,
effort: false,
};
pub const CATALOG: &[ModelInfo] = &[
ModelInfo {
id: "claude-fable-5",
context_window: 1_000_000,
max_output_tokens: 128_000,
input_usd_per_mtok: 10.00,
output_usd_per_mtok: 50.00,
cache_read_usd_per_mtok: 1.00,
cache_write_usd_per_mtok: 12.50,
cache_write_1h_usd_per_mtok: 20.00,
min_cacheable_prefix: 512,
ascii_chars_per_token: 3.0,
caps: FULL,
},
ModelInfo {
id: "claude-opus-5",
context_window: 1_000_000,
max_output_tokens: 128_000,
input_usd_per_mtok: 5.00,
output_usd_per_mtok: 25.00,
cache_read_usd_per_mtok: 0.50,
cache_write_usd_per_mtok: 6.25,
cache_write_1h_usd_per_mtok: 10.00,
min_cacheable_prefix: 512,
ascii_chars_per_token: 3.0,
caps: FULL,
},
ModelInfo {
id: "claude-opus-4-8",
context_window: 1_000_000,
max_output_tokens: 128_000,
input_usd_per_mtok: 5.00,
output_usd_per_mtok: 25.00,
cache_read_usd_per_mtok: 0.50,
cache_write_usd_per_mtok: 6.25,
cache_write_1h_usd_per_mtok: 10.00,
min_cacheable_prefix: 1024,
ascii_chars_per_token: 3.0,
caps: FULL,
},
ModelInfo {
id: "claude-opus-4-7",
context_window: 1_000_000,
max_output_tokens: 128_000,
input_usd_per_mtok: 5.00,
output_usd_per_mtok: 25.00,
cache_read_usd_per_mtok: 0.50,
cache_write_usd_per_mtok: 6.25,
cache_write_1h_usd_per_mtok: 10.00,
min_cacheable_prefix: 2048,
ascii_chars_per_token: 3.0,
caps: FULL,
},
ModelInfo {
id: "claude-opus-4-6",
context_window: 1_000_000,
max_output_tokens: 128_000,
input_usd_per_mtok: 5.00,
output_usd_per_mtok: 25.00,
cache_read_usd_per_mtok: 0.50,
cache_write_usd_per_mtok: 6.25,
cache_write_1h_usd_per_mtok: 10.00,
min_cacheable_prefix: 4096,
ascii_chars_per_token: 3.5,
caps: FULL,
},
ModelInfo {
id: "claude-sonnet-5",
context_window: 1_000_000,
max_output_tokens: 128_000,
input_usd_per_mtok: 3.00,
output_usd_per_mtok: 15.00,
cache_read_usd_per_mtok: 0.30,
cache_write_usd_per_mtok: 3.75,
cache_write_1h_usd_per_mtok: 6.00,
min_cacheable_prefix: 1024,
ascii_chars_per_token: 3.0,
caps: FULL,
},
ModelInfo {
id: "claude-sonnet-4-6",
context_window: 1_000_000,
max_output_tokens: 128_000,
input_usd_per_mtok: 3.00,
output_usd_per_mtok: 15.00,
cache_read_usd_per_mtok: 0.30,
cache_write_usd_per_mtok: 3.75,
cache_write_1h_usd_per_mtok: 6.00,
min_cacheable_prefix: 1024,
ascii_chars_per_token: 3.5,
caps: FULL,
},
ModelInfo {
id: "claude-haiku-4-5",
context_window: 200_000,
max_output_tokens: 64_000,
input_usd_per_mtok: 1.00,
output_usd_per_mtok: 5.00,
cache_read_usd_per_mtok: 0.10,
cache_write_usd_per_mtok: 1.25,
cache_write_1h_usd_per_mtok: 2.00,
min_cacheable_prefix: 4096,
ascii_chars_per_token: 3.5,
caps: NO_EFFORT,
},
];
pub fn lookup(model: &str) -> Option<&'static ModelInfo> {
if model.is_empty() {
return None;
}
if let Some(hit) = CATALOG.iter().find(|m| m.id == model) {
return Some(hit);
}
let bare = model
.split_once('/')
.or_else(|| model.split_once('.'))
.map(|(_, rest)| rest)
.unwrap_or(model);
if let Some(hit) = CATALOG.iter().find(|m| m.id == bare) {
return Some(hit);
}
CATALOG
.iter()
.filter(|m| bare.starts_with(m.id))
.max_by_key(|m| m.id.len())
}
pub fn context_window(model: &str) -> Option<u64> {
lookup(model).map(|m| m.context_window)
}
pub fn max_output_tokens(model: &str) -> Option<u32> {
lookup(model).map(|m| m.max_output_tokens)
}
pub fn cost_usd(model: &str, usage: &TokenUsage) -> Option<f64> {
let m = lookup(model)?;
let per = |tokens: u64, rate: f64| (tokens as f64 / 1_000_000.0) * rate;
let no_breakdown =
usage.cache_creation_5m_input_tokens == 0 && usage.cache_creation_1h_input_tokens == 0;
let creation_cost = if no_breakdown {
per(
usage.cache_creation_input_tokens,
m.cache_write_usd_per_mtok,
)
} else {
let excess = usage.cache_creation_input_tokens.saturating_sub(
usage.cache_creation_5m_input_tokens + usage.cache_creation_1h_input_tokens,
);
per(
usage.cache_creation_5m_input_tokens,
m.cache_write_usd_per_mtok,
) + per(
usage.cache_creation_1h_input_tokens,
m.cache_write_1h_usd_per_mtok,
) + per(excess, m.cache_write_usd_per_mtok)
};
Some(
per(usage.input_tokens, m.input_usd_per_mtok)
+ per(usage.output_tokens, m.output_usd_per_mtok)
+ per(usage.cache_read_input_tokens, m.cache_read_usd_per_mtok)
+ creation_cost,
)
}
pub fn min_cacheable_prefix(model: &str) -> Option<u64> {
lookup(model).map(|m| m.min_cacheable_prefix)
}
pub fn is_cacheable_prefix(model: &str, estimated_tokens: u64) -> bool {
match min_cacheable_prefix(model) {
Some(min) => estimated_tokens >= min,
None => true,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_model_is_in_the_catalog_and_has_a_million_token_window() {
let info = lookup(DEFAULT_MODEL).expect("the default model must be catalogued");
assert_eq!(info.id, "claude-opus-4-8");
assert_eq!(info.context_window, 1_000_000);
assert_eq!(info.max_output_tokens, 128_000);
assert!(
info.context_window > FALLBACK_CONTEXT_WINDOW,
"the whole point of the catalog is that 200K is wrong here"
);
}
#[test]
fn lookup_strips_provider_prefixes_and_dated_suffixes() {
assert_eq!(
lookup("anthropic/claude-opus-5").unwrap().id,
"claude-opus-5"
);
assert_eq!(
lookup("anthropic.claude-opus-5").unwrap().id,
"claude-opus-5"
);
assert_eq!(
lookup("claude-haiku-4-5-20251001").unwrap().id,
"claude-haiku-4-5"
);
assert_eq!(lookup("claude-opus-4-8").unwrap().id, "claude-opus-4-8");
}
#[test]
fn unknown_models_are_none_not_an_error() {
assert!(lookup("llama3").is_none());
assert!(lookup("openai/gpt-5").is_none());
assert!(lookup("").is_none());
}
#[test]
fn window_and_output_resolve_per_model_and_none_for_unknown() {
assert_eq!(context_window("claude-opus-4-8"), Some(1_000_000));
assert_eq!(context_window("claude-haiku-4-5"), Some(200_000));
assert_eq!(context_window("anthropic/claude-sonnet-5"), Some(1_000_000));
assert_eq!(context_window("llama3"), None);
assert_eq!(max_output_tokens("claude-haiku-4-5"), Some(64_000));
assert_eq!(max_output_tokens("claude-opus-5"), Some(128_000));
assert_eq!(max_output_tokens("mistral-large"), None);
}
#[test]
fn the_engines_default_max_tokens_fits_every_catalogued_model() {
for m in CATALOG {
assert!(
m.max_output_tokens >= 32_000,
"{} caps output at {} < the engine default 32_000",
m.id,
m.max_output_tokens
);
}
}
#[test]
fn cost_prices_each_usage_bucket_at_its_own_rate() {
let usage = TokenUsage {
input_tokens: 1_000_000,
output_tokens: 1_000_000,
cache_read_input_tokens: 1_000_000,
cache_creation_input_tokens: 1_000_000,
..Default::default()
};
let cost = cost_usd("claude-opus-4-8", &usage).unwrap();
assert!((cost - 36.75).abs() < 1e-9, "was {cost}");
assert!(cost_usd("llama3", &usage).is_none());
let cached = TokenUsage {
input_tokens: 0,
cache_read_input_tokens: 1_000_000,
..Default::default()
};
let uncached = TokenUsage {
input_tokens: 1_000_000,
..Default::default()
};
assert!(
cost_usd("claude-opus-4-8", &cached).unwrap()
< cost_usd("claude-opus-4-8", &uncached).unwrap()
);
}
#[test]
fn cost_prices_the_1h_bucket_at_double_the_5m_bucket() {
let usage = TokenUsage {
cache_creation_5m_input_tokens: 1_000_000,
cache_creation_1h_input_tokens: 1_000_000,
cache_creation_input_tokens: 2_000_000,
..Default::default()
};
let cost = cost_usd("claude-opus-4-8", &usage).unwrap();
assert!((cost - 16.25).abs() < 1e-9, "was {cost}");
}
#[test]
fn cost_prices_the_breakdown_shortfall_at_the_5m_rate() {
let usage = TokenUsage {
cache_creation_5m_input_tokens: 500_000,
cache_creation_1h_input_tokens: 300_000,
cache_creation_input_tokens: 1_000_000,
..Default::default()
};
let cost = cost_usd("claude-opus-4-8", &usage).unwrap();
assert!((cost - 7.375).abs() < 1e-9, "was {cost}");
}
#[test]
fn cost_falls_back_to_the_5m_rate_when_the_ttl_breakdown_is_absent() {
let usage = TokenUsage {
cache_creation_input_tokens: 1_000_000,
..Default::default()
};
let cost = cost_usd("claude-opus-4-8", &usage).unwrap();
assert!((cost - 6.25).abs() < 1e-9, "was {cost}");
}
#[test]
fn cacheable_prefix_predicate_knows_the_generation_split() {
assert_eq!(min_cacheable_prefix("claude-opus-5"), Some(512));
assert_eq!(min_cacheable_prefix("claude-opus-4-8"), Some(1024));
assert_eq!(min_cacheable_prefix("claude-opus-4-6"), Some(4096));
assert_eq!(min_cacheable_prefix("claude-haiku-4-5"), Some(4096));
assert!(!is_cacheable_prefix("claude-opus-5", 300));
assert!(is_cacheable_prefix("claude-opus-5", 600));
assert!(!is_cacheable_prefix("claude-opus-4-8", 600));
assert!(is_cacheable_prefix("llama3", 1));
}
#[test]
fn every_row_is_internally_consistent() {
for m in CATALOG {
assert!(m.context_window >= 8_000, "{}: window", m.id);
assert!(
(m.max_output_tokens as u64) < m.context_window,
"{}: output cap must fit inside the window",
m.id
);
assert!(
m.output_usd_per_mtok > m.input_usd_per_mtok,
"{}: prices",
m.id
);
assert!(
m.cache_read_usd_per_mtok < m.input_usd_per_mtok,
"{}: cache read",
m.id
);
assert!(
m.cache_write_usd_per_mtok > m.input_usd_per_mtok,
"{}: cache write",
m.id
);
assert!(
(m.cache_write_1h_usd_per_mtok - m.input_usd_per_mtok * 2.0).abs() < 1e-9,
"{}: 1h cache write must be exactly 2x input",
m.id
);
assert!(
m.cache_write_1h_usd_per_mtok > m.cache_write_usd_per_mtok,
"{}: 1h write must cost more than the 5m default",
m.id
);
assert!(m.min_cacheable_prefix >= 512, "{}: cache prefix", m.id);
assert!(m.ascii_chars_per_token > 1.0, "{}: ratio", m.id);
assert_eq!(
CATALOG.iter().filter(|o| o.id == m.id).count(),
1,
"{}: duplicated",
m.id
);
}
}
}