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 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 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::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 efforts: Vec::new(),
110 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
139fn 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
183fn 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 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
220fn 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
241fn 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
288fn 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
307fn 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 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
342fn 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}