1use std::collections::HashSet;
2
3use serde::Serialize;
4
5use crate::routing::slug;
6
7use super::probes::{OpenCodeProbeResult, PiProbeResult};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
10#[serde(rename_all = "snake_case")]
11pub enum AvailabilityStatus {
12 Runnable,
13 Unavailable,
14 Unknown,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
18#[serde(rename_all = "snake_case")]
19pub enum AvailabilitySource {
20 HarnessInstalled,
21 UniversalHarness,
22 #[serde(rename = "pi_probe")]
23 PiProbe,
24 #[serde(rename = "pi_probe_negative")]
25 PiProbeNegative,
26 #[serde(rename = "opencode_probe")]
27 OpenCodeProbe,
28 #[serde(rename = "opencode_probe_negative")]
29 OpenCodeProbeNegative,
30 #[serde(rename = "opencode_probe_unknown")]
31 OpenCodeProbeUnknown,
32 NoHarness,
33 Offline,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
38pub struct RunnablePath {
39 pub harness: String,
40 pub mars_provider: String,
41 pub harness_model_id: String,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
46pub struct ModelAvailability {
47 pub status: AvailabilityStatus,
48 pub source: AvailabilitySource,
49 pub runnable_paths: Vec<RunnablePath>,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum RunnablePathSource {
54 CachedProbe,
55 ProviderMatch,
56 Synthesized,
57 Passthrough,
58}
59
60impl RunnablePathSource {
61 pub fn label(self) -> &'static str {
62 match self {
63 Self::CachedProbe => "cached-probe",
64 Self::ProviderMatch => "provider-match",
65 Self::Synthesized => "synthesized",
66 Self::Passthrough => "passthrough",
67 }
68 }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum RunnableConfidence {
73 Confirmed,
74 Likely,
75 Unknown,
76}
77
78impl RunnableConfidence {
79 pub fn label(self) -> &'static str {
80 match self {
81 Self::Confirmed => "confirmed",
82 Self::Likely => "likely",
83 Self::Unknown => "unknown",
84 }
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct ResolvedRunnablePath {
90 pub harness_model_id: String,
91 pub source: RunnablePathSource,
92 pub confidence: RunnableConfidence,
93}
94
95pub fn resolve_runnable_path(
96 model_id: &str,
97 provider: &str,
98 target_harness: &str,
99 probe_result: Option<&OpenCodeProbeResult>,
100) -> ResolvedRunnablePath {
101 if let Some(cached_path) =
102 resolve_cached_probe_path(model_id, provider, target_harness, probe_result)
103 {
104 return cached_path;
105 }
106
107 if is_provider_native_harness(provider, target_harness) {
108 return ResolvedRunnablePath {
109 harness_model_id: model_id.to_string(),
110 source: RunnablePathSource::ProviderMatch,
111 confidence: RunnableConfidence::Likely,
112 };
113 }
114
115 ResolvedRunnablePath {
116 harness_model_id: model_id.to_string(),
117 source: RunnablePathSource::Passthrough,
118 confidence: RunnableConfidence::Unknown,
119 }
120}
121
122fn resolve_cached_probe_path(
123 model_id: &str,
124 provider: &str,
125 target_harness: &str,
126 probe_result: Option<&OpenCodeProbeResult>,
127) -> Option<ResolvedRunnablePath> {
128 if !target_harness.eq_ignore_ascii_case("opencode") {
129 return None;
130 }
131 if provider.trim().is_empty() {
132 return None;
133 }
134
135 let probe = probe_result?;
136 if !probe.model_probe_success {
137 return None;
138 }
139
140 let matched_slug = slug::find_exact_match(
141 model_id,
142 provider,
143 probe.model_slugs.iter().map(String::as_str),
144 )?
145 .slug;
146 Some(ResolvedRunnablePath {
147 harness_model_id: matched_slug,
148 source: RunnablePathSource::CachedProbe,
149 confidence: RunnableConfidence::Confirmed,
150 })
151}
152
153pub fn classify_for_harness(
155 harness: &str,
156 provider: &str,
157 model_id: &str,
158 installed: &HashSet<String>,
159 probe_result: Option<&OpenCodeProbeResult>,
160) -> Option<(AvailabilityStatus, AvailabilitySource, Option<RunnablePath>)> {
161 let harness = harness.to_ascii_lowercase();
162 if !installed.contains(&harness) {
163 return Some((
164 AvailabilityStatus::Unavailable,
165 AvailabilitySource::NoHarness,
166 None,
167 ));
168 }
169
170 let direct_match = match harness.as_str() {
171 "claude" => slug::providers_match(provider, "anthropic"),
172 "codex" => slug::providers_match(provider, "openai"),
173 "opencode" => return classify_opencode(provider, model_id, probe_result),
174 "pi" | "cursor" => return classify_universal_harness(),
175 _ => false,
176 };
177
178 if direct_match {
179 Some((
180 AvailabilityStatus::Runnable,
181 AvailabilitySource::HarnessInstalled,
182 Some(RunnablePath {
183 harness,
184 mars_provider: provider.to_string(),
185 harness_model_id: model_id.to_string(),
186 }),
187 ))
188 } else {
189 Some((
190 AvailabilityStatus::Unavailable,
191 AvailabilitySource::NoHarness,
192 None,
193 ))
194 }
195}
196
197fn classify_universal_harness()
198-> Option<(AvailabilityStatus, AvailabilitySource, Option<RunnablePath>)> {
199 Some((
200 AvailabilityStatus::Unknown,
201 AvailabilitySource::UniversalHarness,
202 None,
203 ))
204}
205
206fn classify_opencode(
207 provider: &str,
208 model_id: &str,
209 probe_result: Option<&OpenCodeProbeResult>,
210) -> Option<(AvailabilityStatus, AvailabilitySource, Option<RunnablePath>)> {
211 let Some(probe) = probe_result else {
212 return Some((
213 AvailabilityStatus::Unknown,
214 AvailabilitySource::OpenCodeProbeUnknown,
215 None,
216 ));
217 };
218
219 if !probe.model_probe_success {
220 return Some((
221 AvailabilityStatus::Unknown,
222 AvailabilitySource::OpenCodeProbeUnknown,
223 None,
224 ));
225 }
226
227 if is_unknown_provider(provider) {
228 return Some((
229 AvailabilityStatus::Unknown,
230 AvailabilitySource::OpenCodeProbeUnknown,
231 None,
232 ));
233 }
234
235 let Some(harness_model_id) = slug::find_exact_match(
236 model_id,
237 provider,
238 probe.model_slugs.iter().map(String::as_str),
239 )
240 .map(|matched| matched.slug) else {
241 return Some((
242 AvailabilityStatus::Unavailable,
243 AvailabilitySource::OpenCodeProbeNegative,
244 None,
245 ));
246 };
247
248 Some((
249 AvailabilityStatus::Runnable,
250 AvailabilitySource::OpenCodeProbe,
251 Some(RunnablePath {
252 harness: "opencode".to_string(),
253 mars_provider: provider.to_string(),
254 harness_model_id,
255 }),
256 ))
257}
258
259fn is_unknown_provider(provider: &str) -> bool {
260 let provider = provider.trim();
261 provider.is_empty() || provider.eq_ignore_ascii_case("unknown")
262}
263
264fn is_provider_native_harness(provider: &str, target_harness: &str) -> bool {
265 slug::provider_matches_native_harness(provider, target_harness)
266}
267
268pub fn classify_model(
269 model_id: &str,
270 provider: &str,
271 installed: &HashSet<String>,
272 opencode_probe_result: Option<&OpenCodeProbeResult>,
273 pi_probe_result: Option<&PiProbeResult>,
274 offline: bool,
275) -> ModelAvailability {
276 let mut statuses = Vec::new();
277 let mut runnable_paths = Vec::new();
278
279 for harness in ["claude", "codex", "cursor"] {
280 let Some((status, source, path)) =
281 classify_for_harness(harness, provider, model_id, installed, None)
282 else {
283 continue;
284 };
285 if let Some(path) = path {
286 runnable_paths.push(path);
287 }
288 statuses.push((status, source));
289 }
290
291 if let Some((status, source, path)) =
292 classify_pi_for_model(provider, model_id, installed, pi_probe_result, offline)
293 {
294 if let Some(path) = path {
295 runnable_paths.push(path);
296 }
297 statuses.push((status, source));
298 }
299
300 if installed.contains("opencode") {
301 if offline {
302 statuses.push((AvailabilityStatus::Unknown, AvailabilitySource::Offline));
303 } else if let Some(result) = opencode_probe_result {
304 if let Some((status, source, path)) =
305 classify_for_harness("opencode", provider, model_id, installed, Some(result))
306 {
307 if let Some(path) = path {
308 runnable_paths.push(path);
309 }
310 statuses.push((status, source));
311 }
312 } else {
313 statuses.push((
314 AvailabilityStatus::Unknown,
315 AvailabilitySource::OpenCodeProbeUnknown,
316 ));
317 }
318 }
319
320 aggregate_statuses(statuses, runnable_paths)
321}
322
323fn classify_pi_for_model(
324 provider: &str,
325 model_id: &str,
326 installed: &HashSet<String>,
327 pi_probe_result: Option<&PiProbeResult>,
328 offline: bool,
329) -> Option<(AvailabilityStatus, AvailabilitySource, Option<RunnablePath>)> {
330 if !installed.contains("pi") {
331 return None;
332 }
333
334 if offline || pi_probe_result.is_none() {
335 return classify_universal_harness();
336 }
337
338 let pi_probe_result = pi_probe_result.expect("checked is_some above");
339 if !pi_probe_result.compatible {
340 return Some((
341 AvailabilityStatus::Unavailable,
342 AvailabilitySource::PiProbeNegative,
343 None,
344 ));
345 }
346
347 let Some(harness_model_id) = slug::find_exact_match(
348 model_id,
349 provider,
350 pi_probe_result.model_slugs.iter().map(String::as_str),
351 )
352 .map(|matched| matched.slug) else {
353 return Some((
354 AvailabilityStatus::Unavailable,
355 AvailabilitySource::PiProbeNegative,
356 None,
357 ));
358 };
359
360 Some((
361 AvailabilityStatus::Runnable,
362 AvailabilitySource::PiProbe,
363 Some(RunnablePath {
364 harness: "pi".to_string(),
365 mars_provider: provider.to_string(),
366 harness_model_id,
367 }),
368 ))
369}
370
371fn aggregate_statuses(
372 statuses: Vec<(AvailabilityStatus, AvailabilitySource)>,
373 runnable_paths: Vec<RunnablePath>,
374) -> ModelAvailability {
375 if statuses.is_empty() {
376 return ModelAvailability {
377 status: AvailabilityStatus::Unavailable,
378 source: AvailabilitySource::NoHarness,
379 runnable_paths: Vec::new(),
380 };
381 }
382
383 if statuses
384 .iter()
385 .any(|(status, _)| *status == AvailabilityStatus::Runnable)
386 {
387 return ModelAvailability {
388 status: AvailabilityStatus::Runnable,
389 source: statuses
390 .iter()
391 .find_map(|(status, source)| {
392 (*status == AvailabilityStatus::Runnable).then(|| source.clone())
393 })
394 .expect("runnable status exists"),
395 runnable_paths,
396 };
397 }
398
399 if statuses
400 .iter()
401 .any(|(status, _)| *status == AvailabilityStatus::Unknown)
402 {
403 return ModelAvailability {
404 status: AvailabilityStatus::Unknown,
405 source: statuses
406 .iter()
407 .find_map(|(status, source)| {
408 (*status == AvailabilityStatus::Unknown).then(|| source.clone())
409 })
410 .unwrap_or(AvailabilitySource::OpenCodeProbeUnknown),
411 runnable_paths: Vec::new(),
412 };
413 }
414
415 ModelAvailability {
416 status: AvailabilityStatus::Unavailable,
417 source: statuses
418 .iter()
419 .find_map(|(_, source)| {
420 (*source != AvailabilitySource::NoHarness).then(|| source.clone())
421 })
422 .or_else(|| statuses.first().map(|(_, source)| source.clone()))
423 .unwrap_or(AvailabilitySource::NoHarness),
424 runnable_paths: Vec::new(),
425 }
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431
432 fn installed(names: &[&str]) -> HashSet<String> {
433 names.iter().map(|name| (*name).to_string()).collect()
434 }
435
436 #[test]
437 fn test_classify_claude_anthropic() {
438 let result = classify_for_harness(
439 "claude",
440 "Anthropic",
441 "claude-opus-4-7",
442 &installed(&["claude"]),
443 None,
444 )
445 .unwrap();
446 assert_eq!(result.0, AvailabilityStatus::Runnable);
447 assert_eq!(result.1, AvailabilitySource::HarnessInstalled);
448 assert_eq!(
449 result.2.unwrap().harness_model_id,
450 "claude-opus-4-7".to_string()
451 );
452 }
453
454 #[test]
455 fn test_classify_codex_openai() {
456 let result =
457 classify_for_harness("codex", "OpenAI", "gpt-5.4", &installed(&["codex"]), None)
458 .unwrap();
459 assert_eq!(result.0, AvailabilityStatus::Runnable);
460 assert_eq!(result.1, AvailabilitySource::HarnessInstalled);
461 }
462
463 #[test]
464 fn test_classify_codex_openai_codex_variant_is_runnable() {
465 let result = classify_for_harness(
466 "codex",
467 "openai-codex",
468 "gpt-5.4-mini",
469 &installed(&["codex"]),
470 None,
471 )
472 .unwrap();
473 assert_eq!(result.0, AvailabilityStatus::Runnable);
474 assert_eq!(result.1, AvailabilitySource::HarnessInstalled);
475 assert_eq!(
476 result
477 .2
478 .expect("runnable path should be present")
479 .harness_model_id,
480 "gpt-5.4-mini"
481 );
482 }
483
484 #[test]
485 fn test_classify_pi_is_universal_unknown_when_installed() {
486 let result =
487 classify_for_harness("pi", "OpenAI", "gpt-5.4-mini", &installed(&["pi"]), None)
488 .unwrap();
489 assert_eq!(result.0, AvailabilityStatus::Unknown);
490 assert_eq!(result.1, AvailabilitySource::UniversalHarness);
491 assert!(result.2.is_none());
492 }
493
494 #[test]
495 fn test_classify_cursor_is_universal_unknown_when_installed() {
496 let result = classify_for_harness(
497 "cursor",
498 "Anthropic",
499 "claude-opus-4-7",
500 &installed(&["cursor"]),
501 None,
502 )
503 .unwrap();
504 assert_eq!(result.0, AvailabilityStatus::Unknown);
505 assert_eq!(result.1, AvailabilitySource::UniversalHarness);
506 assert!(result.2.is_none());
507 }
508
509 #[test]
510 fn test_classify_no_harness() {
511 let result = classify_for_harness(
512 "claude",
513 "Anthropic",
514 "claude-opus-4-7",
515 &installed(&[]),
516 None,
517 )
518 .unwrap();
519 assert_eq!(result.0, AvailabilityStatus::Unavailable);
520 assert_eq!(result.1, AvailabilitySource::NoHarness);
521 assert!(result.2.is_none());
522 }
523
524 #[test]
525 fn test_classify_multi_harness_any_runnable() {
526 let result = classify_model(
527 "claude-opus-4-7",
528 "Anthropic",
529 &installed(&["claude", "codex"]),
530 None,
531 None,
532 false,
533 );
534 assert_eq!(result.status, AvailabilityStatus::Runnable);
535 assert_eq!(result.source, AvailabilitySource::HarnessInstalled);
536 assert_eq!(result.runnable_paths.len(), 1);
537 assert_eq!(result.runnable_paths[0].harness, "claude");
538 }
539
540 #[test]
541 fn test_classify_multi_harness_all_unavailable() {
542 let result = classify_model(
543 "custom-model",
544 "Unknown",
545 &installed(&[]),
546 None,
547 None,
548 false,
549 );
550 assert_eq!(result.status, AvailabilityStatus::Unavailable);
551 assert_eq!(result.source, AvailabilitySource::NoHarness);
552 assert!(result.runnable_paths.is_empty());
553 }
554
555 #[test]
556 fn test_classify_google_model_with_only_pi_installed_is_unknown_universal() {
557 let result = classify_model(
558 "gemini-2.5-pro",
559 "Google",
560 &installed(&["pi"]),
561 None,
562 None,
563 false,
564 );
565 assert_eq!(result.status, AvailabilityStatus::Unknown);
566 assert_eq!(result.source, AvailabilitySource::UniversalHarness);
567 assert!(result.runnable_paths.is_empty());
568 }
569
570 #[test]
571 fn test_classify_pi_probe_compatible_is_runnable() {
572 let pi_probe = PiProbeResult {
573 compatible: true,
574 model_slugs: HashSet::from(["openai/gpt-5.4-mini".to_string()]),
575 ..PiProbeResult::default()
576 };
577
578 let result = classify_model(
579 "gpt-5.4-mini",
580 "OpenAI",
581 &installed(&["pi"]),
582 None,
583 Some(&pi_probe),
584 false,
585 );
586
587 assert_eq!(result.status, AvailabilityStatus::Runnable);
588 assert_eq!(result.source, AvailabilitySource::PiProbe);
589 assert_eq!(result.runnable_paths.len(), 1);
590 assert_eq!(result.runnable_paths[0].harness, "pi");
591 assert_eq!(
592 result.runnable_paths[0].harness_model_id,
593 "openai/gpt-5.4-mini"
594 );
595 }
596
597 #[test]
598 fn test_classify_pi_probe_incompatible_is_unavailable_without_other_harnesses() {
599 let pi_probe = PiProbeResult {
600 compatible: false,
601 ..PiProbeResult::default()
602 };
603
604 let result = classify_model(
605 "gpt-5.4-mini",
606 "OpenAI",
607 &installed(&["pi"]),
608 None,
609 Some(&pi_probe),
610 false,
611 );
612
613 assert_eq!(result.status, AvailabilityStatus::Unavailable);
614 assert_eq!(result.source, AvailabilitySource::PiProbeNegative);
615 assert!(result.runnable_paths.is_empty());
616 }
617
618 #[test]
619 fn test_classify_pi_probe_incompatible_yields_to_runnable_harness() {
620 let pi_probe = PiProbeResult {
621 compatible: false,
622 ..PiProbeResult::default()
623 };
624
625 let result = classify_model(
626 "gpt-5.4-mini",
627 "OpenAI",
628 &installed(&["pi", "codex"]),
629 None,
630 Some(&pi_probe),
631 false,
632 );
633
634 assert_eq!(result.status, AvailabilityStatus::Runnable);
635 assert_eq!(result.source, AvailabilitySource::HarnessInstalled);
636 assert_eq!(result.runnable_paths.len(), 1);
637 assert_eq!(result.runnable_paths[0].harness, "codex");
638 }
639
640 #[test]
641 fn test_classify_pi_probe_missing_model_is_unavailable() {
642 let pi_probe = PiProbeResult {
643 compatible: true,
644 model_slugs: HashSet::from(["openai/gpt-5.4".to_string()]),
645 ..PiProbeResult::default()
646 };
647
648 let result = classify_model(
649 "gpt-5.4-mini",
650 "OpenAI",
651 &installed(&["pi"]),
652 None,
653 Some(&pi_probe),
654 false,
655 );
656
657 assert_eq!(result.status, AvailabilityStatus::Unavailable);
658 assert_eq!(result.source, AvailabilitySource::PiProbeNegative);
659 assert!(result.runnable_paths.is_empty());
660 }
661
662 #[test]
663 fn test_classify_offline_mode() {
664 let result = classify_model(
665 "gpt-5.4",
666 "OpenAI",
667 &installed(&["codex"]),
668 None,
669 None,
670 true,
671 );
672 assert_eq!(result.status, AvailabilityStatus::Runnable);
673 assert_eq!(result.source, AvailabilitySource::HarnessInstalled);
674 assert_eq!(result.runnable_paths.len(), 1);
675 assert_eq!(result.runnable_paths[0].harness, "codex");
676
677 let result = classify_model(
678 "gpt-5.4",
679 "OpenAI",
680 &installed(&["opencode"]),
681 None,
682 None,
683 true,
684 );
685 assert_eq!(result.status, AvailabilityStatus::Unknown);
686 assert_eq!(result.source, AvailabilitySource::Offline);
687 assert!(result.runnable_paths.is_empty());
688 }
689
690 #[test]
691 fn test_classify_opencode_direct_slug() {
692 let probe = OpenCodeProbeResult {
693 model_slugs: vec!["openai/gpt-5.4".to_string()],
694 model_probe_success: true,
695 error: None,
696 };
697
698 let result = classify_model(
699 "gpt-5.4",
700 "OpenAI",
701 &installed(&["opencode"]),
702 Some(&probe),
703 None,
704 false,
705 );
706
707 assert_eq!(result.status, AvailabilityStatus::Runnable);
708 assert_eq!(result.source, AvailabilitySource::OpenCodeProbe);
709 assert_eq!(result.runnable_paths.len(), 1);
710 assert_eq!(result.runnable_paths[0].harness, "opencode");
711 assert_eq!(result.runnable_paths[0].harness_model_id, "openai/gpt-5.4");
712 }
713
714 #[test]
715 fn test_classify_opencode_nested_provider_slug_is_not_flattened() {
716 let probe = OpenCodeProbeResult {
717 model_slugs: vec!["openrouter/anthropic/claude-opus-4.7".to_string()],
718 model_probe_success: true,
719 error: None,
720 };
721
722 let result = classify_model(
723 "claude-opus-4-7",
724 "Anthropic",
725 &installed(&["opencode"]),
726 Some(&probe),
727 None,
728 false,
729 );
730
731 assert_eq!(result.status, AvailabilityStatus::Unavailable);
732 assert_eq!(result.source, AvailabilitySource::OpenCodeProbeNegative);
733 assert!(result.runnable_paths.is_empty());
734 }
735
736 #[test]
737 fn test_classify_opencode_provider_negative() {
738 let probe = OpenCodeProbeResult {
739 model_slugs: vec!["google/gemini-2.5-pro".to_string()],
740 model_probe_success: true,
741 ..OpenCodeProbeResult::default()
742 };
743
744 let result = classify_model(
745 "gpt-5.4",
746 "OpenAI",
747 &installed(&["opencode"]),
748 Some(&probe),
749 None,
750 false,
751 );
752
753 assert_eq!(result.status, AvailabilityStatus::Unavailable);
754 assert_eq!(result.source, AvailabilitySource::OpenCodeProbeNegative);
755 assert!(result.runnable_paths.is_empty());
756 }
757
758 #[test]
759 fn test_classify_opencode_empty_slugs() {
760 let probe = OpenCodeProbeResult {
761 model_slugs: Vec::new(),
762 model_probe_success: true,
763 error: None,
764 };
765
766 let result = classify_model(
767 "claude-opus-4-7",
768 "Anthropic",
769 &installed(&["opencode"]),
770 Some(&probe),
771 None,
772 false,
773 );
774
775 assert_eq!(result.status, AvailabilityStatus::Unavailable);
776 assert_eq!(result.source, AvailabilitySource::OpenCodeProbeNegative);
777 assert!(result.runnable_paths.is_empty());
778 }
779
780 #[test]
781 fn test_classify_opencode_no_matching_slug() {
782 let probe = OpenCodeProbeResult {
783 model_slugs: vec!["anthropic/claude-3-5-sonnet".to_string()],
784 model_probe_success: true,
785 error: None,
786 };
787
788 let result = classify_model(
789 "claude-opus-4-7",
790 "Anthropic",
791 &installed(&["opencode"]),
792 Some(&probe),
793 None,
794 false,
795 );
796
797 assert_eq!(result.status, AvailabilityStatus::Unavailable);
798 assert_eq!(result.source, AvailabilitySource::OpenCodeProbeNegative);
799 assert!(result.runnable_paths.is_empty());
800 }
801
802 #[test]
803 fn test_classify_opencode_unknown_when_model_probe_fails() {
804 let probe = OpenCodeProbeResult {
805 model_probe_success: false,
806 error: Some("model probe failed: timeout".to_string()),
807 ..OpenCodeProbeResult::default()
808 };
809
810 let result = classify_model(
811 "claude-opus-4-7",
812 "Anthropic",
813 &installed(&["opencode"]),
814 Some(&probe),
815 None,
816 false,
817 );
818
819 assert_eq!(result.status, AvailabilityStatus::Unknown);
820 assert_eq!(result.source, AvailabilitySource::OpenCodeProbeUnknown);
821 assert!(result.runnable_paths.is_empty());
822 }
823
824 #[test]
825 fn test_resolve_runnable_path_prefers_cached_probe_slug() {
826 let probe = OpenCodeProbeResult {
827 model_slugs: vec!["openai/gpt-5.4".to_string()],
828 model_probe_success: true,
829 error: None,
830 };
831
832 let resolved = resolve_runnable_path("gpt-5.4", "OpenAI", "opencode", Some(&probe));
833 assert_eq!(resolved.harness_model_id, "openai/gpt-5.4");
834 assert_eq!(resolved.source, RunnablePathSource::CachedProbe);
835 assert_eq!(resolved.confidence, RunnableConfidence::Confirmed);
836 }
837
838 #[test]
839 fn test_resolve_runnable_path_falls_back_to_passthrough_without_slug_match() {
840 let probe = OpenCodeProbeResult {
841 model_slugs: vec!["openrouter/anthropic/claude-sonnet-4-7".to_string()],
842 model_probe_success: true,
843 error: None,
844 };
845
846 let resolved =
847 resolve_runnable_path("claude-opus-4-7", "Anthropic", "opencode", Some(&probe));
848 assert_eq!(resolved.harness_model_id, "claude-opus-4-7");
849 assert_eq!(resolved.source, RunnablePathSource::Passthrough);
850 assert_eq!(resolved.confidence, RunnableConfidence::Unknown);
851 }
852
853 #[test]
854 fn test_classify_opencode_unknown_when_probe_fails() {
855 let probe = OpenCodeProbeResult {
856 error: Some("model probe failed: timeout".to_string()),
857 ..OpenCodeProbeResult::default()
858 };
859
860 let result = classify_model(
861 "gpt-5.4",
862 "OpenAI",
863 &installed(&["opencode"]),
864 Some(&probe),
865 None,
866 false,
867 );
868
869 assert_eq!(result.status, AvailabilityStatus::Unknown);
870 assert_eq!(result.source, AvailabilitySource::OpenCodeProbeUnknown);
871 assert!(result.runnable_paths.is_empty());
872 }
873
874 #[test]
875 fn test_classify_opencode_unknown_provider_stays_unknown() {
876 let probe = OpenCodeProbeResult {
877 model_slugs: vec!["openai/gpt-5.4".to_string()],
878 model_probe_success: true,
879 ..OpenCodeProbeResult::default()
880 };
881
882 let result = classify_model(
883 "mystery-model",
884 "unknown",
885 &installed(&["opencode"]),
886 Some(&probe),
887 None,
888 false,
889 );
890
891 assert_eq!(result.status, AvailabilityStatus::Unknown);
892 assert_eq!(result.source, AvailabilitySource::OpenCodeProbeUnknown);
893 assert!(result.runnable_paths.is_empty());
894 }
895}