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