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    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
118/// Classify availability for a model through a specific harness.
119pub 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}