Skip to main content

claudectl_core/
models.rs

1use std::collections::HashMap;
2use std::sync::{Mutex, OnceLock};
3
4#[derive(Debug, Clone, Copy, PartialEq)]
5pub struct ModelProfile {
6    pub input_per_m: f64,
7    pub output_per_m: f64,
8    /// Price of a cache *hit*. Tabled rather than derived because the
9    /// multiplier is per-model: 0.1x of base input for most models, but 0.05x
10    /// on Opus 5.5 / Sonnet 5.5 and 0.025x on Fable 5.1.
11    pub cache_read_per_m: f64,
12    /// Price of a **5-minute** cache write, the documented default. The
13    /// 1-hour rate is derived — see [`ModelProfile::cache_write_1h_per_m`].
14    pub cache_write_per_m: f64,
15    pub context_max: u64,
16}
17
18impl ModelProfile {
19    /// Price of a **1-hour** cache write.
20    ///
21    /// Unlike the read multiplier this one is universal — "1-hour cache write
22    /// tokens are 2 times the base input tokens price" — so it is computed
23    /// instead of occupying a column that would have to be kept in step with
24    /// `input_per_m` in every row.
25    pub fn cache_write_1h_per_m(&self) -> f64 {
26        self.input_per_m * 2.0
27    }
28}
29
30#[derive(Debug, Clone, PartialEq)]
31pub struct ModelOverride {
32    pub name: String,
33    pub profile: ModelProfile,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum ModelProfileSource {
38    BuiltIn,
39    Override,
40    Fallback,
41}
42
43impl ModelProfileSource {
44    pub fn label(self) -> &'static str {
45        match self {
46            Self::BuiltIn => "built-in",
47            Self::Override => "override",
48            Self::Fallback => "fallback",
49        }
50    }
51}
52
53#[derive(Debug, Clone, PartialEq)]
54pub struct ResolvedModelProfile {
55    pub key: String,
56    pub profile: ModelProfile,
57    pub source: ModelProfileSource,
58}
59
60static MODEL_OVERRIDES: OnceLock<Mutex<HashMap<String, ModelProfile>>> = OnceLock::new();
61
62/// Reduce an API model id to a pricing key, e.g. `claude-opus-5` ->
63/// `opus-5`, `claude-haiku-4-5-20251001` -> `haiku-4.5`.
64///
65/// This used to collapse anything it did not recognise to the bare family
66/// name, so `claude-opus-5` became `opus`, matched the `"opus"` arm of the
67/// price table, and was billed at retired **Opus 4.1** rates — three times
68/// its real price. A key with no version therefore no longer matches any
69/// built-in profile; it falls through to [`fallback_profile`] and is reported
70/// as unverified, because guessing a sibling's rates is the whole bug.
71pub fn shorten_model(model: &str) -> String {
72    let lower = model.trim().to_lowercase();
73    let family = ["opus", "sonnet", "haiku", "fable", "mythos"]
74        .into_iter()
75        .find(|f| lower.contains(f));
76    let Some(family) = family else {
77        return model.to_string();
78    };
79
80    // The version is the digit groups after the family name, stopping at the
81    // release date: `opus-4-6-20260401` -> 4.6, `opus-5` -> 5, `fable-5-1` ->
82    // 5.1. An already-shortened key (`opus-4.6`) carries its version dotted,
83    // and has to round-trip, because callers pass pricing keys back in.
84    let tail = &lower[lower.find(family).unwrap_or(0) + family.len()..];
85    let mut parts: Vec<String> = Vec::new();
86    for seg in tail.split('-') {
87        if seg.is_empty() {
88            continue;
89        }
90        // An 8-digit group is a release date, which ends the version.
91        if seg.len() >= 8 && seg.chars().all(|c| c.is_ascii_digit()) {
92            break;
93        }
94        if seg.contains('.') {
95            if seg.chars().all(|c| c.is_ascii_digit() || c == '.') {
96                parts = seg
97                    .split('.')
98                    .filter(|x| !x.is_empty())
99                    .map(String::from)
100                    .collect();
101            }
102            break;
103        }
104        if !seg.chars().all(|c| c.is_ascii_digit()) {
105            break;
106        }
107        parts.push(seg.to_string());
108        if parts.len() == 2 {
109            break;
110        }
111    }
112
113    if parts.is_empty() {
114        family.to_string()
115    } else {
116        format!("{family}-{}", parts.join("."))
117    }
118}
119
120pub fn set_overrides(overrides: Vec<ModelOverride>) {
121    let store = MODEL_OVERRIDES.get_or_init(|| Mutex::new(HashMap::new()));
122    let Ok(mut guard) = store.lock() else {
123        return;
124    };
125    guard.clear();
126    for override_ in overrides {
127        let raw = override_.name.trim().to_lowercase();
128        let shortened = shorten_model(&override_.name).to_lowercase();
129        guard.insert(raw, override_.profile);
130        guard.insert(shortened, override_.profile);
131    }
132}
133
134pub fn resolve(model: &str) -> ResolvedModelProfile {
135    let empty = HashMap::new();
136    let store = MODEL_OVERRIDES.get_or_init(|| Mutex::new(HashMap::new()));
137    let guard = store.lock().ok();
138    let overrides = guard.as_deref().unwrap_or(&empty);
139    resolve_with_overrides(model, overrides)
140}
141
142pub(crate) fn resolve_with_overrides(
143    model: &str,
144    overrides: &HashMap<String, ModelProfile>,
145) -> ResolvedModelProfile {
146    let raw_key = model.trim().to_lowercase();
147    let short_key = shorten_model(model).to_lowercase();
148
149    if let Some(profile) = overrides
150        .get(&raw_key)
151        .or_else(|| overrides.get(&short_key))
152        .copied()
153    {
154        return ResolvedModelProfile {
155            key: if raw_key.is_empty() {
156                short_key
157            } else {
158                raw_key
159            },
160            profile,
161            source: ModelProfileSource::Override,
162        };
163    }
164
165    if let Some(profile) = built_in_profile(&short_key) {
166        return ResolvedModelProfile {
167            key: short_key,
168            profile,
169            source: ModelProfileSource::BuiltIn,
170        };
171    }
172
173    ResolvedModelProfile {
174        key: if short_key.is_empty() {
175            "unknown".into()
176        } else {
177            short_key
178        },
179        profile: fallback_profile(),
180        source: ModelProfileSource::Fallback,
181    }
182}
183
184/// Prices per million tokens, from
185/// <https://platform.claude.com/docs/en/about-claude/pricing> as of 2026-10-08.
186///
187/// `cache_write_per_m` is the **5-minute** rate (1.25x base input); the 1-hour
188/// rate is derived. Retired models are kept because a transcript recorded
189/// before their retirement still has to be priced as it was billed.
190///
191/// Haiku 5.5 is deliberately absent: it is priced by prompt length, which this
192/// one-row-per-model shape cannot express, so it resolves as unverified rather
193/// than wrong.
194fn built_in_profile(key: &str) -> Option<ModelProfile> {
195    let p = |input_per_m, output_per_m, cache_read_per_m, cache_write_per_m, context_max| {
196        Some(ModelProfile {
197            input_per_m,
198            output_per_m,
199            cache_read_per_m,
200            cache_write_per_m,
201            context_max,
202        })
203    };
204    match key {
205        // Claude 4.6 and later carry the full 1M window at standard pricing.
206        "fable-5.1" | "mythos-5.1" => p(10.0, 50.0, 0.25, 12.50, 1_000_000),
207        "fable-5" | "mythos-5" => p(10.0, 50.0, 1.00, 12.50, 1_000_000),
208        "opus-5.5" => p(4.0, 20.0, 0.20, 5.00, 1_000_000),
209        "opus-5" | "opus-4.8" | "opus-4.7" | "opus-4.6" => p(5.0, 25.0, 0.50, 6.25, 1_000_000),
210        "opus-4.5" => p(5.0, 25.0, 0.50, 6.25, 200_000),
211        // Retired, but still the correct price for an older transcript.
212        "opus-4.1" | "opus-4" => p(15.0, 75.0, 1.50, 18.75, 200_000),
213        "sonnet-5.5" => p(2.0, 10.0, 0.10, 2.50, 1_000_000),
214        "sonnet-5" => p(2.0, 10.0, 0.20, 2.50, 1_000_000),
215        "sonnet-4.6" => p(3.0, 15.0, 0.30, 3.75, 1_000_000),
216        "sonnet-4.5" | "sonnet-4" => p(3.0, 15.0, 0.30, 3.75, 200_000),
217        "haiku-4.5" => p(1.0, 5.0, 0.10, 1.25, 200_000),
218        "haiku-3.5" => p(0.80, 4.0, 0.08, 1.00, 200_000),
219        _ => None,
220    }
221}
222
223/// Used when the model id matches no row above. Reported as
224/// `ModelProfileSource::Fallback`, which surfaces as `verified: false`.
225///
226/// Set to current Opus 5 rates rather than the retired Opus 4.1 rates it used
227/// to carry: an unknown *Claude* id is far likelier to be a current model than
228/// a 2025 one, and the old value overstated an unknown model's cost threefold.
229fn fallback_profile() -> ModelProfile {
230    ModelProfile {
231        input_per_m: 5.0,
232        output_per_m: 25.0,
233        cache_read_per_m: 0.50,
234        cache_write_per_m: 6.25,
235        context_max: 200_000,
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn resolve_builtin_profile() {
245        let resolved = resolve_with_overrides("claude-opus-4-6-20260401", &HashMap::new());
246        assert_eq!(resolved.source, ModelProfileSource::BuiltIn);
247        assert_eq!(resolved.profile.context_max, 1_000_000);
248    }
249
250    #[test]
251    fn resolve_override_profile() {
252        let mut overrides = HashMap::new();
253        overrides.insert(
254            "gpt-4o".into(),
255            ModelProfile {
256                input_per_m: 1.0,
257                output_per_m: 2.0,
258                cache_read_per_m: 0.5,
259                cache_write_per_m: 1.5,
260                context_max: 128_000,
261            },
262        );
263        let resolved = resolve_with_overrides("gpt-4o", &overrides);
264        assert_eq!(resolved.source, ModelProfileSource::Override);
265        assert_eq!(resolved.profile.context_max, 128_000);
266    }
267
268    #[test]
269    fn resolve_fallback_profile() {
270        let resolved = resolve_with_overrides("mystery-model", &HashMap::new());
271        assert_eq!(resolved.source, ModelProfileSource::Fallback);
272        assert_eq!(resolved.profile.context_max, 200_000);
273    }
274}