#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum KnownModel {
Opus,
Fable5,
Mythos5,
MythosPreview,
Sonnet5,
}
impl KnownModel {
fn bytes_per_token(self) -> f64 {
match self {
KnownModel::Opus => 3.6,
KnownModel::Sonnet5 => 3.7,
KnownModel::Fable5 => 3.8,
KnownModel::Mythos5 => 3.7,
KnownModel::MythosPreview => 3.7,
}
}
}
const UNKNOWN_MODEL_BYTES_PER_TOKEN: f64 = 3.7;
pub fn classify_model(model: &str) -> Option<KnownModel> {
let m = model.to_ascii_lowercase();
if m.contains("opus") {
return match opus_version(&m) {
Some((major, minor)) if (major, minor) >= (4, 7) => Some(KnownModel::Opus),
Some(_) => None,
None => Some(KnownModel::Opus),
};
}
if m.contains("mythos") {
if m.contains("preview") {
return Some(KnownModel::MythosPreview);
}
if mentions_generation(&m, 5) {
return Some(KnownModel::Mythos5);
}
return None;
}
if m.contains("fable") && mentions_generation(&m, 5) {
return Some(KnownModel::Fable5);
}
if m.contains("sonnet") && mentions_generation(&m, 5) {
return Some(KnownModel::Sonnet5);
}
None
}
fn mentions_generation(m: &str, generation: u32) -> bool {
let g = generation.to_string();
m.split(|c: char| !c.is_ascii_digit()).any(|tok| tok == g)
}
fn opus_version(m: &str) -> Option<(u32, u32)> {
let after = m.split("opus").nth(1)?;
let mut nums = after
.split(|c: char| !c.is_ascii_digit())
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse::<u32>().ok());
let major = nums.next()?;
let minor = nums.next().unwrap_or(0);
Some((major, minor))
}
#[derive(Clone, Copy, Debug)]
pub struct TokenEstimate {
pub input_tokens: u64,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Estimator;
impl Estimator {
pub fn estimate(self, model: &str, body_len: usize) -> TokenEstimate {
let ratio = classify_model(model)
.map(KnownModel::bytes_per_token)
.unwrap_or(UNKNOWN_MODEL_BYTES_PER_TOKEN);
let input_tokens = ((body_len as f64) / ratio).ceil() as u64;
TokenEstimate {
input_tokens: input_tokens.max(if body_len > 0 { 1 } else { 0 }),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn full_d21_set_is_classified() {
assert_eq!(classify_model("claude-opus-4-8"), Some(KnownModel::Opus));
assert_eq!(classify_model("claude-opus-4-7"), Some(KnownModel::Opus));
assert_eq!(classify_model("claude-sonnet-5"), Some(KnownModel::Sonnet5));
assert_eq!(classify_model("claude-fable-5"), Some(KnownModel::Fable5));
assert_eq!(classify_model("claude-mythos-5"), Some(KnownModel::Mythos5));
assert_eq!(
classify_model("claude-mythos-preview"),
Some(KnownModel::MythosPreview)
);
}
#[test]
fn older_opus_and_unrelated_models_are_unknown() {
assert_eq!(classify_model("claude-opus-4-6"), None);
assert_eq!(classify_model("claude-haiku-3"), None);
assert_eq!(classify_model("gpt-4o"), None);
assert_eq!(classify_model("claude-sonnet-4-6"), None);
}
#[test]
fn estimate_is_nonzero_offline_and_deterministic() {
let est = Estimator;
let a = est.estimate("claude-opus-4-8", 3600);
let b = est.estimate("claude-opus-4-8", 3600);
assert!(a.input_tokens > 0);
assert_eq!(a.input_tokens, b.input_tokens, "estimate is deterministic");
assert_eq!(est.estimate("claude-opus-4-8", 0).input_tokens, 0);
}
#[test]
fn unknown_model_still_estimates() {
let est = Estimator;
assert!(est.estimate("mystery-model-9", 4000).input_tokens > 0);
}
}