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