Skip to main content

twine_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 Twine 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::Opencode => Ok(HarnessModels {
105                models: parse_opencode(output).ok_or(ModelListError::Unreadable(name))?,
106                allows_custom: true,
107                // Variants require an explicit model, so the default offers no effort choices.
108                efforts: Vec::new(),
109                // OpenCode v2's interactive mini command doesn't accept --auto.
110                supports_yolo: false,
111            }),
112        }
113    }
114}
115
116/// `OpenCode` v2's CLI API returns the current catalog and each model's named variants.
117fn parse_opencode(output: &str) -> Option<Vec<HarnessModel>> {
118    #[derive(Deserialize)]
119    struct Catalog {
120        data: Vec<Entry>,
121    }
122    #[derive(Deserialize)]
123    struct Entry {
124        #[serde(rename = "providerID")]
125        provider: String,
126        id: String,
127        name: String,
128        #[serde(default)]
129        variants: Vec<Variant>,
130    }
131    #[derive(Deserialize)]
132    struct Variant {
133        id: String,
134    }
135    serde_json::from_str::<Catalog>(output)
136        .ok()?
137        .data
138        .into_iter()
139        .map(|entry| {
140            if entry.provider.is_empty() || entry.id.is_empty() {
141                return None;
142            }
143            let selector = format!("{}/{}", entry.provider, entry.id);
144            Some(HarnessModel {
145                id: validate_model(&selector)?.to_owned(),
146                name: entry.name,
147                group: Some(entry.provider),
148                efforts: Some(
149                    entry
150                        .variants
151                        .into_iter()
152                        .filter_map(|variant| validate_variant(&variant.id).map(str::to_owned))
153                        .collect(),
154                ),
155            })
156        })
157        .collect()
158}
159
160/// OMP's JSON catalog provides exact selectors and each model's supported thinking levels.
161fn parse_omp(output: &str) -> Option<Vec<HarnessModel>> {
162    #[derive(Deserialize)]
163    struct Catalog {
164        models: Vec<Entry>,
165    }
166    #[derive(Deserialize)]
167    struct Entry {
168        selector: String,
169        provider: String,
170        name: String,
171        #[serde(default)]
172        thinking: Option<Vec<String>>,
173    }
174    serde_json::from_str::<Catalog>(output)
175        .ok()?
176        .models
177        .into_iter()
178        .map(|entry| {
179            Some(HarnessModel {
180                id: validate_model(&entry.selector)?.to_owned(),
181                name: entry.name,
182                group: Some(entry.provider),
183                // A null list means the model doesn't reason; don't inherit global levels.
184                efforts: Some(
185                    entry
186                        .thinking
187                        .unwrap_or_default()
188                        .into_iter()
189                        .filter(|level| validate_effort(level).is_some())
190                        .collect(),
191                ),
192            })
193        })
194        .collect()
195}
196
197/// Antigravity's tab-separated model IDs and display names, in its picker order.
198fn parse_antigravity(output: &str) -> Option<Vec<HarnessModel>> {
199    let models: Vec<HarnessModel> = output
200        .lines()
201        .filter_map(|line| line.split_once('\t'))
202        .map(|(id, name)| {
203            let name = name.trim();
204            if name.is_empty() {
205                return None;
206            }
207            Some(HarnessModel {
208                id: validate_model(id)?.to_owned(),
209                name: name.to_owned(),
210                group: None,
211                efforts: None,
212            })
213        })
214        .collect::<Option<_>>()?;
215    (!models.is_empty()).then_some(models)
216}
217
218/// Codex's catalog, as its model picker shows it: listed models in priority order.
219fn parse_codex(output: &str) -> Option<Vec<HarnessModel>> {
220    #[derive(Deserialize)]
221    struct Catalog {
222        models: Vec<Entry>,
223    }
224    #[derive(Deserialize)]
225    struct Entry {
226        slug: String,
227        display_name: Option<String>,
228        visibility: Option<String>,
229        #[serde(default)]
230        priority: i64,
231        #[serde(default)]
232        supported_reasoning_levels: Vec<Level>,
233    }
234    #[derive(Deserialize)]
235    struct Level {
236        effort: String,
237    }
238    let mut entries: Vec<Entry> = serde_json::from_str::<Catalog>(output)
239        .ok()?
240        .models
241        .into_iter()
242        .filter(|entry| entry.visibility.as_deref() == Some("list"))
243        .collect();
244    entries.sort_by_key(|entry| entry.priority);
245    Some(
246        entries
247            .into_iter()
248            .map(|entry| HarnessModel {
249                name: entry.display_name.unwrap_or_else(|| entry.slug.clone()),
250                id: entry.slug,
251                group: None,
252                efforts: Some(
253                    entry
254                        .supported_reasoning_levels
255                        .into_iter()
256                        .map(|level| level.effort)
257                        .filter(|effort| validate_effort(effort).is_some())
258                        .collect(),
259                ),
260            })
261            .collect(),
262    )
263}
264
265/// pi's table of `provider  model  …` rows, one model per signed-in provider's entry.
266fn parse_pi(output: &str) -> Vec<HarnessModel> {
267    output
268        .lines()
269        .skip_while(|line| !line.trim_start().starts_with("provider"))
270        .skip(1)
271        .filter_map(|line| {
272            let mut columns = line.split_whitespace();
273            let (provider, model) = (columns.next()?, columns.next()?);
274            Some(HarnessModel {
275                id: format!("{provider}/{model}"),
276                name: model.to_owned(),
277                group: Some(provider.to_owned()),
278                efforts: None,
279            })
280        })
281        .collect()
282}
283
284/// The quoted aliases in the description of Claude Code's `--model` option.
285fn parse_claude_help(output: &str) -> Vec<HarnessModel> {
286    let mut lines = output
287        .lines()
288        .skip_while(|line| !line.trim_start().starts_with("--model "));
289    let Some(first) = lines.next() else {
290        return Vec::new();
291    };
292    // The description continues on indented lines until the next option.
293    let description: String = std::iter::once(first)
294        .chain(lines.take_while(|line| !line.trim_start().starts_with('-')))
295        .collect::<Vec<_>>()
296        .join(" ");
297    description
298        .split('\'')
299        .skip(1)
300        .step_by(2)
301        .filter(|alias| {
302            !alias.is_empty() && alias.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
303        })
304        .map(|alias| {
305            let mut name = alias.to_owned();
306            if let Some(first) = name.get_mut(0..1) {
307                first.make_ascii_uppercase();
308            }
309            HarnessModel {
310                id: alias.to_owned(),
311                name,
312                group: None,
313                efforts: None,
314            }
315        })
316        .collect()
317}
318
319/// The levels an option's help lists, as in `--effort <level>  … (low, medium, high)` or
320/// `--thinking <level>  Set thinking level: off, low, high`.
321fn help_levels(help: &str, option: &str) -> Vec<String> {
322    let mut lines = help
323        .lines()
324        .skip_while(|line| !line.trim_start().starts_with(option));
325    let Some(first) = lines.next() else {
326        return Vec::new();
327    };
328    let description: String =
329        std::iter::once(&first[first.find(option).unwrap_or(0) + option.len()..])
330            .chain(lines.take_while(|line| !line.trim_start().starts_with('-')))
331            .collect::<Vec<_>>()
332            .join(" ");
333    let list = match (description.find('('), description.find(':')) {
334        (Some(open), _) => description[open + 1..]
335            .split(')')
336            .next()
337            .unwrap_or_default(),
338        (None, Some(colon)) => &description[colon + 1..],
339        (None, None) => return Vec::new(),
340    };
341    list.split([',', '|'])
342        .filter_map(|level| validate_effort(level).map(str::to_owned))
343        .collect()
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn codex_lists_its_picker_models_in_priority_order() {
352        let output = r#"{"models":[
353            {"slug":"b","display_name":"Beta","visibility":"list","priority":2,
354             "supported_reasoning_levels":[{"effort":"low"},{"effort":"ultra"}]},
355            {"slug":"hidden","display_name":"Hidden","visibility":"hide","priority":0},
356            {"slug":"a","display_name":"Alpha","visibility":"list","priority":1,"extra":{"x":1},
357             "supported_reasoning_levels":[{"effort":"low"},{"effort":"high"}]},
358            {"slug":"plain","visibility":"list","priority":3}
359        ]}"#;
360        let models = HarnessId::Codex.parse_models(output).unwrap();
361        assert!(!models.allows_custom);
362        assert_eq!(
363            models
364                .models
365                .iter()
366                .map(|m| (m.id.as_str(), m.name.as_str()))
367                .collect::<Vec<_>>(),
368            [("a", "Alpha"), ("b", "Beta"), ("plain", "plain")]
369        );
370        assert_eq!(
371            models.models[1].efforts.as_deref(),
372            Some(&["low".to_owned(), "ultra".to_owned()][..])
373        );
374        assert_eq!(models.efforts, ["low", "high", "ultra"]);
375        assert!(HarnessId::Codex.parse_models("not json").is_err());
376    }
377
378    #[test]
379    fn pi_lists_provider_and_model_rows_as_provider_ids() {
380        let output = "provider    model                         context  max-out  thinking  images\n\
381                      lmstudio    qwen/qwen3.8-27b              169.7K   32.8K    yes       yes\n\
382                      openrouter  ~anthropic/claude-opus-latest 1M       128K     yes       yes\n\n";
383        let models = HarnessId::Pi.parse_models(output).unwrap().models;
384        assert_eq!(models.len(), 2);
385        assert_eq!(models[0].id, "lmstudio/qwen/qwen3.8-27b");
386        assert_eq!(models[0].name, "qwen/qwen3.8-27b");
387        assert_eq!(models[1].group.as_deref(), Some("openrouter"));
388        assert_eq!(
389            HarnessId::Pi
390                .parse_models("No models available.\n")
391                .unwrap()
392                .models,
393            []
394        );
395    }
396
397    #[test]
398    fn omp_lists_exact_selectors_and_model_specific_thinking_levels() {
399        let output = r#"{"models":[
400            {"selector":"local/plain","provider":"local","name":"Plain model","thinking":null},
401            {"selector":"local/path/model","provider":"local","name":"Reasoning model","thinking":["low","high","BAD"]}
402        ]}"#;
403        let models = HarnessId::Omp.parse_models(output).unwrap();
404        assert_eq!(models.models.len(), 2);
405        assert_eq!(models.models[0].name, "Plain model");
406        assert_eq!(models.models[0].efforts, Some(vec![]));
407        assert_eq!(models.models[1].id, "local/path/model");
408        assert_eq!(models.models[1].group.as_deref(), Some("local"));
409        assert_eq!(
410            models.models[1].efforts,
411            Some(vec!["low".into(), "high".into()])
412        );
413        assert!(models.allows_custom && models.supports_yolo);
414        assert_eq!(
415            HarnessId::Omp
416                .parse_models(r#"{"models":[]}"#)
417                .unwrap()
418                .models,
419            []
420        );
421        for unreadable in [
422            "not json",
423            "{}",
424            r#"{"models":[{"selector":"--flag","provider":"local","name":"Bad"}]}"#,
425        ] {
426            assert!(HarnessId::Omp.parse_models(unreadable).is_err());
427        }
428    }
429
430    #[test]
431    fn opencode_lists_provider_selectors_and_model_specific_variants() {
432        let output = r#"{"location":{"directory":"/folder"},"data":[
433            {"providerID":"opencode","id":"plain","name":"Plain","variants":[]},
434            {"providerID":"local","id":"path/model","name":"Reasoning",
435             "variants":[{"id":"low"},{"id":"high"},{"id":"custom-name"},{"id":"Custom_Name"},{"id":"bad#variant"}]}
436        ]}"#;
437        let models = HarnessId::Opencode.parse_models(output).unwrap();
438        assert_eq!(models.models[0].id, "opencode/plain");
439        assert_eq!(models.models[0].efforts, Some(vec![]));
440        assert_eq!(models.models[1].id, "local/path/model");
441        assert_eq!(models.models[1].name, "Reasoning");
442        assert_eq!(models.models[1].group.as_deref(), Some("local"));
443        assert_eq!(
444            models.models[1].efforts,
445            Some(vec![
446                "low".into(),
447                "high".into(),
448                "custom-name".into(),
449                "Custom_Name".into()
450            ])
451        );
452        assert_eq!(models.efforts, Vec::<String>::new());
453        assert!(models.allows_custom && !models.supports_yolo);
454        assert_eq!(
455            HarnessId::Opencode
456                .parse_models(r#"{"data":[]}"#)
457                .unwrap()
458                .models,
459            []
460        );
461        for unreadable in [
462            "",
463            "not json",
464            "{}",
465            r#"{"data":[{"providerID":"","id":"model","name":"Bad"}]}"#,
466        ] {
467            assert!(HarnessId::Opencode.parse_models(unreadable).is_err());
468        }
469    }
470
471    #[test]
472    fn antigravity_lists_model_ids_and_names_without_losing_spaces() {
473        let output = "Fetching available models...\n\
474                      gemini-3.8-flash-high\tGemini 3.8 Flash (High)\n\
475                      claude-sonnet-4-6\tClaude Sonnet 4.6 (Thinking)\n";
476        let models = HarnessId::Antigravity.parse_models(output).unwrap();
477        assert_eq!(models.models.len(), 2);
478        assert_eq!(models.models[0].id, "gemini-3.8-flash-high");
479        assert_eq!(models.models[0].name, "Gemini 3.8 Flash (High)");
480        assert_eq!(models.models[1].name, "Claude Sonnet 4.6 (Thinking)");
481        assert!(!models.allows_custom);
482        assert!(models.supports_yolo);
483        for unreadable in ["not a model list", "", "--flag\tBad model", "model\t"] {
484            assert!(HarnessId::Antigravity.parse_models(unreadable).is_err());
485        }
486    }
487
488    #[test]
489    fn claude_code_offers_the_aliases_its_help_names_and_custom_names() {
490        let output = "  --fallback-model <model>   Enable fallback\n\
491                      \x20 --model <model>            Model for the current session. Provide\n\
492                      \x20                            an alias for the latest model (e.g.\n\
493                      \x20                            'fable', 'opus', or 'sonnet') or a\n\
494                      \x20                            model's full name.\n\
495                      \x20 -n, --name <name>          Set a 'display name'\n\
496                      \x20 --effort <level>           Effort level for the current session\n\
497                      \x20                            (low, medium, high, xhigh, max)\n\
498                      \x20 --environment <id>         Create a session\n";
499        let models = HarnessId::ClaudeCode.parse_models(output).unwrap();
500        assert!(models.allows_custom);
501        assert_eq!(
502            models
503                .models
504                .iter()
505                .map(|m| (m.id.as_str(), m.name.as_str()))
506                .collect::<Vec<_>>(),
507            [("fable", "Fable"), ("opus", "Opus"), ("sonnet", "Sonnet")]
508        );
509        assert_eq!(models.efforts, ["low", "medium", "high", "xhigh", "max"]);
510        let changed = HarnessId::ClaudeCode
511            .parse_models("Usage: claude\n")
512            .unwrap();
513        assert!(changed.models.is_empty() && changed.allows_custom);
514    }
515}