Skip to main content

mars_agents/models/
availability.rs

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