1use 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 pub id: String,
17 pub name: String,
18 #[serde(skip_serializing_if = "Option::is_none")]
20 pub group: Option<String>,
21 #[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 pub allows_custom: bool,
32 pub efforts: Vec<String>,
34 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 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 Self::Pi => Ok(HarnessModels {
80 models: parse_pi(output),
81 allows_custom: false,
82 efforts: Vec::new(),
83 supports_yolo: false,
84 }),
85 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 efforts: Vec::new(),
109 supports_yolo: false,
111 }),
112 }
113 }
114}
115
116fn 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
160fn 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 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
197fn 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
218fn 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
265fn 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
284fn 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 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
319fn 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}