1use serde_json::Value;
20
21use forge_primitives::{AvailableModel, CurrentModel, EffortLevel};
22
23const OPUS_MODEL_ALIAS: &str = "opus";
24const MAX_MODEL_VERSION_PARTS: usize = 2;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27enum ModelFamily {
28 Opus,
29 Sonnet,
30 Haiku,
31 Fable,
32 Unknown,
33}
34
35#[derive(Debug)]
36struct NormalizedModelKey {
37 original: String,
38 family: ModelFamily,
39 version_parts: Vec<u32>,
40 variant_parts: Vec<String>,
41 context_suffix: String,
43}
44
45fn normalize_model_key(id: &str) -> NormalizedModelKey {
46 let original = id.trim().to_owned();
47 if original.is_empty() {
48 return NormalizedModelKey {
49 original,
50 family: ModelFamily::Unknown,
51 version_parts: Vec::new(),
52 variant_parts: Vec::new(),
53 context_suffix: String::new(),
54 };
55 }
56 let lower = original.to_ascii_lowercase();
57 let (without_context, context_suffix) = if let Some(open) = lower.rfind('[')
58 && lower.ends_with(']')
59 {
60 (lower[..open].to_owned(), lower[open + 1..lower.len() - 1].to_owned())
61 } else {
62 (lower.clone(), String::new())
63 };
64 let trimmed_pre = without_context.trim_end_matches('-');
65 let without_prefix = trimmed_pre.strip_prefix("claude-").unwrap_or(trimmed_pre);
66 let mut parts = without_prefix.split('-').filter(|p| !p.is_empty());
67 let family_part = parts.next().unwrap_or("");
68 let family = match family_part {
69 "opus" => ModelFamily::Opus,
70 "sonnet" => ModelFamily::Sonnet,
71 "haiku" => ModelFamily::Haiku,
72 "fable" => ModelFamily::Fable,
73 _ => ModelFamily::Unknown,
74 };
75
76 let mut version_parts: Vec<u32> = Vec::new();
77 let mut variant_parts: Vec<String> = Vec::new();
78
79 if family != ModelFamily::Unknown {
80 for part in parts {
81 if part.chars().all(|c| c.is_ascii_digit()) {
82 if version_parts.len() < MAX_MODEL_VERSION_PARTS {
83 if let Ok(v) = part.parse::<u32>() {
84 version_parts.push(v);
85 }
86 continue;
87 }
88 if part.len() == 8
92 && part.starts_with("20")
93 && part.chars().all(|c| c.is_ascii_digit())
94 {
95 continue;
96 }
97 }
98 variant_parts.push(part.to_owned());
99 }
100 }
101
102 NormalizedModelKey { original, family, version_parts, variant_parts, context_suffix }
103}
104
105fn family_label(family: ModelFamily) -> Option<&'static str> {
106 match family {
107 ModelFamily::Opus => Some("Opus"),
108 ModelFamily::Sonnet => Some("Sonnet"),
109 ModelFamily::Haiku => Some("Haiku"),
110 ModelFamily::Fable => Some("Fable"),
111 ModelFamily::Unknown => None,
112 }
113}
114
115fn format_humanized(key: &NormalizedModelKey) -> String {
116 let Some(family_lbl) = family_label(key.family) else {
117 return key.original.clone();
118 };
119 let version_lbl = if key.version_parts.is_empty() {
120 String::new()
121 } else {
122 format!(" {}", key.version_parts.iter().map(u32::to_string).collect::<Vec<_>>().join("."))
123 };
124 let context_lbl = match key.context_suffix.as_str() {
125 "" => String::new(),
126 s if s.eq_ignore_ascii_case("1m") => " [1M]".to_owned(),
127 s => format!(" [{s}]"),
128 };
129 format!("{family_lbl}{version_lbl}{context_lbl}")
130}
131
132fn humanize_model_id(id: &str) -> String {
133 format_humanized(&normalize_model_key(id))
134}
135
136fn model_keys_are_compatible(left_id: &str, right_id: &str) -> bool {
137 let left = normalize_model_key(left_id);
138 let right = normalize_model_key(right_id);
139 if left.family == ModelFamily::Unknown || right.family == ModelFamily::Unknown {
140 return left.original.eq_ignore_ascii_case(&right.original);
141 }
142 if left.family != right.family {
143 return false;
144 }
145 if left.variant_parts.join(".") != right.variant_parts.join(".") {
146 return false;
147 }
148 if left.version_parts.is_empty() || right.version_parts.is_empty() {
149 return true;
150 }
151 left.version_parts == right.version_parts
152}
153
154fn same_context_suffix(left_id: &str, right_id: &str) -> bool {
155 normalize_model_key(left_id).context_suffix == normalize_model_key(right_id).context_suffix
156}
157
158fn same_family_and_version(left_id: &str, right_id: &str) -> bool {
159 let left = normalize_model_key(left_id);
160 let right = normalize_model_key(right_id);
161 if left.family == ModelFamily::Unknown || right.family == ModelFamily::Unknown {
162 return left.original.eq_ignore_ascii_case(&right.original);
163 }
164 if left.family != right.family {
165 return false;
166 }
167 if left.version_parts.is_empty() || right.version_parts.is_empty() {
168 return left.version_parts.len() == right.version_parts.len();
169 }
170 left.version_parts == right.version_parts
171}
172
173fn has_variant_sibling_conflict(
174 available_models: &[AvailableModel],
175 candidate_id: &str,
176 resolved_id: &str,
177) -> bool {
178 if same_context_suffix(candidate_id, resolved_id) {
179 return false;
180 }
181 let resolved_context = normalize_model_key(resolved_id).context_suffix;
182 if resolved_context.is_empty() {
183 return false;
184 }
185 available_models.iter().any(|entry| {
186 if entry.id == candidate_id {
187 return false;
188 }
189 if !same_family_and_version(&entry.id, resolved_id) {
190 return false;
191 }
192 normalize_model_key(&entry.id).context_suffix == resolved_context
193 })
194}
195
196fn current_model_is_authoritative(resolved_id: &str, requested_id: Option<&str>) -> bool {
197 let resolved = resolved_id.trim();
198 if resolved.is_empty() || resolved == "Connecting..." {
199 return requested_id.is_some_and(|s| !s.trim().is_empty());
200 }
201 true
202}
203
204fn resolve_catalog_model<'a>(
205 available_models: &'a [AvailableModel],
206 resolved_id: &str,
207 requested_id: Option<&str>,
208) -> Option<&'a AvailableModel> {
209 if let Some(exact) = available_models.iter().find(|m| m.id == resolved_id) {
210 return Some(exact);
211 }
212 if let Some(req) = requested_id
213 && let Some(exact_requested) = available_models.iter().find(|m| m.id == req)
214 && model_keys_are_compatible(&exact_requested.id, resolved_id)
215 && !has_variant_sibling_conflict(available_models, &exact_requested.id, resolved_id)
216 {
217 return Some(exact_requested);
218 }
219 let compatible: Vec<&AvailableModel> = available_models
220 .iter()
221 .filter(|m| {
222 model_keys_are_compatible(&m.id, resolved_id)
223 && !has_variant_sibling_conflict(available_models, &m.id, resolved_id)
224 })
225 .collect();
226 if compatible.len() == 1 {
227 return Some(compatible[0]);
228 }
229 None
230}
231
232pub fn resolve_current_model_from_inputs(
237 model_id: &str,
238 requested_model_id: Option<&str>,
239 resolved_runtime_model_id: Option<&str>,
240 available_models: &[AvailableModel],
241) -> CurrentModel {
242 let requested_id = requested_model_id.map(str::trim).filter(|s| !s.is_empty());
243 let resolved_id =
244 resolved_runtime_model_id.map(str::trim).filter(|s| !s.is_empty()).map_or_else(
245 || {
246 if model_id.trim().is_empty() {
247 requested_id.unwrap_or(OPUS_MODEL_ALIAS).to_owned()
248 } else {
249 model_id.trim().to_owned()
250 }
251 },
252 str::to_owned,
253 );
254
255 let catalog = resolve_catalog_model(available_models, &resolved_id, requested_id);
256 let runtime_display_id = if resolved_id.is_empty() {
257 requested_id.unwrap_or(OPUS_MODEL_ALIAS)
258 } else {
259 resolved_id.as_str()
260 };
261
262 let catalog_name = catalog.map(|m| m.display_name.clone()).filter(|s| !s.is_empty());
269 let catalog_has_version =
270 catalog_name.as_deref().is_some_and(|s| s.chars().any(|c| c.is_ascii_digit()));
271 let display_name = if catalog_has_version {
272 catalog_name.unwrap_or_else(|| humanize_model_id(runtime_display_id))
273 } else {
274 let humanized = humanize_model_id(runtime_display_id);
275 let humanized_has_version = humanized.chars().any(|c| c.is_ascii_digit());
276 if humanized_has_version { humanized } else { catalog_name.unwrap_or(humanized) }
277 };
278
279 let display_name = requested_id.unwrap_or(display_name.as_str()).to_owned();
283
284 CurrentModel {
285 requested_id: requested_id.map(str::to_owned),
286 resolved_id: resolved_id.clone(),
287 display_name_short: display_name.clone(),
288 display_name_long: display_name,
289 catalog_id: catalog.map(|m| m.id.clone()),
290 supports_effort: catalog.is_some_and(|m| m.supports_effort),
291 supported_effort_levels: catalog
292 .map_or_else(Vec::new, |m| m.supported_effort_levels.clone()),
293 supports_auto_mode: catalog.and_then(|m| m.supports_auto_mode),
294 supports_adaptive_thinking: catalog.and_then(|m| m.supports_adaptive_thinking),
295 is_authoritative: current_model_is_authoritative(&resolved_id, requested_id),
296 }
297}
298
299pub fn map_available_models(models: Option<&Value>) -> Vec<AvailableModel> {
303 let Some(arr) = models.and_then(Value::as_array) else {
304 return Vec::new();
305 };
306 arr.iter()
307 .filter_map(|entry| {
308 let r = entry.as_object()?;
309 let id = r.get("value").and_then(Value::as_str)?.trim().to_owned();
310 if id.is_empty() {
311 return None;
312 }
313 let display_name = r.get("displayName").and_then(Value::as_str)?.trim().to_owned();
314 if display_name.is_empty() {
315 return None;
316 }
317 let supported_effort_levels: Vec<EffortLevel> = r
318 .get("supportedEffortLevels")
319 .and_then(Value::as_array)
320 .map(|levels| {
321 levels
322 .iter()
323 .filter_map(|l| match l.as_str()? {
324 "low" => Some(EffortLevel::Low),
325 "medium" => Some(EffortLevel::Medium),
326 "high" => Some(EffortLevel::High),
327 "xhigh" => Some(EffortLevel::Xhigh),
328 "max" => Some(EffortLevel::Max),
329 _ => None,
330 })
331 .collect()
332 })
333 .unwrap_or_default();
334 Some(AvailableModel {
335 id,
336 display_name,
337 description: r.get("description").and_then(Value::as_str).map(str::to_owned),
338 supports_effort: r.get("supportsEffort").and_then(Value::as_bool).unwrap_or(false),
339 supported_effort_levels,
340 supports_adaptive_thinking: r
341 .get("supportsAdaptiveThinking")
342 .and_then(Value::as_bool),
343 supports_auto_mode: r.get("supportsAutoMode").and_then(Value::as_bool),
344 })
345 })
346 .collect()
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[test]
354 fn humanize_known_families() {
355 assert_eq!(humanize_model_id("claude-sonnet-4-6"), "Sonnet 4.6");
356 assert_eq!(humanize_model_id("claude-opus-4-7"), "Opus 4.7");
357 assert_eq!(humanize_model_id("claude-haiku-4-5"), "Haiku 4.5");
358 }
359
360 #[test]
361 fn humanize_fable_family() {
362 assert_eq!(humanize_model_id("claude-fable-5"), "Fable 5");
363 assert_eq!(humanize_model_id("claude-fable-5[1m]"), "Fable 5 [1M]");
364 }
365
366 #[test]
367 fn humanize_with_context_suffix() {
368 assert_eq!(humanize_model_id("claude-sonnet-4-6[1m]"), "Sonnet 4.6 [1M]");
369 assert_eq!(humanize_model_id("claude-sonnet-4-6[8k]"), "Sonnet 4.6 [8k]");
370 }
371
372 #[test]
373 fn humanize_unknown_falls_through() {
374 assert_eq!(humanize_model_id("custom-model-1"), "custom-model-1");
375 assert_eq!(humanize_model_id(""), "");
376 }
377
378 #[test]
379 fn humanize_strips_release_build_tokens() {
380 assert_eq!(humanize_model_id("claude-sonnet-4-6-20260101"), "Sonnet 4.6");
382 }
383
384 #[test]
385 fn map_models_filters_empty_and_invalid() {
386 let v = serde_json::json!([
387 { "value": "a", "displayName": "A", "supportsEffort": true,
388 "supportedEffortLevels": ["low", "medium", "wrong"] },
389 { "value": " ", "displayName": "skip" },
390 { "value": "b", "displayName": " " },
391 { "value": "c", "displayName": "C" },
392 ]);
393 let m = map_available_models(Some(&v));
394 assert_eq!(m.len(), 2);
395 assert_eq!(m[0].id, "a");
396 assert_eq!(m[0].supported_effort_levels.len(), 2);
397 assert_eq!(m[1].id, "c");
398 }
399
400 #[test]
401 fn resolve_current_model_falls_back_to_opus() {
402 let cm = resolve_current_model_from_inputs("", None, None, &[]);
403 assert_eq!(cm.resolved_id, "opus");
404 assert!(cm.is_authoritative);
407
408 let cm = resolve_current_model_from_inputs("claude-sonnet-4-6", None, None, &[]);
409 assert_eq!(cm.resolved_id, "claude-sonnet-4-6");
410 assert_eq!(cm.display_name_short, "Sonnet 4.6");
411 assert!(cm.is_authoritative);
412 }
413
414 #[test]
420 fn the_panel_shows_the_canonical_model_name_verbatim() {
421 let cm =
422 resolve_current_model_from_inputs("claude-opus-5", Some("claude-opus-5"), None, &[]);
423 assert_eq!(
424 cm.display_name_long, "claude-opus-5",
425 "the panel shows the canonical name, not the humanised label"
426 );
427 }
428
429 #[test]
433 fn a_resolved_model_the_session_never_asked_for_does_not_name_the_row() {
434 let cm = resolve_current_model_from_inputs(
435 "claude-opus-5[1m]",
436 Some("deepseek-v4.1-flash"),
437 None,
438 &[],
439 );
440 assert_eq!(
441 cm.display_name_long, "deepseek-v4.1-flash",
442 "the row names the model the session runs on, not the CLI's stray resolution",
443 );
444 }
445
446 #[test]
450 fn a_catalogue_display_name_does_not_rename_the_canonical_model() {
451 let catalog = vec![AvailableModel {
452 id: "deepseek-v4.1-flash[1m]".to_owned(),
453 display_name: "DeepSeek Flash".to_owned(),
454 description: None,
455 supports_effort: false,
456 supported_effort_levels: vec![],
457 supports_adaptive_thinking: None,
458 supports_auto_mode: None,
459 }];
460 let cm = resolve_current_model_from_inputs(
461 "deepseek-v4.1-flash[1m]",
462 Some("deepseek-v4.1-flash"),
463 None,
464 &catalog,
465 );
466 assert_eq!(
467 cm.display_name_long, "deepseek-v4.1-flash",
468 "the canonical name is not renamed by the catalogue or the context marker",
469 );
470 }
471
472 #[test]
473 fn catalog_short_name_augmented_with_version_from_resolved_id() {
474 let catalog = vec![AvailableModel {
479 id: "claude-opus-4-7-20251022".to_owned(),
480 display_name: "Opus".to_owned(),
481 description: None,
482 supports_effort: false,
483 supported_effort_levels: vec![],
484 supports_adaptive_thinking: None,
485 supports_auto_mode: None,
486 }];
487 let cm =
488 resolve_current_model_from_inputs("claude-opus-4-7-20251022", None, None, &catalog);
489 assert_eq!(cm.display_name_short, "Opus 4.7");
490 assert_eq!(cm.display_name_long, "Opus 4.7");
491 }
492
493 #[test]
494 fn catalog_versioned_name_kept_as_is() {
495 let catalog = vec![AvailableModel {
498 id: "claude-opus-4-7-20251022".to_owned(),
499 display_name: "Claude Opus 4.7".to_owned(),
500 description: None,
501 supports_effort: false,
502 supported_effort_levels: vec![],
503 supports_adaptive_thinking: None,
504 supports_auto_mode: None,
505 }];
506 let cm =
507 resolve_current_model_from_inputs("claude-opus-4-7-20251022", None, None, &catalog);
508 assert_eq!(cm.display_name_short, "Claude Opus 4.7");
509 }
510
511 #[test]
512 fn catalog_match_populates_supports_effort() {
513 let catalog = vec![AvailableModel {
514 id: "claude-sonnet-4-6".to_owned(),
515 display_name: "Sonnet 4.6".to_owned(),
516 description: None,
517 supports_effort: true,
518 supported_effort_levels: vec![EffortLevel::Medium, EffortLevel::High],
519 supports_adaptive_thinking: Some(true),
520 supports_auto_mode: Some(true),
521 }];
522 let cm = resolve_current_model_from_inputs("claude-sonnet-4-6", None, None, &catalog);
523 assert!(cm.supports_effort);
524 assert_eq!(cm.supported_effort_levels.len(), 2);
525 assert_eq!(cm.catalog_id.as_deref(), Some("claude-sonnet-4-6"));
526 assert_eq!(cm.supports_auto_mode, Some(true));
527 }
528}