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