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 pub cache_read_per_m: f64,
12 pub cache_write_per_m: f64,
15 pub context_max: u64,
16}
17
18impl ModelProfile {
19 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
62pub 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 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 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
184fn 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 "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 "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
223fn 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}