1use std::ffi::OsString;
4use std::io::{Read, Seek};
5use std::path::Path;
6use std::process::{Command, Stdio};
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::{Arc, Mutex, PoisonError, Weak, mpsc};
9use std::thread;
10use std::time::{Duration, Instant};
11
12use tracing::warn;
13
14use super::{HarnessModels, ModelListError, help_levels};
15use crate::harness::{HarnessError, HarnessId, LocatedHarness};
16use crate::process::CommandChild;
17
18const LIST_TIMEOUT: Duration = Duration::from_secs(20);
19const OPENCODE_CATALOG_GRACE: Duration = Duration::from_secs(2);
20const MAX_LIST_OUTPUT: u64 = 4 * 1024 * 1024;
22
23impl HarnessId {
24 fn model_list_arguments(self) -> &'static [&'static str] {
26 match self {
27 Self::Codex => &["debug", "models"],
28 Self::ClaudeCode => &["--help"],
29 Self::Pi => &["--list-models"],
30 Self::Antigravity => &["models"],
31 Self::Omp => &["models", "--json"],
32 Self::Opencode => &["api", "model.list"],
33 Self::Kiro => &["chat", "--v3", "--list-models", "--format", "json"],
34 }
35 }
36}
37
38#[derive(Default)]
40pub(crate) struct ModelListWorkers {
41 workers: Mutex<Vec<Weak<ModelListWorker>>>,
42}
43
44impl ModelListWorkers {
45 pub(crate) fn request(
46 &self,
47 harness: HarnessId,
48 folder: Option<std::path::PathBuf>,
49 locate: impl FnOnce() -> Result<LocatedHarness, HarnessError> + Send + 'static,
50 ) -> ModelListRequest {
51 let request = ModelListRequest::spawn(harness, folder, locate);
52 let mut workers = self.workers.lock().unwrap_or_else(PoisonError::into_inner);
53 workers.retain(|worker| worker.strong_count() > 0);
54 workers.push(Arc::downgrade(&request.worker));
55 request
56 }
57
58 pub(crate) fn shutdown(&self) {
59 let workers =
60 std::mem::take(&mut *self.workers.lock().unwrap_or_else(PoisonError::into_inner))
61 .into_iter()
62 .filter_map(|worker| worker.upgrade())
63 .collect::<Vec<_>>();
64 for worker in &workers {
66 worker.cancelled.store(true, Ordering::Relaxed);
67 }
68 for worker in workers {
69 worker.join();
70 }
71 }
72}
73
74struct ModelListWorker {
75 cancelled: Arc<AtomicBool>,
76 thread: Mutex<Option<thread::JoinHandle<()>>>,
77}
78
79impl ModelListWorker {
80 fn join(&self) {
81 let mut thread = self.thread.lock().unwrap_or_else(PoisonError::into_inner);
83 if let Some(thread) = thread.take()
84 && thread.join().is_err()
85 {
86 warn!("model discovery worker panicked");
87 }
88 }
89}
90
91pub struct ModelListRequest {
94 receiver: mpsc::Receiver<Result<HarnessModels, ModelListError>>,
95 worker: Arc<ModelListWorker>,
96}
97
98impl ModelListRequest {
99 pub(crate) fn spawn(
101 harness: HarnessId,
102 folder: Option<std::path::PathBuf>,
103 locate: impl FnOnce() -> Result<LocatedHarness, HarnessError> + Send + 'static,
104 ) -> Self {
105 let (sender, receiver) = mpsc::sync_channel(1);
106 let cancelled = Arc::new(AtomicBool::new(false));
107 let flag = Arc::clone(&cancelled);
108 let spawned = crate::blocking_worker::spawn("harness-models".into(), move || {
109 let located = locate();
110 let result = if flag.load(Ordering::Relaxed) {
111 Err(ModelListError::Cancelled)
112 } else {
113 located.map_err(ModelListError::from).and_then(|located| {
114 list_models(
115 harness,
116 &located.program,
117 &located.path,
118 folder.as_deref(),
119 &flag,
120 )
121 })
122 };
123 let _ = sender.send(result);
124 });
125 let thread = spawned
126 .map_err(|error| warn!(%error, "couldn't start listing harness models"))
127 .ok();
128 Self {
129 receiver,
130 worker: Arc::new(ModelListWorker {
131 cancelled,
132 thread: Mutex::new(thread),
133 }),
134 }
135 }
136
137 #[must_use]
139 pub fn poll(&self) -> Option<Result<HarnessModels, ModelListError>> {
140 match self.receiver.try_recv() {
141 Ok(result) => Some(result),
142 Err(mpsc::TryRecvError::Empty) => None,
143 Err(mpsc::TryRecvError::Disconnected) => Some(Err(ModelListError::Cancelled)),
144 }
145 }
146}
147
148impl Drop for ModelListRequest {
149 fn drop(&mut self) {
150 self.worker.cancelled.store(true, Ordering::Relaxed);
151 self.worker.join();
152 }
153}
154
155fn list_models(
157 harness: HarnessId,
158 program: &Path,
159 path: &OsString,
160 folder: Option<&Path>,
161 cancelled: &AtomicBool,
162) -> Result<HarnessModels, ModelListError> {
163 let name = harness.definition().name;
164 let directory = if harness == HarnessId::Opencode {
165 folder
166 } else {
167 None
168 };
169 let run = |arguments| {
170 run(
171 program,
172 arguments,
173 path,
174 directory,
175 name,
176 LIST_TIMEOUT,
177 cancelled,
178 )
179 };
180 let location =
182 directory.map(|directory| format!("location[directory]={}", directory.display()));
183 let mut arguments = harness.model_list_arguments().to_vec();
184 if let Some(location) = &location {
185 arguments.extend(["--param", location.as_str()]);
186 }
187 let mut models = harness.parse_models(&run(&arguments)?)?;
188 let deadline = Instant::now() + OPENCODE_CATALOG_GRACE;
190 while harness == HarnessId::Opencode && models.models.is_empty() && Instant::now() < deadline {
191 if cancelled.load(Ordering::Relaxed) {
192 return Err(ModelListError::Cancelled);
193 }
194 thread::sleep(Duration::from_millis(100));
195 models = harness.parse_models(&run(&arguments)?)?;
196 }
197 let effort_option = match harness {
199 HarnessId::Pi => Some("--thinking <level>"),
200 HarnessId::Antigravity => Some("--effort"),
201 HarnessId::Omp => Some("--thinking=<value>"),
202 HarnessId::Codex | HarnessId::ClaudeCode | HarnessId::Opencode | HarnessId::Kiro => None,
203 };
204 if let Some(option) = effort_option {
205 models.efforts = help_levels(&run(&["--help"])?, option);
206 }
207 Ok(models)
208}
209
210fn run(
213 program: &Path,
214 arguments: &[&str],
215 path: &OsString,
216 directory: Option<&Path>,
217 name: &'static str,
218 timeout: Duration,
219 cancelled: &AtomicBool,
220) -> Result<String, ModelListError> {
221 if cancelled.load(Ordering::Relaxed) {
222 return Err(ModelListError::Cancelled);
223 }
224 let failed = |error: std::io::Error| {
225 warn!(harness = name, %error, "couldn't run a harness to list its models");
226 ModelListError::Failed(name)
227 };
228 let mut output = tempfile::tempfile().map_err(failed)?;
230 let stdout = output.try_clone().map_err(failed)?;
231 let mut command = Command::new(program);
232 if let Some(directory) = directory {
233 command.current_dir(directory);
234 }
235 command
236 .args(arguments)
237 .env("PATH", path)
238 .env("NO_COLOR", "1")
239 .stdin(Stdio::null())
240 .stdout(stdout)
241 .stderr(Stdio::null());
242 let mut child = CommandChild::spawn(&mut command).map_err(|error| {
243 warn!(harness = name, %error, "couldn't start a harness to list its models");
244 ModelListError::Start(name)
245 })?;
246 let deadline = Instant::now() + timeout;
247 let status = loop {
248 if cancelled.load(Ordering::Relaxed) {
249 return Err(ModelListError::Cancelled);
250 }
251 if output.metadata().map_err(failed)?.len() > MAX_LIST_OUTPUT {
252 warn!(
253 harness = name,
254 "a harness printed too much while listing its models"
255 );
256 return Err(ModelListError::Unreadable(name));
257 }
258 if child.has_exited().map_err(failed)? {
259 break child.finish().map_err(failed)?;
260 }
261 if Instant::now() >= deadline {
262 warn!(harness = name, "a harness took too long to list its models");
263 return Err(ModelListError::Timeout(name));
264 }
265 thread::sleep(Duration::from_millis(10));
266 };
267 if !status.success() {
268 warn!(harness = name, %status, "a harness failed to list its models");
269 return Err(ModelListError::Failed(name));
270 }
271 output.rewind().map_err(failed)?;
272 let mut text = String::new();
273 output
274 .take(MAX_LIST_OUTPUT)
275 .read_to_string(&mut text)
276 .map_err(|_| ModelListError::Unreadable(name))?;
277 Ok(text)
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283 use crate::process::tests::FixtureProcess;
284
285 #[test]
286 fn concurrent_request_destruction_and_shutdown_both_wait_for_cleanup() {
287 let workers = Arc::new(ModelListWorkers::default());
288 let (started, ready) = mpsc::channel();
289 let (release, gate) = mpsc::channel();
290 let request = workers.request(HarnessId::Pi, None, move || {
291 started.send(()).unwrap();
292 gate.recv().unwrap();
293 Err(HarnessError::NotFound {
294 name: "fixture",
295 binary: "fixture",
296 })
297 });
298 ready.recv_timeout(Duration::from_secs(3)).unwrap();
299 let cancellation = Arc::clone(&request.worker.cancelled);
300 let (dropped, drop_done) = mpsc::channel();
301 let dropping = thread::spawn(move || {
302 drop(request);
303 dropped.send(()).unwrap();
304 });
305 let deadline = Instant::now() + Duration::from_secs(3);
306 while !cancellation.load(Ordering::Relaxed) {
307 assert!(Instant::now() < deadline);
308 thread::sleep(Duration::from_millis(5));
309 }
310 let registry = Arc::clone(&workers);
311 let (stopped, shutdown_done) = mpsc::channel();
312 let stopping = thread::spawn(move || {
313 registry.shutdown();
314 stopped.send(()).unwrap();
315 });
316 while !workers.workers.lock().unwrap().is_empty() {
318 assert!(Instant::now() < deadline);
319 thread::sleep(Duration::from_millis(5));
320 }
321 assert!(matches!(
322 drop_done.recv_timeout(Duration::from_millis(50)),
323 Err(mpsc::RecvTimeoutError::Timeout)
324 ));
325 assert!(matches!(
326 shutdown_done.recv_timeout(Duration::from_millis(50)),
327 Err(mpsc::RecvTimeoutError::Timeout)
328 ));
329 release.send(()).unwrap();
330 dropping.join().unwrap();
331 stopping.join().unwrap();
332 drop_done.recv().unwrap();
333 shutdown_done.recv().unwrap();
334 }
335
336 #[test]
337 fn omp_discovers_models_and_thinking_levels_from_its_cli() {
338 let directory = tempfile::tempdir().unwrap();
339 let program = stub(
340 directory.path(),
341 "omp",
342 "case \"$1 $2\" in\n'models --json') printf '%s\\n' '{\"models\":[{\"selector\":\"local/model\",\"provider\":\"local\",\"name\":\"Model\",\"thinking\":[\"high\"]}]}' ;;\n\
343 '--help ') printf ' --thinking=<value> Set thinking level: off, minimal, low, medium, high, xhigh, max, auto\\n --service-tier=<value> Service tier\\n' ;;\n\
344 *) exit 1 ;;\nesac",
345 );
346 let models = list_models(
347 HarnessId::Omp,
348 &program,
349 &OsString::from("/bin:/usr/bin"),
350 None,
351 &AtomicBool::new(false),
352 )
353 .unwrap();
354 assert_eq!(models.models[0].id, "local/model");
355 assert_eq!(
356 models.efforts,
357 [
358 "off", "minimal", "low", "medium", "high", "xhigh", "max", "auto"
359 ]
360 );
361 }
362
363 #[test]
364 fn opencode_discovers_model_variants_through_its_cli_api() {
365 let directory = tempfile::tempdir().unwrap();
366 let program = stub(
367 directory.path(),
368 "opencode",
369 r#"
370 [ "$#" -eq 2 ] && [ "$1 $2" = 'api model.list' ] || exit 1
371 printf '%s\n' '{"data":[{"providerID":"local","id":"model","name":"Model","variants":[{"id":"high"}]}]}'
372 "#,
373 );
374 let models = list_models(
375 HarnessId::Opencode,
376 &program,
377 &OsString::from("/bin:/usr/bin"),
378 None,
379 &AtomicBool::new(false),
380 )
381 .unwrap();
382 assert_eq!(models.models[0].id, "local/model");
383 assert_eq!(models.models[0].efforts, Some(vec!["high".into()]));
384 assert!(models.efforts.is_empty() && !models.supports_yolo);
385 }
386
387 #[test]
388 fn opencode_discovers_each_folders_catalog_after_provider_startup() {
389 let directory = tempfile::tempdir().unwrap();
390 let program = stub(
391 directory.path(),
392 "opencode",
393 r#"
394 [ "$1 $2 $3" = 'api model.list --param' ] || exit 1
395 catalog_location=${4#*=}
396 [ "${4%%=*}" = 'location[directory]' ] && [ "$catalog_location" -ef "$PWD" ] || exit 1
397 if [ ! -f ready ]; then
398 touch ready
399 printf '%s\n' '{"data":[]}'
400 else
401 cat catalog.json
402 fi
403 "#,
404 );
405 for (folder, variant) in [("first", "custom-name"), ("second", "Custom_Name")] {
406 let path = directory.path().join(folder);
407 std::fs::create_dir(&path).unwrap();
408 std::fs::write(path.join("catalog.json"), serde_json::to_vec(&serde_json::json!({
409 "data": [{"providerID": "local", "id": folder, "name": folder, "variants": [{"id": variant}]}]
410 })).unwrap()).unwrap();
411 let models = list_models(
412 HarnessId::Opencode,
413 &program,
414 &OsString::from("/bin:/usr/bin"),
415 Some(&path),
416 &AtomicBool::new(false),
417 )
418 .unwrap();
419 assert_eq!(models.models[0].id, format!("local/{folder}"));
420 assert_eq!(models.models[0].efforts, Some(vec![variant.into()]));
421 }
422 }
423
424 #[test]
425 fn antigravity_reads_models_and_effort_levels_from_its_cli() {
426 let directory = tempfile::tempdir().unwrap();
427 let program = stub(
428 directory.path(),
429 "agy",
430 "case \"$1\" in\nmodels) printf 'gemini-3.8-flash-high\\tGemini 3.8 Flash (High)\\n' ;;\n\
431 --help) printf ' --effort Reasoning effort for the current CLI session (low|medium|high)\\n --model Model\\n' ;;\n\
432 *) exit 1 ;;\nesac",
433 );
434 let models = list_models(
435 HarnessId::Antigravity,
436 &program,
437 &OsString::from("/bin:/usr/bin"),
438 None,
439 &AtomicBool::new(false),
440 )
441 .unwrap();
442 assert_eq!(models.models[0].id, "gemini-3.8-flash-high");
443 assert_eq!(models.efforts, ["low", "medium", "high"]);
444 assert!(models.supports_yolo);
445 }
446
447 fn stub(directory: &Path, name: &str, script: &str) -> std::path::PathBuf {
448 let program = directory.join(name);
449 std::fs::write(&program, format!("#!/bin/sh\n{script}\n")).unwrap();
450 std::fs::set_permissions(
451 &program,
452 std::os::unix::fs::PermissionsExt::from_mode(0o755),
453 )
454 .unwrap();
455 program
456 }
457
458 fn is_running(pid: &str) -> bool {
459 Command::new("/bin/kill")
460 .args(["-0", pid])
461 .stderr(Stdio::null())
462 .status()
463 .unwrap()
464 .success()
465 }
466
467 #[test]
468 fn listing_runs_the_harness_and_reports_a_harness_that_fails_to_answer() {
469 let directory = tempfile::tempdir().unwrap();
470 let program = stub(
471 directory.path(),
472 "pi",
473 "case \"$1\" in\n--list-models) printf 'provider model\\nlocal m1 x\\n' ;;\n\
474 --help) printf ' --thinking <level> Set thinking level: off, low, high\\n -e x\\n' ;;\n\
475 *) exit 1 ;;\nesac",
476 );
477 let path = OsString::from("/bin:/usr/bin");
478 let running = AtomicBool::new(false);
479 let models = list_models(HarnessId::Pi, &program, &path, None, &running).unwrap();
480 assert_eq!(models.models[0].id, "local/m1");
481 assert_eq!(models.efforts, ["off", "low", "high"]);
482 assert!(list_models(HarnessId::Codex, &program, &path, None, &running).is_err());
483 assert!(
484 list_models(
485 HarnessId::Pi,
486 &directory.path().join("missing"),
487 &path,
488 None,
489 &running
490 )
491 .is_err()
492 );
493 let broken = stub(directory.path(), "claude", "exit 1");
495 assert!(matches!(
496 list_models(HarnessId::ClaudeCode, &broken, &path, None, &running),
497 Err(ModelListError::Failed("Claude Code"))
498 ));
499 }
500
501 #[test]
502 fn a_slow_or_flooding_harness_is_stopped() {
503 let directory = tempfile::tempdir().unwrap();
504 let path = OsString::from("/bin:/usr/bin");
505 let running = AtomicBool::new(false);
506 let pid_file = directory.path().join("pid");
507 let slow = stub(
508 directory.path(),
509 "slow",
510 &format!("echo $$ > '{}'; exec sleep 60", pid_file.display()),
511 );
512 let started = Instant::now();
513 assert!(matches!(
514 run(
515 &slow,
516 &[],
517 &path,
518 None,
519 "pi",
520 Duration::from_millis(300),
521 &running
522 ),
523 Err(ModelListError::Timeout("pi"))
524 ));
525 assert!(started.elapsed() < Duration::from_secs(5));
526 let pid = std::fs::read_to_string(&pid_file).unwrap();
527 assert!(!is_running(pid.trim()), "the timed-out harness was killed");
528 let child_file = directory.path().join("child");
529 let wrapper = stub(
530 directory.path(),
531 "wrapper",
532 &format!("sleep 60 & echo $! > '{}'; wait", child_file.display()),
533 );
534 assert!(
535 run(
536 &wrapper,
537 &[],
538 &path,
539 None,
540 "pi",
541 Duration::from_millis(300),
542 &running
543 )
544 .is_err()
545 );
546 let child = std::fs::read_to_string(&child_file).unwrap();
547 let deadline = Instant::now() + Duration::from_secs(5);
549 while is_running(child.trim()) {
550 assert!(
551 Instant::now() < deadline,
552 "what the harness started was left running"
553 );
554 thread::sleep(Duration::from_millis(10));
555 }
556 let flood = stub(directory.path(), "flood", "exec yes model");
557 assert!(matches!(
558 run(&flood, &[], &path, None, "pi", LIST_TIMEOUT, &running),
559 Err(ModelListError::Unreadable("pi"))
560 ));
561 }
562
563 #[test]
564 fn completed_helpers_leave_no_background_children() {
565 let directory = tempfile::tempdir().unwrap();
566 let pid_path = directory.path().join("child.pid");
567 for exit_code in [0, 7] {
568 let program = stub(
569 directory.path(),
570 "helper",
571 &format!(
572 "sleep 60 & echo $! > '{}'; printf 'catalog'; exit {exit_code}",
573 pid_path.display()
574 ),
575 );
576 let result = run(
577 &program,
578 &[],
579 &OsString::from("/bin:/usr/bin"),
580 None,
581 "fixture",
582 LIST_TIMEOUT,
583 &AtomicBool::new(false),
584 );
585 let child = FixtureProcess::read(&pid_path);
586 if exit_code == 0 {
587 assert_eq!(result.unwrap(), "catalog");
588 } else {
589 assert!(matches!(result, Err(ModelListError::Failed("fixture"))));
590 }
591 child.assert_stopped();
592 }
593 }
594
595 #[test]
596 fn dropping_a_request_stops_its_harness() {
597 let directory = tempfile::tempdir().unwrap();
598 let pid_file = directory.path().join("pid");
599 let slow = stub(
600 directory.path(),
601 "pi",
602 &format!("echo $$ > '{}'; exec sleep 60", pid_file.display()),
603 );
604 let request = ModelListRequest::spawn(HarnessId::Pi, None, move || {
605 Ok(LocatedHarness {
606 program: slow,
607 path: OsString::from("/bin:/usr/bin"),
608 })
609 });
610 let deadline = Instant::now() + Duration::from_secs(10);
611 while std::fs::read_to_string(&pid_file).map_or(true, |pid| pid.trim().is_empty()) {
612 assert!(Instant::now() < deadline, "the harness never started");
613 thread::sleep(Duration::from_millis(10));
614 }
615 assert!(request.poll().is_none(), "still listing");
616 drop(request);
617 let pid = std::fs::read_to_string(&pid_file).unwrap();
618 assert!(
619 !is_running(pid.trim()),
620 "request destruction waits for the harness to stop"
621 );
622 }
623}