Skip to main content

handshake_core/harness/
models.rs

1//! The models a harness offers, read from the harness's own CLI.
2
3use super::launch::{validate_effort, validate_model, validate_variant};
4use super::{HarnessError, HarnessId};
5use serde::{Deserialize, Serialize};
6use thiserror::Error;
7
8mod discovery;
9pub use discovery::ModelListRequest;
10pub(crate) use discovery::ModelListWorkers;
11
12#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
13#[serde(rename_all = "camelCase")]
14pub struct HarnessModel {
15    /// What the harness's `--model` flag takes.
16    pub id: String,
17    pub name: String,
18    /// A heading for long lists, such as pi's provider.
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub group: Option<String>,
21    /// The effort levels this model supports, when they differ by model, as Codex's do.
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub efforts: Option<Vec<String>>,
24}
25
26#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
27#[serde(rename_all = "camelCase")]
28pub struct HarnessModels {
29    pub models: Vec<HarnessModel>,
30    /// Whether the harness also takes model names it doesn't list.
31    pub allows_custom: bool,
32    /// The effort levels the harness takes, weakest first. A model's own levels take precedence.
33    pub efforts: Vec<String>,
34    /// Whether the harness has a flag that skips its permission prompts.
35    pub supports_yolo: bool,
36}
37
38#[derive(Debug, Error)]
39pub enum ModelListError {
40    #[error(transparent)]
41    NotFound(#[from] HarnessError),
42    #[error("{0} couldn't list its models.")]
43    Failed(&'static str),
44    #[error("Listing models stopped before it finished.")]
45    Cancelled,
46    #[error("{0} couldn't be started to list its models.")]
47    Start(&'static str),
48    #[error("{0} took too long to list its models.")]
49    Timeout(&'static str),
50    #[error("{0} listed its models in a form Handshake doesn't recognize.")]
51    Unreadable(&'static str),
52}
53
54impl HarnessId {
55    pub(crate) fn parse_models(self, output: &str) -> Result<HarnessModels, ModelListError> {
56        let name = self.definition().name;
57        match self {
58            Self::Codex => {
59                let models = parse_codex(output).ok_or(ModelListError::Unreadable(name))?;
60                // The default model's levels aren't listed, so offer every level some model takes.
61                let mut efforts: Vec<String> = Vec::new();
62                for effort in models
63                    .iter()
64                    .flat_map(|model| model.efforts.iter().flatten())
65                {
66                    if !efforts.contains(effort) {
67                        efforts.push(effort.clone());
68                    }
69                }
70                Ok(HarnessModels {
71                    models,
72                    allows_custom: false,
73                    efforts,
74                    supports_yolo: true,
75                })
76            }
77            // pi's list only covers models; its help names the thinking levels.
78            // pi runs tools without asking, so it has no permission prompts to skip.
79            Self::Pi => Ok(HarnessModels {
80                models: parse_pi(output),
81                allows_custom: false,
82                efforts: Vec::new(),
83                supports_yolo: false,
84            }),
85            // Claude Code names only its aliases, and takes any full model name besides.
86            Self::ClaudeCode => Ok(HarnessModels {
87                models: parse_claude_help(output),
88                allows_custom: true,
89                efforts: help_levels(output, "--effort <level>"),
90                supports_yolo: true,
91            }),
92            Self::Antigravity => Ok(HarnessModels {
93                models: parse_antigravity(output).ok_or(ModelListError::Unreadable(name))?,
94                allows_custom: false,
95                efforts: Vec::new(),
96                supports_yolo: true,
97            }),
98            Self::Omp => Ok(HarnessModels {
99                models: parse_omp(output).ok_or(ModelListError::Unreadable(name))?,
100                allows_custom: true,
101                efforts: Vec::new(),
102                supports_yolo: true,
103            }),
104            Self::Kiro => parse_kiro(output).ok_or(ModelListError::Unreadable(name)),
105            Self::Opencode => Ok(HarnessModels {
106                models: parse_opencode(output).ok_or(ModelListError::Unreadable(name))?,
107                allows_custom: true,
108                // Variants require an explicit model, so the default offers no effort choices.
109                efforts: Vec::new(),
110                // OpenCode v2's interactive mini command doesn't accept --auto.
111                supports_yolo: false,
112            }),
113        }
114    }
115}
116
117fn parse_kiro(output: &str) -> Option<HarnessModels> {
118    let catalog: serde_json::Value = serde_json::from_str(output).ok()?;
119    let models = catalog["models"]
120        .as_array()?
121        .iter()
122        .map(|model| {
123            Some(HarnessModel {
124                id: validate_model(model["model_id"].as_str()?)?.to_owned(),
125                name: model["model_name"].as_str()?.to_owned(),
126                group: None,
127                efforts: None,
128            })
129        })
130        .collect::<Option<Vec<_>>>()?;
131    Some(HarnessModels {
132        models,
133        allows_custom: false,
134        efforts: Vec::new(),
135        supports_yolo: true,
136    })
137}
138
139/// `OpenCode` v2's CLI API returns the current catalog and each model's named variants.
140fn parse_opencode(output: &str) -> Option<Vec<HarnessModel>> {
141    #[derive(Deserialize)]
142    struct Catalog {
143        data: Vec<Entry>,
144    }
145    #[derive(Deserialize)]
146    struct Entry {
147        #[serde(rename = "providerID")]
148        provider: String,
149        id: String,
150        name: String,
151        #[serde(default)]
152        variants: Vec<Variant>,
153    }
154    #[derive(Deserialize)]
155    struct Variant {
156        id: String,
157    }
158    serde_json::from_str::<Catalog>(output)
159        .ok()?
160        .data
161        .into_iter()
162        .map(|entry| {
163            if entry.provider.is_empty() || entry.id.is_empty() {
164                return None;
165            }
166            let selector = format!("{}/{}", entry.provider, entry.id);
167            Some(HarnessModel {
168                id: validate_model(&selector)?.to_owned(),
169                name: entry.name,
170                group: Some(entry.provider),
171                efforts: Some(
172                    entry
173                        .variants
174                        .into_iter()
175                        .filter_map(|variant| validate_variant(&variant.id).map(str::to_owned))
176                        .collect(),
177                ),
178            })
179        })
180        .collect()
181}
182
183/// OMP's JSON catalog provides exact selectors and each model's supported thinking levels.
184fn parse_omp(output: &str) -> Option<Vec<HarnessModel>> {
185    #[derive(Deserialize)]
186    struct Catalog {
187        models: Vec<Entry>,
188    }
189    #[derive(Deserialize)]
190    struct Entry {
191        selector: String,
192        provider: String,
193        name: String,
194        #[serde(default)]
195        thinking: Option<Vec<String>>,
196    }
197    serde_json::from_str::<Catalog>(output)
198        .ok()?
199        .models
200        .into_iter()
201        .map(|entry| {
202            Some(HarnessModel {
203                id: validate_model(&entry.selector)?.to_owned(),
204                name: entry.name,
205                group: Some(entry.provider),
206                // A null list means the model doesn't reason; don't inherit global levels.
207                efforts: Some(
208                    entry
209                        .thinking
210                        .unwrap_or_default()
211                        .into_iter()
212                        .filter(|level| validate_effort(level).is_some())
213                        .collect(),
214                ),
215            })
216        })
217        .collect()
218}
219
220/// Antigravity's tab-separated model IDs and display names, in its picker order.
221fn parse_antigravity(output: &str) -> Option<Vec<HarnessModel>> {
222    let models: Vec<HarnessModel> = output
223        .lines()
224        .filter_map(|line| line.split_once('\t'))
225        .map(|(id, name)| {
226            let name = name.trim();
227            if name.is_empty() {
228                return None;
229            }
230            Some(HarnessModel {
231                id: validate_model(id)?.to_owned(),
232                name: name.to_owned(),
233                group: None,
234                efforts: None,
235            })
236        })
237        .collect::<Option<_>>()?;
238    (!models.is_empty()).then_some(models)
239}
240
241/// Codex's catalog, as its model picker shows it: listed models in priority order.
242fn parse_codex(output: &str) -> Option<Vec<HarnessModel>> {
243    #[derive(Deserialize)]
244    struct Catalog {
245        models: Vec<Entry>,
246    }
247    #[derive(Deserialize)]
248    struct Entry {
249        slug: String,
250        display_name: Option<String>,
251        visibility: Option<String>,
252        #[serde(default)]
253        priority: i64,
254        #[serde(default)]
255        supported_reasoning_levels: Vec<Level>,
256    }
257    #[derive(Deserialize)]
258    struct Level {
259        effort: String,
260    }
261    let mut entries: Vec<Entry> = serde_json::from_str::<Catalog>(output)
262        .ok()?
263        .models
264        .into_iter()
265        .filter(|entry| entry.visibility.as_deref() == Some("list"))
266        .collect();
267    entries.sort_by_key(|entry| entry.priority);
268    Some(
269        entries
270            .into_iter()
271            .map(|entry| HarnessModel {
272                name: entry.display_name.unwrap_or_else(|| entry.slug.clone()),
273                id: entry.slug,
274                group: None,
275                efforts: Some(
276                    entry
277                        .supported_reasoning_levels
278                        .into_iter()
279                        .map(|level| level.effort)
280                        .filter(|effort| validate_effort(effort).is_some())
281                        .collect(),
282                ),
283            })
284            .collect(),
285    )
286}
287
288/// pi's table of `provider  model  …` rows, one model per signed-in provider's entry.
289fn parse_pi(output: &str) -> Vec<HarnessModel> {
290    output
291        .lines()
292        .skip_while(|line| !line.trim_start().starts_with("provider"))
293        .skip(1)
294        .filter_map(|line| {
295            let mut columns = line.split_whitespace();
296            let (provider, model) = (columns.next()?, columns.next()?);
297            Some(HarnessModel {
298                id: format!("{provider}/{model}"),
299                name: model.to_owned(),
300                group: Some(provider.to_owned()),
301                efforts: None,
302            })
303        })
304        .collect()
305}
306
307/// The quoted aliases in the description of Claude Code's `--model` option.
308fn parse_claude_help(output: &str) -> Vec<HarnessModel> {
309    let mut lines = output
310        .lines()
311        .skip_while(|line| !line.trim_start().starts_with("--model "));
312    let Some(first) = lines.next() else {
313        return Vec::new();
314    };
315    // The description continues on indented lines until the next option.
316    let description: String = std::iter::once(first)
317        .chain(lines.take_while(|line| !line.trim_start().starts_with('-')))
318        .collect::<Vec<_>>()
319        .join(" ");
320    description
321        .split('\'')
322        .skip(1)
323        .step_by(2)
324        .filter(|alias| {
325            !alias.is_empty() && alias.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
326        })
327        .map(|alias| {
328            let mut name = alias.to_owned();
329            if let Some(first) = name.get_mut(0..1) {
330                first.make_ascii_uppercase();
331            }
332            HarnessModel {
333                id: alias.to_owned(),
334                name,
335                group: None,
336                efforts: None,
337            }
338        })
339        .collect()
340}
341
342/// The levels an option's help lists, as in `--effort <level>  … (low, medium, high)` or
343/// `--thinking <level>  Set thinking level: off, low, high`.
344fn help_levels(help: &str, option: &str) -> Vec<String> {
345    let mut lines = help
346        .lines()
347        .skip_while(|line| !line.trim_start().starts_with(option));
348    let Some(first) = lines.next() else {
349        return Vec::new();
350    };
351    let description: String =
352        std::iter::once(&first[first.find(option).unwrap_or(0) + option.len()..])
353            .chain(lines.take_while(|line| !line.trim_start().starts_with('-')))
354            .collect::<Vec<_>>()
355            .join(" ");
356    let list = match (description.find('('), description.find(':')) {
357        (Some(open), _) => description[open + 1..]
358            .split(')')
359            .next()
360            .unwrap_or_default(),
361        (None, Some(colon)) => &description[colon + 1..],
362        (None, None) => return Vec::new(),
363    };
364    list.split([',', '|'])
365        .filter_map(|level| validate_effort(level).map(str::to_owned))
366        .collect()
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[test]
374    fn kiro_catalog_uses_native_selectors_without_guessing_effort_levels() {
375        let catalog =
376            parse_kiro(r#"{"models":[{"model_name":"Model","model_id":"provider/model"}]}"#)
377                .unwrap();
378        assert_eq!(catalog.models[0].id, "provider/model");
379        assert!(catalog.supports_yolo && catalog.efforts.is_empty());
380        assert!(
381            parse_kiro(r#"{"models":[{"model_name":"Model","model_id":"--unsafe"}]}"#).is_none()
382        );
383    }
384
385    #[test]
386    fn codex_lists_its_picker_models_in_priority_order() {
387        let output = r#"{"models":[
388            {"slug":"b","display_name":"Beta","visibility":"list","priority":2,
389             "supported_reasoning_levels":[{"effort":"low"},{"effort":"ultra"}]},
390            {"slug":"hidden","display_name":"Hidden","visibility":"hide","priority":0},
391            {"slug":"a","display_name":"Alpha","visibility":"list","priority":1,"extra":{"x":1},
392             "supported_reasoning_levels":[{"effort":"low"},{"effort":"high"}]},
393            {"slug":"plain","visibility":"list","priority":3}
394        ]}"#;
395        let models = HarnessId::Codex.parse_models(output).unwrap();
396        assert!(!models.allows_custom);
397        assert_eq!(
398            models
399                .models
400                .iter()
401                .map(|m| (m.id.as_str(), m.name.as_str()))
402                .collect::<Vec<_>>(),
403            [("a", "Alpha"), ("b", "Beta"), ("plain", "plain")]
404        );
405        assert_eq!(
406            models.models[1].efforts.as_deref(),
407            Some(&["low".to_owned(), "ultra".to_owned()][..])
408        );
409        assert_eq!(models.efforts, ["low", "high", "ultra"]);
410        assert!(HarnessId::Codex.parse_models("not json").is_err());
411    }
412
413    #[test]
414    fn pi_lists_provider_and_model_rows_as_provider_ids() {
415        let output = "provider    model                         context  max-out  thinking  images\n\
416                      lmstudio    qwen/qwen3.8-27b              169.7K   32.8K    yes       yes\n\
417                      openrouter  ~anthropic/claude-opus-latest 1M       128K     yes       yes\n\n";
418        let models = HarnessId::Pi.parse_models(output).unwrap().models;
419        assert_eq!(models.len(), 2);
420        assert_eq!(models[0].id, "lmstudio/qwen/qwen3.8-27b");
421        assert_eq!(models[0].name, "qwen/qwen3.8-27b");
422        assert_eq!(models[1].group.as_deref(), Some("openrouter"));
423        assert_eq!(
424            HarnessId::Pi
425                .parse_models("No models available.\n")
426                .unwrap()
427                .models,
428            []
429        );
430    }
431
432    #[test]
433    fn omp_lists_exact_selectors_and_model_specific_thinking_levels() {
434        let output = r#"{"models":[
435            {"selector":"local/plain","provider":"local","name":"Plain model","thinking":null},
436            {"selector":"local/path/model","provider":"local","name":"Reasoning model","thinking":["low","high","BAD"]}
437        ]}"#;
438        let models = HarnessId::Omp.parse_models(output).unwrap();
439        assert_eq!(models.models.len(), 2);
440        assert_eq!(models.models[0].name, "Plain model");
441        assert_eq!(models.models[0].efforts, Some(vec![]));
442        assert_eq!(models.models[1].id, "local/path/model");
443        assert_eq!(models.models[1].group.as_deref(), Some("local"));
444        assert_eq!(
445            models.models[1].efforts,
446            Some(vec!["low".into(), "high".into()])
447        );
448        assert!(models.allows_custom && models.supports_yolo);
449        assert_eq!(
450            HarnessId::Omp
451                .parse_models(r#"{"models":[]}"#)
452                .unwrap()
453                .models,
454            []
455        );
456        for unreadable in [
457            "not json",
458            "{}",
459            r#"{"models":[{"selector":"--flag","provider":"local","name":"Bad"}]}"#,
460        ] {
461            assert!(HarnessId::Omp.parse_models(unreadable).is_err());
462        }
463    }
464
465    #[test]
466    fn opencode_lists_provider_selectors_and_model_specific_variants() {
467        let output = r#"{"location":{"directory":"/folder"},"data":[
468            {"providerID":"opencode","id":"plain","name":"Plain","variants":[]},
469            {"providerID":"local","id":"path/model","name":"Reasoning",
470             "variants":[{"id":"low"},{"id":"high"},{"id":"custom-name"},{"id":"Custom_Name"},{"id":"bad#variant"}]}
471        ]}"#;
472        let models = HarnessId::Opencode.parse_models(output).unwrap();
473        assert_eq!(models.models[0].id, "opencode/plain");
474        assert_eq!(models.models[0].efforts, Some(vec![]));
475        assert_eq!(models.models[1].id, "local/path/model");
476        assert_eq!(models.models[1].name, "Reasoning");
477        assert_eq!(models.models[1].group.as_deref(), Some("local"));
478        assert_eq!(
479            models.models[1].efforts,
480            Some(vec![
481                "low".into(),
482                "high".into(),
483                "custom-name".into(),
484                "Custom_Name".into()
485            ])
486        );
487        assert_eq!(models.efforts, Vec::<String>::new());
488        assert!(models.allows_custom && !models.supports_yolo);
489        assert_eq!(
490            HarnessId::Opencode
491                .parse_models(r#"{"data":[]}"#)
492                .unwrap()
493                .models,
494            []
495        );
496        for unreadable in [
497            "",
498            "not json",
499            "{}",
500            r#"{"data":[{"providerID":"","id":"model","name":"Bad"}]}"#,
501        ] {
502            assert!(HarnessId::Opencode.parse_models(unreadable).is_err());
503        }
504    }
505
506    #[test]
507    fn antigravity_lists_model_ids_and_names_without_losing_spaces() {
508        let output = "Fetching available models...\n\
509                      gemini-3.8-flash-high\tGemini 3.8 Flash (High)\n\
510                      claude-sonnet-4-6\tClaude Sonnet 4.6 (Thinking)\n";
511        let models = HarnessId::Antigravity.parse_models(output).unwrap();
512        assert_eq!(models.models.len(), 2);
513        assert_eq!(models.models[0].id, "gemini-3.8-flash-high");
514        assert_eq!(models.models[0].name, "Gemini 3.8 Flash (High)");
515        assert_eq!(models.models[1].name, "Claude Sonnet 4.6 (Thinking)");
516        assert!(!models.allows_custom);
517        assert!(models.supports_yolo);
518        for unreadable in ["not a model list", "", "--flag\tBad model", "model\t"] {
519            assert!(HarnessId::Antigravity.parse_models(unreadable).is_err());
520        }
521    }
522
523    #[test]
524    fn claude_code_offers_the_aliases_its_help_names_and_custom_names() {
525        let output = "  --fallback-model <model>   Enable fallback\n\
526                      \x20 --model <model>            Model for the current session. Provide\n\
527                      \x20                            an alias for the latest model (e.g.\n\
528                      \x20                            'fable', 'opus', or 'sonnet') or a\n\
529                      \x20                            model's full name.\n\
530                      \x20 -n, --name <name>          Set a 'display name'\n\
531                      \x20 --effort <level>           Effort level for the current session\n\
532                      \x20                            (low, medium, high, xhigh, max)\n\
533                      \x20 --environment <id>         Create a session\n";
534        let models = HarnessId::ClaudeCode.parse_models(output).unwrap();
535        assert!(models.allows_custom);
536        assert_eq!(
537            models
538                .models
539                .iter()
540                .map(|m| (m.id.as_str(), m.name.as_str()))
541                .collect::<Vec<_>>(),
542            [("fable", "Fable"), ("opus", "Opus"), ("sonnet", "Sonnet")]
543        );
544        assert_eq!(models.efforts, ["low", "medium", "high", "xhigh", "max"]);
545        let changed = HarnessId::ClaudeCode
546            .parse_models("Usage: claude\n")
547            .unwrap();
548        assert!(changed.models.is_empty() && changed.allows_custom);
549    }
550}