gestura_core_foundation/
model_display.rs

1//! Model name display formatting utilities.
2//!
3//! Provides human-friendly model name formatting for display in CLI/GUI.
4
5/// Format an OpenAI model ID to a human-readable name.
6///
7/// # Examples
8/// - `gpt-4o` → `GPT-4o`
9/// - `gpt-4o-mini` → `GPT-4o Mini`
10/// - `gpt-3.5-turbo` → `GPT-3.5 Turbo`
11pub fn format_openai_model_name(id: &str) -> String {
12    match id {
13        "gpt-4o" => "GPT-4o".to_string(),
14        "gpt-4o-mini" => "GPT-4o Mini".to_string(),
15        "gpt-4-turbo" => "GPT-4 Turbo".to_string(),
16        "gpt-4-turbo-preview" => "GPT-4 Turbo Preview".to_string(),
17        "gpt-4" => "GPT-4".to_string(),
18        "gpt-3.5-turbo" => "GPT-3.5 Turbo".to_string(),
19        "o1-preview" => "o1 Preview".to_string(),
20        "o1-mini" => "o1 Mini".to_string(),
21        "o3-mini" => "o3 Mini".to_string(),
22        _ => title_case_kebab(id),
23    }
24}
25
26/// Format an Anthropic model ID to a human-readable name.
27///
28/// Preserves the full version identifier (including date suffixes) so that
29/// users can distinguish between model snapshots.
30///
31/// # Examples
32/// - `claude-sonnet-4-20250514` → `Claude Sonnet 4 (20250514)`
33/// - `claude-opus-4-20250514` → `Claude Opus 4 (20250514)`
34/// - `claude-3-7-sonnet-20250219` → `Claude 3.7 Sonnet (20250219)`
35/// - `claude-3-5-sonnet-20241022` → `Claude 3.5 Sonnet (20241022)`
36/// - `claude-3-5-sonnet-latest` → `Claude 3.5 Sonnet (latest)`
37/// - `claude-3-opus-20240229` → `Claude 3 Opus (20240229)`
38pub fn format_anthropic_model_name(id: &str) -> String {
39    let parts: Vec<&str> = id.split('-').collect();
40
41    // Must start with "claude" and have at least 3 parts.
42    if parts.is_empty() || parts[0] != "claude" || parts.len() < 3 {
43        return title_case_kebab(id);
44    }
45
46    // Detect the naming convention by checking whether parts[1] is numeric.
47    //
48    // Old format (parts[1] is numeric):
49    //   claude-{major}[-{minor}]-{variant}-{date|"latest"}
50    //   e.g. claude-3-opus-20240229, claude-3-5-sonnet-20241022, claude-3-7-sonnet-20250219
51    //
52    // New format (parts[1] is a word):
53    //   claude-{variant}-{major}-{date}
54    //   e.g. claude-sonnet-4-20250514, claude-opus-4-20250514
55
56    let first_is_numeric = parts[1].chars().all(|c| c.is_ascii_digit());
57
58    if first_is_numeric {
59        // Old format: claude-{major}[-{minor}]-{variant}-{suffix}
60        // Gather consecutive numeric parts as the version (e.g., "3", "5" → "3.5").
61        let mut version_parts: Vec<&str> = Vec::new();
62        let mut idx = 1;
63        while idx < parts.len() && parts[idx].chars().all(|c| c.is_ascii_digit()) {
64            version_parts.push(parts[idx]);
65            idx += 1;
66        }
67        let version = version_parts.join(".");
68
69        // Next part is the variant name (e.g., "sonnet", "opus", "haiku").
70        let variant = if idx < parts.len() {
71            capitalize_first(parts[idx])
72        } else {
73            String::new()
74        };
75        idx += 1;
76
77        // Remaining parts form the suffix (date or "latest").
78        let suffix = if idx < parts.len() {
79            parts[idx..].join("-")
80        } else {
81            String::new()
82        };
83
84        if suffix.is_empty() {
85            format!("Claude {} {}", version, variant).trim().to_string()
86        } else {
87            format!("Claude {} {} ({})", version, variant, suffix)
88                .trim()
89                .to_string()
90        }
91    } else {
92        // New format: claude-{variant}-{major}-{suffix}
93        let variant = capitalize_first(parts[1]);
94        let major = if parts.len() > 2 { parts[2] } else { "" };
95
96        // Remaining parts form the suffix.
97        let suffix = if parts.len() > 3 {
98            parts[3..].join("-")
99        } else {
100            String::new()
101        };
102
103        if suffix.is_empty() {
104            format!("Claude {} {}", variant, major).trim().to_string()
105        } else {
106            format!("Claude {} {} ({})", variant, major, suffix)
107                .trim()
108                .to_string()
109        }
110    }
111}
112
113/// Format a Grok model ID to human-readable name.
114///
115/// Preserves numeric version suffixes (e.g., release dates) in parentheses so
116/// that distinct model snapshots remain distinguishable.
117///
118/// # Examples
119/// - `grok-4-0709` → `Grok 4 (0709)`
120/// - `grok-3` → `Grok 3`
121/// - `grok-3-mini` → `Grok 3 Mini`
122/// - `grok-3-mini-fast` → `Grok 3 Mini Fast`
123/// - `grok-2-1212` → `Grok 2 (1212)`
124/// - `grok-2-vision-1212` → `Grok 2 Vision (1212)`
125pub fn format_grok_model_name(id: &str) -> String {
126    let parts: Vec<&str> = id.split('-').collect();
127    let mut name = String::new();
128
129    for (i, part) in parts.iter().enumerate() {
130        if i == 0 {
131            name.push_str("Grok");
132        } else if part.chars().all(|c| c.is_ascii_digit()) {
133            if i == 1 {
134                // Primary version number (e.g., the "3" in grok-3)
135                name.push_str(&format!(" {}", part));
136            } else {
137                // Secondary numeric part — release/version suffix (e.g., "1212", "0709")
138                name.push_str(&format!(" ({})", part));
139            }
140        } else {
141            let formatted = match *part {
142                "mini" => "Mini",
143                "fast" => "Fast",
144                "vision" => "Vision",
145                "code" => "Code",
146                "beta" => "Beta",
147                _ => part,
148            };
149            name.push_str(&format!(" {}", formatted));
150        }
151    }
152
153    name.trim().to_string()
154}
155
156/// Format a Gemini model ID to a human-readable name.
157///
158/// # Examples
159/// - `gemini-2.0-flash` → `Gemini 2.0 Flash`
160/// - `gemini-2.0-flash-lite` → `Gemini 2.0 Flash Lite`
161/// - `gemini-1.5-pro` → `Gemini 1.5 Pro`
162pub fn format_gemini_model_name(id: &str) -> String {
163    let parts: Vec<&str> = id.split('-').collect();
164    let mut name = String::new();
165
166    for (i, part) in parts.iter().enumerate() {
167        if i == 0 && part.eq_ignore_ascii_case("gemini") {
168            name.push_str("Gemini");
169        } else {
170            let formatted = match *part {
171                "pro" => "Pro",
172                "flash" => "Flash",
173                "lite" => "Lite",
174                "ultra" => "Ultra",
175                "nano" => "Nano",
176                "exp" => "Experimental",
177                "latest" => "(Latest)",
178                other => {
179                    // Numeric version segments (e.g. "2.0", "1.5") are kept as-is.
180                    name.push(' ');
181                    name.push_str(other);
182                    continue;
183                }
184            };
185            name.push(' ');
186            name.push_str(formatted);
187        }
188    }
189
190    name.trim().to_string()
191}
192
193/// Format any model name based on provider.
194///
195/// # Arguments
196/// - `provider`: LLM provider name (openai, anthropic, grok, gemini, ollama)
197/// - `model_id`: Raw model identifier
198pub fn format_model_name(provider: &str, model_id: &str) -> String {
199    match provider.to_lowercase().as_str() {
200        "openai" => format_openai_model_name(model_id),
201        "anthropic" => format_anthropic_model_name(model_id),
202        "grok" => format_grok_model_name(model_id),
203        "gemini" => format_gemini_model_name(model_id),
204        "ollama" => capitalize_first(model_id),
205        _ => model_id.to_string(),
206    }
207}
208
209/// Check if a provider is local (no cost tracking).
210pub fn is_local_provider(provider: &str) -> bool {
211    matches!(provider.to_lowercase().as_str(), "ollama" | "local")
212}
213
214/// Convert kebab-case to Title Case.
215fn title_case_kebab(s: &str) -> String {
216    s.split('-')
217        .map(capitalize_first)
218        .collect::<Vec<_>>()
219        .join(" ")
220}
221
222/// Capitalize the first character of a string.
223fn capitalize_first(s: &str) -> String {
224    let mut chars = s.chars();
225    match chars.next() {
226        None => String::new(),
227        Some(first) => first.to_uppercase().chain(chars).collect(),
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn test_openai_format() {
237        assert_eq!(format_openai_model_name("gpt-4o"), "GPT-4o");
238        assert_eq!(format_openai_model_name("gpt-4o-mini"), "GPT-4o Mini");
239    }
240
241    #[test]
242    fn test_anthropic_format_old_single_version() {
243        // Old format: claude-{major}-{variant}-{date}
244        assert_eq!(
245            format_anthropic_model_name("claude-3-opus-20240229"),
246            "Claude 3 Opus (20240229)"
247        );
248        assert_eq!(
249            format_anthropic_model_name("claude-3-sonnet-20240229"),
250            "Claude 3 Sonnet (20240229)"
251        );
252        assert_eq!(
253            format_anthropic_model_name("claude-3-haiku-20240307"),
254            "Claude 3 Haiku (20240307)"
255        );
256    }
257
258    #[test]
259    fn test_anthropic_format_old_double_version() {
260        // Old format: claude-{major}-{minor}-{variant}-{date}
261        assert_eq!(
262            format_anthropic_model_name("claude-3-5-sonnet-20241022"),
263            "Claude 3.5 Sonnet (20241022)"
264        );
265        assert_eq!(
266            format_anthropic_model_name("claude-3-5-haiku-20241022"),
267            "Claude 3.5 Haiku (20241022)"
268        );
269        assert_eq!(
270            format_anthropic_model_name("claude-3-7-sonnet-20250219"),
271            "Claude 3.7 Sonnet (20250219)"
272        );
273    }
274
275    #[test]
276    fn test_anthropic_format_new_convention() {
277        // New format: claude-{variant}-{major}-{date}
278        assert_eq!(
279            format_anthropic_model_name("claude-sonnet-4-20250514"),
280            "Claude Sonnet 4 (20250514)"
281        );
282        assert_eq!(
283            format_anthropic_model_name("claude-opus-4-20250514"),
284            "Claude Opus 4 (20250514)"
285        );
286    }
287
288    #[test]
289    fn test_anthropic_format_latest_alias() {
290        assert_eq!(
291            format_anthropic_model_name("claude-3-5-sonnet-latest"),
292            "Claude 3.5 Sonnet (latest)"
293        );
294    }
295
296    #[test]
297    fn test_grok_format_with_version_suffix() {
298        assert_eq!(format_grok_model_name("grok-3"), "Grok 3");
299        assert_eq!(format_grok_model_name("grok-3-mini"), "Grok 3 Mini");
300        assert_eq!(
301            format_grok_model_name("grok-3-mini-fast"),
302            "Grok 3 Mini Fast"
303        );
304        assert_eq!(format_grok_model_name("grok-4-0709"), "Grok 4 (0709)");
305        assert_eq!(format_grok_model_name("grok-2-1212"), "Grok 2 (1212)");
306        assert_eq!(
307            format_grok_model_name("grok-2-vision-1212"),
308            "Grok 2 Vision (1212)"
309        );
310        assert_eq!(format_grok_model_name("grok-beta"), "Grok Beta");
311    }
312
313    #[test]
314    fn test_gemini_format() {
315        assert_eq!(
316            format_gemini_model_name("gemini-2.0-flash"),
317            "Gemini 2.0 Flash"
318        );
319        assert_eq!(
320            format_gemini_model_name("gemini-2.0-flash-lite"),
321            "Gemini 2.0 Flash Lite"
322        );
323        assert_eq!(format_gemini_model_name("gemini-1.5-pro"), "Gemini 1.5 Pro");
324        assert_eq!(
325            format_gemini_model_name("gemini-1.5-flash"),
326            "Gemini 1.5 Flash"
327        );
328    }
329
330    #[test]
331    fn test_format_model_name_gemini_dispatch() {
332        assert_eq!(
333            format_model_name("gemini", "gemini-2.0-flash"),
334            "Gemini 2.0 Flash"
335        );
336    }
337
338    #[test]
339    fn test_local_provider() {
340        assert!(is_local_provider("ollama"));
341        assert!(is_local_provider("Ollama"));
342        assert!(!is_local_provider("openai"));
343    }
344}