chant/operations/model.rs
1//! Model selection and detection logic.
2//!
3//! Handles model name resolution with the following priority:
4//! 1. CHANT_MODEL env var (explicit override)
5//! 2. ANTHROPIC_MODEL env var (Claude CLI default)
6//! 3. defaults.model in config
7//! 4. Parse from `claude --version` output (last resort)
8//!
9//! All model names are normalized to shorthand (e.g., "claude-sonnet-4-20250514" -> "sonnet").
10
11use crate::config::Config;
12use crate::spec::normalize_model_name;
13
14/// Get the model name using the following priority:
15/// 1. CHANT_MODEL env var (explicit override)
16/// 2. ANTHROPIC_MODEL env var (Claude CLI default)
17/// 3. defaults.model in config
18/// 4. Parse from `claude --version` output (last resort)
19///
20/// Model names are normalized to shorthand (e.g., "claude-sonnet-4-20250514" -> "sonnet").
21pub fn get_model_name(config: Option<&Config>) -> Option<String> {
22 get_model_name_with_default(config.and_then(|c| c.defaults.model.as_deref()))
23}
24
25/// Get the model name with an optional default from config.
26/// Used by parallel execution where full Config isn't available.
27///
28/// Model names are normalized to shorthand (e.g., "claude-sonnet-4-20250514" -> "sonnet").
29pub fn get_model_name_with_default(config_model: Option<&str>) -> Option<String> {
30 // 1. CHANT_MODEL env var
31 if let Ok(model) = std::env::var("CHANT_MODEL") {
32 if !model.is_empty() {
33 return Some(normalize_model_name(&model));
34 }
35 }
36
37 // 2. ANTHROPIC_MODEL env var
38 if let Ok(model) = std::env::var("ANTHROPIC_MODEL") {
39 if !model.is_empty() {
40 return Some(normalize_model_name(&model));
41 }
42 }
43
44 // 3. defaults.model from config
45 if let Some(model) = config_model {
46 if !model.is_empty() {
47 return Some(normalize_model_name(model));
48 }
49 }
50
51 // 4. Parse from claude --version output
52 parse_model_from_claude_version().map(|m| normalize_model_name(&m))
53}
54
55/// Parse model name from `claude --version` output.
56/// Expected format: "X.Y.Z (model-name)" or similar patterns.
57fn parse_model_from_claude_version() -> Option<String> {
58 use std::process::Command;
59
60 let output = Command::new("claude").arg("--version").output().ok()?;
61
62 if !output.status.success() {
63 return None;
64 }
65
66 let version_str = String::from_utf8_lossy(&output.stdout);
67
68 // Try to extract model from parentheses, e.g., "1.0.0 (claude-sonnet-4)"
69 if let Some(start) = version_str.find('(') {
70 if let Some(end) = version_str.find(')') {
71 if start < end {
72 let model = version_str[start + 1..end].trim();
73 // Check if it looks like a model name (contains "claude" or common model patterns)
74 if model.contains("claude")
75 || model.contains("sonnet")
76 || model.contains("opus")
77 || model.contains("haiku")
78 {
79 return Some(model.to_string());
80 }
81 }
82 }
83 }
84
85 None
86}