Skip to main content

mars_agents/routing/
mod.rs

1use std::collections::HashSet;
2
3pub mod acceptance;
4pub mod evidence;
5pub mod probe_match;
6pub mod report;
7pub mod slug;
8
9pub(crate) use probe_match::{SlugSelection, select_probe_slug};
10
11use crate::models;
12use crate::models::harness::HarnessOrderFailure;
13use crate::models::probes::CursorProbeResult;
14use crate::models::probes::OpenCodeProbeResult;
15use crate::models::probes::PiProbeResult;
16
17pub use evidence::{RoutingEvidence, RoutingSettingsEvidence};
18
19/// How the harness was selected — orthogonal to slug evidence.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum SelectionKind {
22    Auto,
23    Fixed,
24    ConfigDefault,
25    LinkedFallback,
26    HardcodedDefault,
27}
28
29impl SelectionKind {
30    pub fn label(self) -> &'static str {
31        match self {
32            Self::Auto => "auto",
33            Self::Fixed => "fixed",
34            Self::ConfigDefault => "config_default",
35            Self::LinkedFallback => "linked_fallback",
36            Self::HardcodedDefault => "hardcoded_default",
37        }
38    }
39}
40
41/// Slug evidence the evaluator found for this harness.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum MatchEvidence {
44    Confirmed,
45    Constrained,
46    Passthrough,
47    None,
48}
49
50impl MatchEvidence {
51    pub fn label(self) -> &'static str {
52        match self {
53            Self::Confirmed => "confirmed",
54            Self::Constrained => "constrained",
55            Self::Passthrough => "passthrough",
56            Self::None => "none",
57        }
58    }
59}
60
61/// How the harness was selected.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum RouteSource {
64    Cli,
65    Profile,
66    Alias,
67    ConfigOrder,
68    ConfigDefault,
69    Provider,
70    HardcodedDefault,
71}
72
73impl RouteSource {
74    pub fn label(self) -> &'static str {
75        match self {
76            Self::Cli => "cli",
77            Self::Profile => "profile",
78            Self::Alias => "alias",
79            Self::ConfigOrder => "config-order",
80            Self::ConfigDefault => "config",
81            Self::Provider => "provider",
82            Self::HardcodedDefault => "default",
83        }
84    }
85}
86
87/// Assessment of one candidate harness.
88#[derive(Debug, Clone)]
89pub struct CandidateAssessment {
90    pub harness: String,
91    pub installed: bool,
92    pub candidate_slugs: Vec<String>,
93    pub filtered_slugs: Vec<String>,
94    pub chosen_slug: Option<String>,
95    pub chosen_model: Option<String>,
96    pub match_evidence: Option<MatchEvidence>,
97    pub skip_reason: Option<&'static str>,
98}
99
100/// Full routing trace for diagnostics/provenance.
101#[derive(Debug, Clone)]
102pub struct RoutingTrace {
103    pub source: RouteSource,
104    pub selection_kind: SelectionKind,
105    pub match_evidence: MatchEvidence,
106    pub harness: String,
107    pub harness_order_position: Option<usize>,
108    pub candidates_tried: Vec<String>,
109    pub assessments: Vec<CandidateAssessment>,
110    pub diagnostics: Vec<String>,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct SelectedChosenSlugEvidence {
115    pub slug: String,
116    pub match_evidence: Option<MatchEvidence>,
117}
118
119impl RoutingTrace {
120    pub fn selected_harness(&self) -> &str {
121        &self.harness
122    }
123
124    pub fn selected_selection_kind(&self) -> SelectionKind {
125        self.selection_kind
126    }
127
128    pub fn selected_match_evidence(&self) -> MatchEvidence {
129        self.match_evidence
130    }
131
132    pub fn selected_diagnostics(&self) -> &[String] {
133        &self.diagnostics
134    }
135
136    pub fn selected_harness_order_position(&self) -> Option<usize> {
137        self.harness_order_position
138    }
139
140    pub fn selected_chosen_slug_evidence(&self) -> Option<SelectedChosenSlugEvidence> {
141        self.assessments
142            .iter()
143            .find(|assessment| assessment.harness == self.harness)
144            .and_then(|assessment| {
145                assessment
146                    .chosen_slug
147                    .as_ref()
148                    .map(|slug| SelectedChosenSlugEvidence {
149                        slug: slug.clone(),
150                        match_evidence: assessment.match_evidence,
151                    })
152            })
153    }
154
155    pub fn to_report(&self) -> report::RouteDecisionReport {
156        report::RouteDecisionReport::from_trace(self)
157    }
158}
159
160/// Input to the routing engine.
161pub struct RoutingInput<'a> {
162    pub model_id: &'a str,
163    pub provider_for_order: Option<&'a str>,
164    pub provider_constraint: Option<&'a str>,
165    pub settings_provider_order: Option<&'a [String]>,
166    pub settings_harness_order: Option<&'a [String]>,
167    pub config_default_harness: Option<&'a str>,
168    pub installed_harnesses: &'a HashSet<String>,
169    pub linked_harnesses: Option<&'a [String]>,
170    pub opencode_probe_result: Option<&'a OpenCodeProbeResult>,
171    pub pi_probe_result: Option<&'a PiProbeResult>,
172    pub cursor_probe_result: Option<&'a CursorProbeResult>,
173    /// Cached catalog slugs (`provider/model`) for full model-id matching on native harnesses.
174    pub catalog_model_slugs: Option<&'a [String]>,
175}
176
177pub trait ProbeResolver {
178    fn opencode_probe_result(&mut self) -> Option<OpenCodeProbeResult>;
179    fn pi_probe_result(&mut self) -> Option<PiProbeResult>;
180    fn cursor_probe_result(&mut self) -> Option<CursorProbeResult>;
181}
182
183#[derive(Debug, Default)]
184struct StaticProbeResolver {
185    opencode_probe_result: Option<OpenCodeProbeResult>,
186    pi_probe_result: Option<PiProbeResult>,
187    cursor_probe_result: Option<CursorProbeResult>,
188}
189
190impl StaticProbeResolver {
191    fn from_input(input: &RoutingInput<'_>) -> Self {
192        Self {
193            opencode_probe_result: input.opencode_probe_result.cloned(),
194            pi_probe_result: input.pi_probe_result.cloned(),
195            cursor_probe_result: input.cursor_probe_result.cloned(),
196        }
197    }
198}
199
200impl ProbeResolver for StaticProbeResolver {
201    fn opencode_probe_result(&mut self) -> Option<OpenCodeProbeResult> {
202        self.opencode_probe_result.clone()
203    }
204
205    fn pi_probe_result(&mut self) -> Option<PiProbeResult> {
206        self.pi_probe_result.clone()
207    }
208
209    fn cursor_probe_result(&mut self) -> Option<CursorProbeResult> {
210        self.cursor_probe_result.clone()
211    }
212}
213
214/// Evaluate all candidates and return a routing trace.
215/// This is the ONLY candidate evaluator. Both `mars models` and `mars build` call this.
216pub fn evaluate_candidates(input: &RoutingInput<'_>) -> RoutingTrace {
217    let mut probe_resolver = StaticProbeResolver::from_input(input);
218    evaluate_candidates_with_auth_and_probes(
219        input,
220        &mut probe_resolver,
221        models::harness::native_harness_authenticated,
222    )
223}
224
225/// Evaluate one fixed harness choice without fallback.
226/// Used by fixed-selection precedence paths (CLI/profile/alias).
227pub fn evaluate_fixed_harness(input: &RoutingInput<'_>, harness: &str) -> CandidateAssessment {
228    let mut probe_resolver = StaticProbeResolver::from_input(input);
229    evaluate_fixed_harness_with_auth_and_probes(
230        input,
231        harness,
232        &mut probe_resolver,
233        models::harness::native_harness_authenticated,
234    )
235}
236
237pub fn evaluate_fixed_harness_with_auth<F>(
238    input: &RoutingInput<'_>,
239    harness: &str,
240    auth_check: F,
241) -> CandidateAssessment
242where
243    F: Fn(&str) -> bool,
244{
245    let mut probe_resolver = StaticProbeResolver::from_input(input);
246    evaluate_fixed_harness_with_auth_and_probes(input, harness, &mut probe_resolver, auth_check)
247}
248
249pub fn evaluate_fixed_harness_with_auth_and_probes<F, P>(
250    input: &RoutingInput<'_>,
251    harness: &str,
252    probe_resolver: &mut P,
253    auth_check: F,
254) -> CandidateAssessment
255where
256    F: Fn(&str) -> bool,
257    P: ProbeResolver + ?Sized,
258{
259    candidate_match_evidence_with_auth(
260        input,
261        harness,
262        input.settings_provider_order,
263        probe_resolver,
264        &auth_check,
265    )
266}
267
268/// Build a fixed-selection routing trace from one fixed harness assessment.
269pub fn trace_for_fixed_harness(
270    source: RouteSource,
271    harness: &str,
272    assessment: CandidateAssessment,
273    diagnostics: Vec<String>,
274) -> RoutingTrace {
275    let match_evidence = assessment.match_evidence.unwrap_or(MatchEvidence::None);
276    RoutingTrace {
277        source,
278        selection_kind: SelectionKind::Fixed,
279        match_evidence,
280        harness: harness.to_string(),
281        harness_order_position: None,
282        candidates_tried: vec![harness.to_string()],
283        assessments: vec![assessment],
284        diagnostics,
285    }
286}
287
288pub fn provider_for_order_for_fixed_harness<'a>(
289    provider_for_order: Option<&'a str>,
290    harness: &str,
291) -> Option<&'a str> {
292    let has_explicit_provider = provider_for_order.is_some_and(|provider| {
293        let normalized = provider.trim();
294        !normalized.is_empty() && !normalized.eq_ignore_ascii_case("unknown")
295    });
296    if has_explicit_provider {
297        return provider_for_order;
298    }
299
300    native_provider_for_harness(harness).or(provider_for_order)
301}
302
303pub fn evaluate_candidates_with_auth<F>(input: &RoutingInput<'_>, auth_check: F) -> RoutingTrace
304where
305    F: Fn(&str) -> bool,
306{
307    let mut probe_resolver = StaticProbeResolver::from_input(input);
308    evaluate_candidates_with_auth_and_probes(input, &mut probe_resolver, auth_check)
309}
310
311pub fn evaluate_candidates_with_auth_and_probes<F, P>(
312    input: &RoutingInput<'_>,
313    probe_resolver: &mut P,
314    auth_check: F,
315) -> RoutingTrace
316where
317    F: Fn(&str) -> bool,
318    P: ProbeResolver + ?Sized,
319{
320    let mut diagnostics = Vec::new();
321    let parsed_provider_order =
322        parse_settings_provider_order(input.settings_provider_order, &mut diagnostics);
323    let config_default_harness =
324        normalize_config_default_harness(input.config_default_harness, &mut diagnostics);
325    let linked_harnesses = input
326        .linked_harnesses
327        .filter(|harnesses| !harnesses.is_empty());
328    let linked_harnesses_set = linked_harnesses
329        .map(|harnesses| harnesses.iter().map(String::as_str).collect::<HashSet<_>>());
330    let has_link_constraints = linked_harnesses_set.is_some();
331    let effective_config_default_harness = config_default_harness
332        .as_ref()
333        .filter(|harness| {
334            linked_harnesses_set
335                .as_ref()
336                .is_none_or(|known| known.contains(harness.as_str()))
337        })
338        .cloned();
339    if has_link_constraints
340        && config_default_harness.is_some()
341        && effective_config_default_harness.is_none()
342    {
343        diagnostics.push(
344            "settings.default_harness is excluded by known linked harness constraints; ignoring fallback"
345                .to_string(),
346        );
347    }
348
349    let mut harness_order_failure = None;
350
351    let mut candidate_source = RouteSource::Provider;
352
353    let candidates = if let Some(order) = input.settings_harness_order {
354        let parsed_order = models::harness::parse_settings_harness_order(order);
355        diagnostics.extend(parsed_order.warnings);
356
357        if parsed_order.failure == Some(HarnessOrderFailure::Empty) {
358            diagnostics.push(
359                "settings.harness_order is empty; falling through to provider candidate order"
360                    .to_string(),
361            );
362            let provider_for_order = input.provider_for_order.unwrap_or("unknown");
363            filter_candidates_by_links(
364                models::harness::harness_candidates_for_provider(provider_for_order),
365                linked_harnesses_set.as_ref(),
366            )
367            .into_iter()
368            .map(|harness| (harness, None))
369            .collect::<Vec<_>>()
370        } else {
371            candidate_source = RouteSource::ConfigOrder;
372            let mut candidate_pairs = parsed_order
373                .valid_candidates
374                .into_iter()
375                .enumerate()
376                .map(|(index, harness)| (harness, Some(index)))
377                .collect::<Vec<_>>();
378
379            filter_candidate_pairs_by_links(&mut candidate_pairs, linked_harnesses_set.as_ref());
380
381            let valid_candidates = candidate_pairs
382                .iter()
383                .map(|(harness, _)| harness.clone())
384                .collect::<Vec<_>>();
385
386            if !valid_candidates.is_empty()
387                && valid_candidates
388                    .iter()
389                    .all(|candidate| !input.installed_harnesses.contains(candidate))
390            {
391                harness_order_failure = Some(HarnessOrderFailure::NoneInstalled {
392                    valid_candidates: valid_candidates.clone(),
393                });
394            }
395
396            candidate_pairs
397        }
398    } else if input.model_id.trim().is_empty() {
399        filter_candidates_by_links(
400            models::harness::VALID_HARNESSES
401                .iter()
402                .map(|harness| (*harness).to_string())
403                .collect(),
404            linked_harnesses_set.as_ref(),
405        )
406        .into_iter()
407        .map(|harness| (harness, None))
408        .collect::<Vec<_>>()
409    } else {
410        let provider_for_order = input.provider_for_order.unwrap_or("unknown");
411        filter_candidates_by_links(
412            models::harness::harness_candidates_for_provider(provider_for_order),
413            linked_harnesses_set.as_ref(),
414        )
415        .into_iter()
416        .map(|harness| (harness, None))
417        .collect::<Vec<_>>()
418    };
419
420    let mut candidates_tried = Vec::new();
421    let mut assessments = Vec::new();
422    let mut passthrough_selection: Option<(String, Option<usize>, MatchEvidence)> = None;
423
424    for (harness, harness_order_position) in candidates {
425        let assessment = candidate_match_evidence_with_auth(
426            input,
427            &harness,
428            Some(parsed_provider_order.as_slice()),
429            probe_resolver,
430            &auth_check,
431        );
432
433        candidates_tried.push(harness.clone());
434        let match_evidence = assessment.match_evidence;
435        assessments.push(assessment);
436
437        if let Some(match_evidence) = match_evidence {
438            match match_evidence {
439                MatchEvidence::Confirmed | MatchEvidence::Constrained => {
440                    return RoutingTrace {
441                        source: candidate_source,
442                        selection_kind: SelectionKind::Auto,
443                        match_evidence,
444                        harness,
445                        harness_order_position,
446                        candidates_tried,
447                        assessments,
448                        diagnostics,
449                    };
450                }
451                MatchEvidence::Passthrough => {
452                    if passthrough_selection.is_none() {
453                        passthrough_selection =
454                            Some((harness, harness_order_position, match_evidence));
455                    }
456                }
457                MatchEvidence::None => {}
458            }
459        }
460    }
461
462    if let Some((harness, harness_order_position, match_evidence)) = passthrough_selection {
463        return RoutingTrace {
464            source: candidate_source,
465            selection_kind: SelectionKind::Auto,
466            match_evidence,
467            harness,
468            harness_order_position,
469            candidates_tried,
470            assessments,
471            diagnostics,
472        };
473    }
474
475    if input.settings_harness_order.is_some()
476        && let Some(warning) = format_harness_order_fallback_warning(
477            harness_order_failure.as_ref(),
478            effective_config_default_harness.is_some(),
479            has_link_constraints,
480        )
481    {
482        diagnostics.push(warning);
483    }
484
485    if let Some(harness) = effective_config_default_harness {
486        return RoutingTrace {
487            source: RouteSource::ConfigDefault,
488            selection_kind: SelectionKind::ConfigDefault,
489            match_evidence: MatchEvidence::Passthrough,
490            harness,
491            harness_order_position: None,
492            candidates_tried,
493            assessments,
494            diagnostics,
495        };
496    }
497
498    if let Some(known_links) = linked_harnesses {
499        if let Some(harness) = select_linked_fallback_harness(input, known_links, &assessments) {
500            diagnostics.push(format!(
501                "known linked harness constraints left no eligible auto-routing candidates; selecting linked harness `{harness}` in harness order (skipped incompatible candidates)"
502            ));
503            candidates_tried.push(harness.clone());
504
505            return RoutingTrace {
506                source: candidate_source,
507                selection_kind: SelectionKind::LinkedFallback,
508                match_evidence: MatchEvidence::Passthrough,
509                harness,
510                harness_order_position: None,
511                candidates_tried,
512                assessments,
513                diagnostics,
514            };
515        }
516
517        diagnostics.push(
518            "known linked harness constraints left no linked harness eligible for this model after routing assessments"
519                .to_string(),
520        );
521    }
522
523    diagnostics
524        .push("harness not set by CLI/profile/alias/provider/config; defaulting to `pi`".into());
525
526    RoutingTrace {
527        source: RouteSource::HardcodedDefault,
528        selection_kind: SelectionKind::HardcodedDefault,
529        match_evidence: MatchEvidence::Passthrough,
530        harness: "pi".to_string(),
531        harness_order_position: None,
532        candidates_tried,
533        assessments,
534        diagnostics,
535    }
536}
537
538/// Normalize and validate config default_harness. Returns normalized name or None with warning.
539pub fn normalize_config_default_harness(
540    config_default_harness: Option<&str>,
541    warnings: &mut Vec<String>,
542) -> Option<String> {
543    match config_default_harness {
544        Some(value) => match models::harness::normalize_harness_name(value) {
545            Some(valid) => Some(valid),
546            None => {
547                warnings.push(format!(
548                    "settings.default_harness `{value}` is invalid; expected one of: {}",
549                    models::harness::VALID_HARNESSES.join(", ")
550                ));
551                None
552            }
553        },
554        None => None,
555    }
556}
557
558fn filter_candidate_pairs_by_links(
559    candidates: &mut Vec<(String, Option<usize>)>,
560    linked_harnesses: Option<&HashSet<&str>>,
561) {
562    if let Some(linked_harnesses) = linked_harnesses {
563        candidates.retain(|(harness, _)| linked_harnesses.contains(harness.as_str()));
564    }
565}
566
567fn filter_candidates_by_links(
568    candidates: Vec<String>,
569    linked_harnesses: Option<&HashSet<&str>>,
570) -> Vec<String> {
571    let Some(linked_harnesses) = linked_harnesses else {
572        return candidates;
573    };
574
575    candidates
576        .into_iter()
577        .filter(|harness| linked_harnesses.contains(harness.as_str()))
578        .collect()
579}
580
581fn candidate_match_evidence_with_auth<F, P>(
582    input: &RoutingInput<'_>,
583    harness: &str,
584    provider_order: Option<&[String]>,
585    probe_resolver: &mut P,
586    auth_check: &F,
587) -> CandidateAssessment
588where
589    F: Fn(&str) -> bool,
590    P: ProbeResolver + ?Sized,
591{
592    if !input.installed_harnesses.contains(harness) {
593        return CandidateAssessment {
594            harness: harness.to_string(),
595            installed: false,
596            candidate_slugs: Vec::new(),
597            filtered_slugs: Vec::new(),
598            chosen_slug: None,
599            chosen_model: None,
600            match_evidence: None,
601            skip_reason: Some("not_installed"),
602        };
603    }
604
605    if is_native_harness(harness)
606        && provider_constraint_excludes_native_harness(input.provider_constraint, harness)
607    {
608        return CandidateAssessment {
609            harness: harness.to_string(),
610            installed: true,
611            candidate_slugs: Vec::new(),
612            filtered_slugs: Vec::new(),
613            chosen_slug: None,
614            chosen_model: None,
615            match_evidence: None,
616            skip_reason: Some("provider_constraint_unsatisfied"),
617        };
618    }
619
620    if input.model_id.trim().is_empty() {
621        return CandidateAssessment {
622            harness: harness.to_string(),
623            installed: true,
624            candidate_slugs: Vec::new(),
625            filtered_slugs: Vec::new(),
626            chosen_slug: None,
627            chosen_model: None,
628            match_evidence: Some(MatchEvidence::Passthrough),
629            skip_reason: None,
630        };
631    }
632
633    if is_native_harness(harness) {
634        let native_slugs = catalog_slugs_for_native_harness(harness, input.catalog_model_slugs);
635        if !native_slugs.is_empty() {
636            let selection = select_probe_slug(
637                input.model_id,
638                input.provider_constraint,
639                effective_provider_for_order(input).as_deref(),
640                provider_order,
641                native_slugs,
642            );
643            return assessment_from_slug_selection(
644                harness,
645                selection,
646                input.provider_constraint,
647                true,
648                &auth_check,
649            );
650        }
651
652        if is_native_match(effective_provider_for_order(input).as_deref(), harness) {
653            if auth_check(harness) {
654                return CandidateAssessment {
655                    harness: harness.to_string(),
656                    installed: true,
657                    candidate_slugs: Vec::new(),
658                    filtered_slugs: Vec::new(),
659                    chosen_slug: None,
660                    chosen_model: Some(input.model_id.to_string()),
661                    match_evidence: Some(match_evidence_for_match(input.provider_constraint)),
662                    skip_reason: None,
663                };
664            }
665
666            return CandidateAssessment {
667                harness: harness.to_string(),
668                installed: true,
669                candidate_slugs: Vec::new(),
670                filtered_slugs: Vec::new(),
671                chosen_slug: None,
672                chosen_model: None,
673                match_evidence: None,
674                skip_reason: Some("native_auth_unavailable"),
675            };
676        }
677
678        return CandidateAssessment {
679            harness: harness.to_string(),
680            installed: true,
681            candidate_slugs: Vec::new(),
682            filtered_slugs: Vec::new(),
683            chosen_slug: None,
684            chosen_model: None,
685            match_evidence: None,
686            skip_reason: Some("no_model_match"),
687        };
688    }
689
690    if harness == "opencode" {
691        let Some(opencode_probe) = probe_resolver.opencode_probe_result() else {
692            return CandidateAssessment {
693                harness: harness.to_string(),
694                installed: true,
695                candidate_slugs: Vec::new(),
696                filtered_slugs: Vec::new(),
697                chosen_slug: None,
698                chosen_model: None,
699                match_evidence: Some(MatchEvidence::Passthrough),
700                skip_reason: None,
701            };
702        };
703        if !opencode_probe.model_probe_success {
704            return CandidateAssessment {
705                harness: harness.to_string(),
706                installed: true,
707                candidate_slugs: Vec::new(),
708                filtered_slugs: Vec::new(),
709                chosen_slug: None,
710                chosen_model: None,
711                match_evidence: Some(MatchEvidence::Passthrough),
712                skip_reason: None,
713            };
714        }
715
716        let selection = select_probe_slug(
717            input.model_id,
718            input.provider_constraint,
719            input.provider_for_order,
720            provider_order,
721            opencode_probe.model_slugs.iter().map(String::as_str),
722        );
723
724        if let Some(chosen_slug) = selection.chosen_slug.clone() {
725            return CandidateAssessment {
726                harness: harness.to_string(),
727                installed: true,
728                candidate_slugs: selection.candidate_slugs,
729                filtered_slugs: selection.filtered_slugs,
730                chosen_model: slug::parse(&chosen_slug).map(|parts| parts.model_id.to_string()),
731                chosen_slug: Some(chosen_slug),
732                match_evidence: Some(match_evidence_for_match(input.provider_constraint)),
733                skip_reason: None,
734            };
735        }
736
737        if !selection.candidate_slugs.is_empty() {
738            return CandidateAssessment {
739                harness: harness.to_string(),
740                installed: true,
741                candidate_slugs: selection.candidate_slugs,
742                filtered_slugs: selection.filtered_slugs,
743                chosen_slug: None,
744                chosen_model: None,
745                match_evidence: None,
746                skip_reason: Some("provider_constraint_unsatisfied"),
747            };
748        }
749
750        return CandidateAssessment {
751            harness: harness.to_string(),
752            installed: true,
753            candidate_slugs: selection.candidate_slugs,
754            filtered_slugs: selection.filtered_slugs,
755            chosen_slug: None,
756            chosen_model: None,
757            match_evidence: None,
758            skip_reason: Some("no_model_match"),
759        };
760    }
761
762    if harness == "pi" {
763        if let Some(pi_probe) = probe_resolver.pi_probe_result() {
764            if pi_probe.compatible {
765                let selection = select_probe_slug(
766                    input.model_id,
767                    input.provider_constraint,
768                    input.provider_for_order,
769                    provider_order,
770                    pi_probe.model_slugs.iter().map(String::as_str),
771                );
772
773                if let Some(chosen_slug) = selection.chosen_slug.clone() {
774                    return CandidateAssessment {
775                        harness: harness.to_string(),
776                        installed: true,
777                        candidate_slugs: selection.candidate_slugs,
778                        filtered_slugs: selection.filtered_slugs,
779                        chosen_model: slug::parse(&chosen_slug)
780                            .map(|parts| parts.model_id.to_string()),
781                        chosen_slug: Some(chosen_slug),
782                        match_evidence: Some(match_evidence_for_match(input.provider_constraint)),
783                        skip_reason: None,
784                    };
785                }
786
787                if !selection.candidate_slugs.is_empty() {
788                    return CandidateAssessment {
789                        harness: harness.to_string(),
790                        installed: true,
791                        candidate_slugs: selection.candidate_slugs,
792                        filtered_slugs: selection.filtered_slugs,
793                        chosen_slug: None,
794                        chosen_model: None,
795                        match_evidence: None,
796                        skip_reason: Some("provider_constraint_unsatisfied"),
797                    };
798                }
799
800                return CandidateAssessment {
801                    harness: harness.to_string(),
802                    installed: true,
803                    candidate_slugs: selection.candidate_slugs,
804                    filtered_slugs: selection.filtered_slugs,
805                    chosen_slug: None,
806                    chosen_model: None,
807                    match_evidence: None,
808                    skip_reason: Some("no_model_match"),
809                };
810            }
811            return CandidateAssessment {
812                harness: harness.to_string(),
813                installed: true,
814                candidate_slugs: Vec::new(),
815                filtered_slugs: Vec::new(),
816                chosen_slug: None,
817                chosen_model: None,
818                match_evidence: None,
819                skip_reason: Some("pi_incompatible"),
820            };
821        }
822
823        return CandidateAssessment {
824            harness: harness.to_string(),
825            installed: true,
826            candidate_slugs: Vec::new(),
827            filtered_slugs: Vec::new(),
828            chosen_slug: None,
829            chosen_model: None,
830            match_evidence: Some(MatchEvidence::Passthrough),
831            skip_reason: None,
832        };
833    }
834
835    if harness == "cursor" {
836        let Some(cursor_probe) = probe_resolver.cursor_probe_result() else {
837            return passthrough_assessment(harness);
838        };
839        if !cursor_probe.model_probe_success {
840            return passthrough_assessment(harness);
841        }
842        if cursor_probe.slugs.is_empty() {
843            return passthrough_assessment(harness);
844        }
845
846        let normalized_model = crate::models::probes::cursor::normalize_slug(input.model_id);
847        if cursor_probe
848            .slugs
849            .iter()
850            .any(|slug| crate::models::probes::cursor::normalize_slug(slug) == normalized_model)
851        {
852            return CandidateAssessment {
853                harness: harness.to_string(),
854                installed: true,
855                candidate_slugs: vec![input.model_id.to_string()],
856                filtered_slugs: vec![input.model_id.to_string()],
857                chosen_slug: Some(input.model_id.to_string()),
858                chosen_model: Some(input.model_id.to_string()),
859                match_evidence: Some(MatchEvidence::Confirmed),
860                skip_reason: None,
861            };
862        }
863
864        let matches = crate::models::probes::cursor::find_cursor_prefix_matches(
865            input.model_id,
866            &cursor_probe.slugs,
867        );
868        if !matches.is_empty() {
869            let candidate_slugs: Vec<String> =
870                matches.iter().map(|slug| (*slug).to_string()).collect();
871            return CandidateAssessment {
872                harness: harness.to_string(),
873                installed: true,
874                candidate_slugs: candidate_slugs.clone(),
875                filtered_slugs: candidate_slugs,
876                chosen_slug: Some(input.model_id.to_string()),
877                chosen_model: Some(input.model_id.to_string()),
878                match_evidence: Some(MatchEvidence::Confirmed),
879                skip_reason: None,
880            };
881        }
882
883        return CandidateAssessment {
884            harness: harness.to_string(),
885            installed: true,
886            candidate_slugs: Vec::new(),
887            filtered_slugs: Vec::new(),
888            chosen_slug: None,
889            chosen_model: None,
890            match_evidence: None,
891            skip_reason: Some("no_model_match"),
892        };
893    }
894
895    CandidateAssessment {
896        harness: harness.to_string(),
897        installed: true,
898        candidate_slugs: Vec::new(),
899        filtered_slugs: Vec::new(),
900        chosen_slug: None,
901        chosen_model: None,
902        match_evidence: None,
903        skip_reason: Some("unsupported_candidate"),
904    }
905}
906
907fn passthrough_assessment(harness: &str) -> CandidateAssessment {
908    CandidateAssessment {
909        harness: harness.to_string(),
910        installed: true,
911        candidate_slugs: Vec::new(),
912        filtered_slugs: Vec::new(),
913        chosen_slug: None,
914        chosen_model: None,
915        match_evidence: Some(MatchEvidence::Passthrough),
916        skip_reason: None,
917    }
918}
919
920fn native_provider_for_harness(harness: &str) -> Option<&'static str> {
921    match harness {
922        "claude" => Some("anthropic"),
923        "codex" => Some("openai"),
924        _ => None,
925    }
926}
927
928fn is_native_match(provider: Option<&str>, harness: &str) -> bool {
929    provider
930        .map(|provider| slug::provider_matches_native_harness(provider, harness))
931        .unwrap_or(false)
932}
933
934fn is_native_harness(harness: &str) -> bool {
935    matches!(harness, "claude" | "codex")
936}
937
938fn provider_constraint_excludes_native_harness(
939    provider_constraint: Option<&str>,
940    harness: &str,
941) -> bool {
942    let Some(provider_constraint) = provider_constraint else {
943        return false;
944    };
945
946    !slug::provider_matches_native_harness(provider_constraint, harness)
947}
948
949fn match_evidence_for_match(provider_constraint: Option<&str>) -> MatchEvidence {
950    if provider_constraint.is_some() {
951        MatchEvidence::Constrained
952    } else {
953        MatchEvidence::Confirmed
954    }
955}
956
957fn parse_settings_provider_order(
958    provider_order: Option<&[String]>,
959    diagnostics: &mut Vec<String>,
960) -> Vec<String> {
961    let Some(provider_order) = provider_order else {
962        return Vec::new();
963    };
964
965    provider_order
966        .iter()
967        .filter_map(|provider| {
968            let normalized = provider.trim().to_ascii_lowercase();
969            if normalized.is_empty() {
970                return None;
971            }
972            if !is_known_provider_or_variant(&normalized) {
973                diagnostics.push(format!(
974                    "settings.provider_order contains unknown provider `{provider}`; keeping it for forward-compat routing preferences"
975                ));
976            }
977            Some(normalized)
978        })
979        .collect()
980}
981
982fn is_known_provider_or_variant(provider: &str) -> bool {
983    matches!(
984        provider,
985        "anthropic"
986            | "openai"
987            | "google"
988            | "meta"
989            | "mistral"
990            | "deepseek"
991            | "cohere"
992            | "openrouter"
993            | "openai-codex"
994            | "anthropic-claude"
995    )
996}
997
998fn effective_provider_for_order(input: &RoutingInput<'_>) -> Option<String> {
999    input
1000        .provider_for_order
1001        .map(str::trim)
1002        .filter(|provider| !provider.is_empty() && !provider.eq_ignore_ascii_case("unknown"))
1003        .map(str::to_string)
1004        .or_else(|| models::infer_provider_from_model_id(input.model_id).map(str::to_string))
1005}
1006
1007fn catalog_slugs_for_native_harness<'a>(
1008    harness: &str,
1009    catalog_model_slugs: Option<&'a [String]>,
1010) -> Vec<&'a str> {
1011    let Some(slugs) = catalog_model_slugs else {
1012        return Vec::new();
1013    };
1014    slugs
1015        .iter()
1016        .filter(|slug| {
1017            slug::parse(slug)
1018                .is_some_and(|parts| slug::provider_matches_native_harness(parts.provider, harness))
1019        })
1020        .map(String::as_str)
1021        .collect()
1022}
1023
1024fn assessment_from_slug_selection<F>(
1025    harness: &str,
1026    selection: SlugSelection,
1027    provider_constraint: Option<&str>,
1028    require_auth: bool,
1029    auth_check: &F,
1030) -> CandidateAssessment
1031where
1032    F: Fn(&str) -> bool,
1033{
1034    if let Some(chosen_slug) = selection.chosen_slug.clone() {
1035        if require_auth && !auth_check(harness) {
1036            return CandidateAssessment {
1037                harness: harness.to_string(),
1038                installed: true,
1039                candidate_slugs: selection.candidate_slugs,
1040                filtered_slugs: selection.filtered_slugs,
1041                chosen_slug: None,
1042                chosen_model: None,
1043                match_evidence: None,
1044                skip_reason: Some("native_auth_unavailable"),
1045            };
1046        }
1047        return CandidateAssessment {
1048            harness: harness.to_string(),
1049            installed: true,
1050            candidate_slugs: selection.candidate_slugs,
1051            filtered_slugs: selection.filtered_slugs,
1052            chosen_model: slug::parse(&chosen_slug).map(|parts| parts.model_id.to_string()),
1053            chosen_slug: Some(chosen_slug),
1054            match_evidence: Some(match_evidence_for_match(provider_constraint)),
1055            skip_reason: None,
1056        };
1057    }
1058
1059    if !selection.candidate_slugs.is_empty() {
1060        return CandidateAssessment {
1061            harness: harness.to_string(),
1062            installed: true,
1063            candidate_slugs: selection.candidate_slugs,
1064            filtered_slugs: selection.filtered_slugs,
1065            chosen_slug: None,
1066            chosen_model: None,
1067            match_evidence: None,
1068            skip_reason: Some("provider_constraint_unsatisfied"),
1069        };
1070    }
1071
1072    CandidateAssessment {
1073        harness: harness.to_string(),
1074        installed: true,
1075        candidate_slugs: selection.candidate_slugs,
1076        filtered_slugs: selection.filtered_slugs,
1077        chosen_slug: None,
1078        chosen_model: None,
1079        match_evidence: None,
1080        skip_reason: Some("no_model_match"),
1081    }
1082}
1083
1084fn is_hard_assessment_skip(skip_reason: Option<&str>) -> bool {
1085    matches!(
1086        skip_reason,
1087        Some(
1088            "pi_incompatible"
1089                | "no_model_match"
1090                | "unsupported_candidate"
1091                | "not_installed"
1092                | "provider_constraint_unsatisfied"
1093        )
1094    )
1095}
1096
1097fn select_linked_fallback_harness(
1098    input: &RoutingInput<'_>,
1099    linked_harnesses: &[String],
1100    assessments: &[CandidateAssessment],
1101) -> Option<String> {
1102    let linked_set: HashSet<&str> = linked_harnesses.iter().map(String::as_str).collect();
1103
1104    let walk_order: Vec<String> = input
1105        .settings_harness_order
1106        .map(|order| {
1107            order
1108                .iter()
1109                .filter(|harness| linked_set.contains(harness.as_str()))
1110                .cloned()
1111                .collect()
1112        })
1113        .unwrap_or_else(|| linked_harnesses.to_vec());
1114
1115    for harness in walk_order {
1116        let rejected = assessments
1117            .iter()
1118            .find(|assessment| assessment.harness == harness)
1119            .and_then(|assessment| assessment.skip_reason)
1120            .is_some_and(|reason| is_hard_assessment_skip(Some(reason)));
1121        if !rejected {
1122            return Some(harness);
1123        }
1124    }
1125
1126    None
1127}
1128
1129fn format_harness_order_fallback_warning(
1130    harness_order_failure: Option<&HarnessOrderFailure>,
1131    has_config_default_harness: bool,
1132    has_link_constraints: bool,
1133) -> Option<String> {
1134    let mut warning = match harness_order_failure {
1135        Some(HarnessOrderFailure::Empty) => "settings.harness_order is empty".to_string(),
1136        Some(HarnessOrderFailure::NoneInstalled { valid_candidates }) => format!(
1137            "settings.harness_order is set but none of [{}] are installed",
1138            valid_candidates.join(", ")
1139        ),
1140        None => return None,
1141    };
1142
1143    if has_config_default_harness {
1144        warning.push_str("; falling through to settings.default_harness");
1145    } else if has_link_constraints {
1146        warning.push_str("; linked harness constraints prevent unrelated fallback");
1147    } else {
1148        warning.push_str("; settings.default_harness is unset, falling through to hardcoded `pi`");
1149    }
1150
1151    Some(warning)
1152}
1153
1154#[cfg(test)]
1155mod tests {
1156    use super::*;
1157
1158    fn installed(names: &[&str]) -> HashSet<String> {
1159        names.iter().map(|name| (*name).to_string()).collect()
1160    }
1161
1162    fn always_authed(_: &str) -> bool {
1163        true
1164    }
1165
1166    fn never_authed(_: &str) -> bool {
1167        false
1168    }
1169
1170    type ProbeInputs<'a> = (
1171        Option<&'a OpenCodeProbeResult>,
1172        Option<&'a PiProbeResult>,
1173        Option<&'a CursorProbeResult>,
1174    );
1175
1176    fn routing_input<'a>(
1177        model_id: &'a str,
1178        provider_for_order: Option<&'a str>,
1179        settings_harness_order: Option<&'a [String]>,
1180        config_default_harness: Option<&'a str>,
1181        installed_harnesses: &'a HashSet<String>,
1182        linked_harnesses: Option<&'a [String]>,
1183        probe_inputs: ProbeInputs<'a>,
1184    ) -> RoutingInput<'a> {
1185        routing_input_with_catalog(
1186            model_id,
1187            provider_for_order,
1188            settings_harness_order,
1189            config_default_harness,
1190            installed_harnesses,
1191            linked_harnesses,
1192            None,
1193            probe_inputs,
1194        )
1195    }
1196
1197    #[allow(clippy::too_many_arguments)]
1198    fn routing_input_with_catalog<'a>(
1199        model_id: &'a str,
1200        provider_for_order: Option<&'a str>,
1201        settings_harness_order: Option<&'a [String]>,
1202        config_default_harness: Option<&'a str>,
1203        installed_harnesses: &'a HashSet<String>,
1204        linked_harnesses: Option<&'a [String]>,
1205        catalog_model_slugs: Option<&'a [String]>,
1206        probe_inputs: ProbeInputs<'a>,
1207    ) -> RoutingInput<'a> {
1208        let (opencode_probe_result, pi_probe_result, cursor_probe_result) = probe_inputs;
1209        RoutingInput {
1210            model_id,
1211            provider_for_order,
1212            provider_constraint: None,
1213            settings_provider_order: None,
1214            settings_harness_order,
1215            config_default_harness,
1216            installed_harnesses,
1217            linked_harnesses,
1218            opencode_probe_result,
1219            pi_probe_result,
1220            cursor_probe_result,
1221            catalog_model_slugs,
1222        }
1223    }
1224
1225    #[test]
1226    fn native_match_with_auth_returns_confirmed() {
1227        let installed = installed(&["claude"]);
1228        let input = routing_input(
1229            "claude-opus-4-7",
1230            Some("anthropic"),
1231            None,
1232            None,
1233            &installed,
1234            None,
1235            (None, None, None),
1236        );
1237
1238        let trace = evaluate_candidates_with_auth(&input, always_authed);
1239
1240        assert_eq!(trace.source, RouteSource::Provider);
1241        assert_eq!(trace.selection_kind, SelectionKind::Auto);
1242        assert_eq!(trace.harness, "claude");
1243        assert_eq!(trace.match_evidence, MatchEvidence::Confirmed);
1244        assert_eq!(trace.candidates_tried, vec!["claude".to_string()]);
1245    }
1246
1247    #[test]
1248    fn catalog_native_match_without_explicit_provider() {
1249        let installed = installed(&["claude", "pi"]);
1250        let catalog = vec!["anthropic/claude-opus-4-6".to_string()];
1251        let harness_order = vec!["claude".to_string(), "pi".to_string()];
1252        let input = routing_input_with_catalog(
1253            "claude-opus-4-6",
1254            None,
1255            Some(&harness_order),
1256            None,
1257            &installed,
1258            None,
1259            Some(&catalog),
1260            (None, None, None),
1261        );
1262
1263        let trace = evaluate_candidates_with_auth(&input, always_authed);
1264
1265        assert_eq!(trace.harness, "claude");
1266        assert_eq!(trace.selection_kind, SelectionKind::Auto);
1267        assert_eq!(trace.match_evidence, MatchEvidence::Confirmed);
1268        assert_eq!(
1269            trace
1270                .assessments
1271                .iter()
1272                .find(|assessment| assessment.harness == "claude")
1273                .and_then(|assessment| assessment.chosen_slug.as_deref()),
1274            Some("anthropic/claude-opus-4-6")
1275        );
1276    }
1277
1278    #[test]
1279    fn linked_fallback_skips_pi_incompatible() {
1280        let installed = installed(&["claude", "pi"]);
1281        let catalog = vec!["anthropic/claude-opus-4-6".to_string()];
1282        let harness_order = vec!["pi".to_string(), "claude".to_string()];
1283        let linked = vec!["pi".to_string(), "claude".to_string()];
1284        let pi_probe = PiProbeResult {
1285            compatible: false,
1286            model_slugs: HashSet::new(),
1287            ..PiProbeResult::default()
1288        };
1289        let input = routing_input_with_catalog(
1290            "claude-opus-4-6",
1291            None,
1292            Some(&harness_order),
1293            None,
1294            &installed,
1295            Some(&linked),
1296            Some(&catalog),
1297            (None, Some(&pi_probe), None),
1298        );
1299
1300        let trace = evaluate_candidates_with_auth(&input, never_authed);
1301
1302        assert_eq!(trace.harness, "claude");
1303        assert_eq!(trace.selection_kind, SelectionKind::LinkedFallback);
1304        assert!(
1305            trace
1306                .diagnostics
1307                .iter()
1308                .any(|diagnostic| diagnostic.contains("skipped incompatible candidates"))
1309        );
1310        assert_eq!(
1311            trace
1312                .assessments
1313                .iter()
1314                .find(|assessment| assessment.harness == "pi")
1315                .and_then(|assessment| assessment.skip_reason),
1316            Some("pi_incompatible")
1317        );
1318    }
1319
1320    #[test]
1321    fn native_match_without_auth_falls_through() {
1322        let installed = installed(&["claude", "pi"]);
1323        let input = routing_input(
1324            "claude-opus-4-7",
1325            Some("anthropic"),
1326            None,
1327            None,
1328            &installed,
1329            None,
1330            (None, None, None),
1331        );
1332
1333        let trace = evaluate_candidates_with_auth(&input, never_authed);
1334
1335        assert_eq!(trace.harness, "pi");
1336        assert_eq!(trace.selection_kind, SelectionKind::Auto);
1337        assert_eq!(trace.match_evidence, MatchEvidence::Passthrough);
1338        assert_eq!(trace.candidates_tried[0], "claude");
1339        assert_eq!(trace.candidates_tried[1], "pi");
1340        assert_eq!(
1341            trace
1342                .assessments
1343                .first()
1344                .and_then(|assessment| assessment.skip_reason),
1345            Some("native_auth_unavailable")
1346        );
1347    }
1348
1349    #[test]
1350    fn pi_or_cursor_installed_returns_passthrough() {
1351        let installed = installed(&["cursor"]);
1352        let input = routing_input(
1353            "gemini-2.5-pro",
1354            Some("google"),
1355            None,
1356            None,
1357            &installed,
1358            None,
1359            (None, None, None),
1360        );
1361
1362        let trace = evaluate_candidates_with_auth(&input, never_authed);
1363
1364        assert_eq!(trace.harness, "cursor");
1365        assert_eq!(trace.match_evidence, MatchEvidence::Passthrough);
1366    }
1367
1368    #[test]
1369    fn cursor_with_no_probe_falls_back_to_passthrough() {
1370        let installed = installed(&["cursor"]);
1371        let input = routing_input(
1372            "gpt-5.5",
1373            Some("openai"),
1374            None,
1375            None,
1376            &installed,
1377            None,
1378            (None, None, None),
1379        );
1380
1381        let trace = evaluate_candidates_with_auth(&input, never_authed);
1382        assert_eq!(trace.harness, "cursor");
1383        assert_eq!(trace.match_evidence, MatchEvidence::Passthrough);
1384    }
1385
1386    #[test]
1387    fn cursor_prefix_match_returns_confirmed_with_candidate_slugs() {
1388        let installed = installed(&["cursor"]);
1389        let cursor_probe = CursorProbeResult {
1390            slugs: vec!["gpt-5.5-high".to_string(), "gpt-5.5-low".to_string()],
1391            model_probe_success: true,
1392            error: None,
1393        };
1394        let input = routing_input(
1395            "gpt-5.5",
1396            Some("openai"),
1397            None,
1398            None,
1399            &installed,
1400            None,
1401            (None, None, Some(&cursor_probe)),
1402        );
1403
1404        let trace = evaluate_candidates_with_auth(&input, never_authed);
1405        assert_eq!(trace.harness, "cursor");
1406        assert_eq!(trace.match_evidence, MatchEvidence::Confirmed);
1407        let cursor_assessment = trace
1408            .assessments
1409            .iter()
1410            .find(|assessment| assessment.harness == "cursor")
1411            .expect("cursor assessment should exist");
1412        assert_eq!(
1413            cursor_assessment.candidate_slugs,
1414            vec!["gpt-5.5-high".to_string(), "gpt-5.5-low".to_string()]
1415        );
1416        assert_eq!(cursor_assessment.chosen_slug.as_deref(), Some("gpt-5.5"));
1417    }
1418
1419    #[test]
1420    fn cursor_exact_match_returns_confirmed() {
1421        let installed = installed(&["cursor"]);
1422        let cursor_probe = CursorProbeResult {
1423            slugs: vec!["gpt-5.5".to_string(), "gpt-5.5-high".to_string()],
1424            model_probe_success: true,
1425            error: None,
1426        };
1427        let input = routing_input(
1428            "gpt-5.5",
1429            Some("openai"),
1430            None,
1431            None,
1432            &installed,
1433            None,
1434            (None, None, Some(&cursor_probe)),
1435        );
1436
1437        let trace = evaluate_candidates_with_auth(&input, never_authed);
1438        assert_eq!(trace.harness, "cursor");
1439        assert_eq!(trace.match_evidence, MatchEvidence::Confirmed);
1440        let cursor_assessment = trace
1441            .assessments
1442            .iter()
1443            .find(|assessment| assessment.harness == "cursor")
1444            .expect("cursor assessment should exist");
1445        assert_eq!(
1446            cursor_assessment.candidate_slugs,
1447            vec!["gpt-5.5".to_string()]
1448        );
1449        assert_eq!(cursor_assessment.chosen_slug.as_deref(), Some("gpt-5.5"));
1450    }
1451
1452    #[test]
1453    fn cursor_no_match_falls_through() {
1454        let installed = installed(&["cursor"]);
1455        let cursor_probe = CursorProbeResult {
1456            slugs: vec!["claude-opus-4-7-high".to_string()],
1457            model_probe_success: true,
1458            error: None,
1459        };
1460        let input = routing_input(
1461            "gpt-5.5",
1462            Some("openai"),
1463            None,
1464            None,
1465            &installed,
1466            None,
1467            (None, None, Some(&cursor_probe)),
1468        );
1469
1470        let trace = evaluate_candidates_with_auth(&input, never_authed);
1471        assert_eq!(trace.harness, "pi");
1472        assert_eq!(trace.selection_kind, SelectionKind::HardcodedDefault);
1473        assert_eq!(
1474            trace
1475                .assessments
1476                .iter()
1477                .find(|assessment| assessment.harness == "cursor")
1478                .and_then(|assessment| assessment.skip_reason),
1479            Some("no_model_match")
1480        );
1481    }
1482
1483    #[test]
1484    fn compatible_pi_probe_returns_confirmed() {
1485        let installed = installed(&["pi"]);
1486        let pi_probe = PiProbeResult {
1487            compatible: true,
1488            model_slugs: HashSet::from(["google/gemini-2.5-pro".to_string()]),
1489            ..PiProbeResult::default()
1490        };
1491        let input = routing_input(
1492            "gemini-2.5-pro",
1493            Some("google"),
1494            None,
1495            None,
1496            &installed,
1497            None,
1498            (None, Some(&pi_probe), None),
1499        );
1500
1501        let trace = evaluate_candidates_with_auth(&input, never_authed);
1502
1503        assert_eq!(trace.harness, "pi");
1504        assert_eq!(trace.match_evidence, MatchEvidence::Confirmed);
1505    }
1506
1507    #[test]
1508    fn provider_constraint_accepts_variant_provider_name() {
1509        let installed = installed(&["pi", "opencode"]);
1510        let pi_probe = PiProbeResult {
1511            compatible: true,
1512            model_slugs: HashSet::from(["openai-codex/gpt-5.4-mini".to_string()]),
1513            ..PiProbeResult::default()
1514        };
1515        let opencode_probe = OpenCodeProbeResult {
1516            model_slugs: vec!["openai/gpt-5.4-mini".to_string()],
1517            model_probe_success: true,
1518            error: None,
1519        };
1520        let input = RoutingInput {
1521            model_id: "gpt-5.4-mini",
1522            provider_for_order: Some("openai"),
1523            provider_constraint: Some("openai"),
1524            settings_provider_order: None,
1525            settings_harness_order: None,
1526            config_default_harness: None,
1527            installed_harnesses: &installed,
1528            linked_harnesses: None,
1529            opencode_probe_result: Some(&opencode_probe),
1530            pi_probe_result: Some(&pi_probe),
1531            cursor_probe_result: None,
1532            catalog_model_slugs: None,
1533        };
1534
1535        let trace = evaluate_candidates_with_auth(&input, never_authed);
1536
1537        assert_eq!(trace.harness, "pi");
1538        assert_eq!(trace.match_evidence, MatchEvidence::Constrained);
1539        assert_eq!(
1540            trace
1541                .assessments
1542                .iter()
1543                .find(|assessment| assessment.harness == "pi")
1544                .and_then(|assessment| assessment.chosen_slug.as_deref()),
1545            Some("openai-codex/gpt-5.4-mini")
1546        );
1547    }
1548
1549    #[test]
1550    fn bare_direct_model_prefers_unknown_provider_ladder_and_pi_slug() {
1551        let installed = installed(&["codex", "pi", "opencode"]);
1552        let pi_probe = PiProbeResult {
1553            compatible: true,
1554            model_slugs: HashSet::from(["openai-codex/gpt-5.4".to_string()]),
1555            ..PiProbeResult::default()
1556        };
1557        let input = RoutingInput {
1558            model_id: "gpt-5.4",
1559            provider_for_order: None,
1560            provider_constraint: None,
1561            settings_provider_order: None,
1562            settings_harness_order: None,
1563            config_default_harness: None,
1564            installed_harnesses: &installed,
1565            linked_harnesses: None,
1566            opencode_probe_result: None,
1567            pi_probe_result: Some(&pi_probe),
1568            cursor_probe_result: None,
1569            catalog_model_slugs: None,
1570        };
1571
1572        let trace = evaluate_candidates_with_auth(&input, always_authed);
1573
1574        assert_eq!(trace.harness, "pi");
1575        assert_eq!(trace.match_evidence, MatchEvidence::Confirmed);
1576        assert_eq!(trace.candidates_tried, vec!["pi".to_string()]);
1577        assert_eq!(
1578            trace
1579                .assessments
1580                .iter()
1581                .find(|assessment| assessment.harness == "pi")
1582                .and_then(|assessment| assessment.chosen_slug.as_deref()),
1583            Some("openai-codex/gpt-5.4")
1584        );
1585    }
1586
1587    #[test]
1588    fn provider_order_ranking_is_lenient_for_known_variants() {
1589        let provider_order = vec!["openai".to_string(), "anthropic".to_string()];
1590        assert_eq!(
1591            probe_match::provider_order_rank("openai-codex", &provider_order),
1592            0
1593        );
1594        assert_eq!(
1595            probe_match::provider_order_rank("anthropic-claude", &provider_order),
1596            1
1597        );
1598        assert_eq!(
1599            probe_match::provider_order_rank("openrouter", &provider_order),
1600            usize::MAX
1601        );
1602    }
1603
1604    #[test]
1605    fn unknown_provider_order_entries_warn_but_do_not_block_routing() {
1606        let installed = installed(&["opencode"]);
1607        let provider_order = vec!["future-provider".to_string()];
1608        let probe = OpenCodeProbeResult {
1609            model_slugs: vec!["openai/gpt-5.4-mini".to_string()],
1610            model_probe_success: true,
1611            error: None,
1612        };
1613        let input = RoutingInput {
1614            model_id: "gpt-5.4-mini",
1615            provider_for_order: Some("openai"),
1616            provider_constraint: None,
1617            settings_provider_order: Some(&provider_order),
1618            settings_harness_order: None,
1619            config_default_harness: None,
1620            installed_harnesses: &installed,
1621            linked_harnesses: None,
1622            opencode_probe_result: Some(&probe),
1623            pi_probe_result: None,
1624            cursor_probe_result: None,
1625            catalog_model_slugs: None,
1626        };
1627
1628        let trace = evaluate_candidates_with_auth(&input, never_authed);
1629
1630        assert_eq!(trace.harness, "opencode");
1631        assert_eq!(trace.match_evidence, MatchEvidence::Confirmed);
1632        assert!(trace.diagnostics.iter().any(|diagnostic| {
1633            diagnostic
1634                .contains("settings.provider_order contains unknown provider `future-provider`")
1635        }));
1636    }
1637
1638    #[test]
1639    fn incompatible_pi_probe_skips_to_next_candidate() {
1640        let installed = installed(&["pi", "cursor"]);
1641        let pi_probe = PiProbeResult {
1642            compatible: false,
1643            ..PiProbeResult::default()
1644        };
1645        let input = routing_input(
1646            "gemini-2.5-pro",
1647            Some("google"),
1648            None,
1649            None,
1650            &installed,
1651            None,
1652            (None, Some(&pi_probe), None),
1653        );
1654
1655        let trace = evaluate_candidates_with_auth(&input, never_authed);
1656
1657        assert_eq!(trace.harness, "cursor");
1658        assert_eq!(
1659            trace
1660                .assessments
1661                .iter()
1662                .find(|assessment| assessment.harness == "pi")
1663                .and_then(|assessment| assessment.skip_reason),
1664            Some("pi_incompatible")
1665        );
1666    }
1667
1668    #[test]
1669    fn opencode_positive_probe_returns_likely() {
1670        let installed = installed(&["opencode"]);
1671        let probe = OpenCodeProbeResult {
1672            model_slugs: vec!["openai/gpt-5".to_string()],
1673            model_probe_success: true,
1674            error: None,
1675        };
1676        let input = routing_input(
1677            "gpt-5",
1678            Some("openai"),
1679            None,
1680            None,
1681            &installed,
1682            None,
1683            (Some(&probe), None, None),
1684        );
1685
1686        let trace = evaluate_candidates_with_auth(&input, never_authed);
1687
1688        assert_eq!(trace.harness, "opencode");
1689        assert_eq!(trace.match_evidence, MatchEvidence::Confirmed);
1690    }
1691
1692    #[test]
1693    fn opencode_negative_probe_falls_through() {
1694        let installed = installed(&["opencode", "cursor"]);
1695        let probe = OpenCodeProbeResult {
1696            model_slugs: Vec::new(),
1697            model_probe_success: true,
1698            error: None,
1699        };
1700        let input = routing_input(
1701            "gpt-5",
1702            Some("openai"),
1703            None,
1704            None,
1705            &installed,
1706            None,
1707            (Some(&probe), None, None),
1708        );
1709
1710        let trace = evaluate_candidates_with_auth(&input, never_authed);
1711
1712        assert_eq!(trace.harness, "cursor");
1713        assert_eq!(trace.match_evidence, MatchEvidence::Passthrough);
1714        assert_eq!(
1715            trace
1716                .assessments
1717                .iter()
1718                .find(|assessment| assessment.harness == "opencode")
1719                .and_then(|assessment| assessment.skip_reason),
1720            Some("no_model_match")
1721        );
1722    }
1723
1724    #[test]
1725    fn link_filtering_reduces_candidates() {
1726        let installed = installed(&["codex", "pi"]);
1727        let linked_harnesses = vec!["pi".to_string()];
1728        let input = routing_input(
1729            "gpt-5",
1730            Some("openai"),
1731            None,
1732            None,
1733            &installed,
1734            Some(&linked_harnesses),
1735            (None, None, None),
1736        );
1737
1738        let trace = evaluate_candidates_with_auth(&input, always_authed);
1739
1740        assert_eq!(trace.harness, "pi");
1741        assert_eq!(trace.candidates_tried, vec!["pi"]);
1742    }
1743
1744    #[test]
1745    fn settings_harness_order_overrides_provider_order() {
1746        let installed = installed(&["codex", "pi"]);
1747        let order = vec!["pi".to_string(), "codex".to_string()];
1748        let input = routing_input(
1749            "gpt-5",
1750            Some("openai"),
1751            Some(&order),
1752            None,
1753            &installed,
1754            None,
1755            (None, None, None),
1756        );
1757
1758        let trace = evaluate_candidates_with_auth(&input, always_authed);
1759
1760        assert_eq!(trace.source, RouteSource::ConfigOrder);
1761        assert_eq!(trace.harness, "codex");
1762        assert_eq!(trace.harness_order_position, Some(1));
1763        assert_eq!(trace.match_evidence, MatchEvidence::Confirmed);
1764    }
1765
1766    #[test]
1767    fn empty_harness_order_falls_through_to_provider() {
1768        let installed = installed(&["codex"]);
1769        let order: Vec<String> = Vec::new();
1770        let input = routing_input(
1771            "gpt-5",
1772            Some("openai"),
1773            Some(&order),
1774            None,
1775            &installed,
1776            None,
1777            (None, None, None),
1778        );
1779
1780        let trace = evaluate_candidates_with_auth(&input, always_authed);
1781
1782        assert_eq!(trace.source, RouteSource::Provider);
1783        assert_eq!(trace.harness, "codex");
1784        assert!(
1785            trace
1786                .diagnostics
1787                .iter()
1788                .any(|diagnostic| diagnostic.contains("settings.harness_order is empty"))
1789        );
1790    }
1791
1792    #[test]
1793    fn uses_config_default_fallback() {
1794        let installed = installed(&[]);
1795        let input = routing_input(
1796            "gpt-5",
1797            Some("openai"),
1798            None,
1799            Some("Pi"),
1800            &installed,
1801            None,
1802            (None, None, None),
1803        );
1804
1805        let trace = evaluate_candidates_with_auth(&input, never_authed);
1806
1807        assert_eq!(trace.source, RouteSource::ConfigDefault);
1808        assert_eq!(trace.selection_kind, SelectionKind::ConfigDefault);
1809        assert_eq!(trace.harness, "pi");
1810        assert_eq!(trace.match_evidence, MatchEvidence::Passthrough);
1811    }
1812
1813    #[test]
1814    fn uses_hardcoded_pi_fallback_with_warning() {
1815        let installed = installed(&[]);
1816        let input = routing_input(
1817            "model",
1818            None,
1819            None,
1820            None,
1821            &installed,
1822            None,
1823            (None, None, None),
1824        );
1825
1826        let trace = evaluate_candidates_with_auth(&input, never_authed);
1827
1828        assert_eq!(trace.source, RouteSource::HardcodedDefault);
1829        assert_eq!(trace.selection_kind, SelectionKind::HardcodedDefault);
1830        assert_eq!(trace.harness, "pi");
1831        assert!(
1832            trace
1833                .diagnostics
1834                .iter()
1835                .any(|diagnostic| { diagnostic.contains("defaulting to `pi`") })
1836        );
1837    }
1838
1839    #[test]
1840    fn linked_constraints_apply_to_default_and_hardcoded_fallbacks() {
1841        let installed = installed(&["codex"]);
1842        let linked_harnesses = vec!["claude".to_string()];
1843
1844        let with_config_default = routing_input(
1845            "gpt-5",
1846            Some("openai"),
1847            None,
1848            Some("pi"),
1849            &installed,
1850            Some(&linked_harnesses),
1851            (None, None, None),
1852        );
1853        let with_default_trace = evaluate_candidates_with_auth(&with_config_default, never_authed);
1854        assert_eq!(with_default_trace.source, RouteSource::Provider);
1855        assert_eq!(
1856            with_default_trace.selection_kind,
1857            SelectionKind::LinkedFallback
1858        );
1859        assert_eq!(with_default_trace.harness, "claude");
1860        assert_eq!(with_default_trace.candidates_tried, vec!["claude"]);
1861        assert!(with_default_trace.diagnostics.iter().any(|diagnostic| {
1862            diagnostic.contains(
1863                "settings.default_harness is excluded by known linked harness constraints",
1864            )
1865        }));
1866
1867        let without_config_default = routing_input(
1868            "gpt-5",
1869            Some("openai"),
1870            None,
1871            None,
1872            &installed,
1873            Some(&linked_harnesses),
1874            (None, None, None),
1875        );
1876        let hardcoded_trace = evaluate_candidates_with_auth(&without_config_default, never_authed);
1877        assert_eq!(hardcoded_trace.source, RouteSource::Provider);
1878        assert_eq!(
1879            hardcoded_trace.selection_kind,
1880            SelectionKind::LinkedFallback
1881        );
1882        assert_eq!(hardcoded_trace.harness, "claude");
1883        assert!(
1884            hardcoded_trace
1885                .diagnostics
1886                .iter()
1887                .any(|diagnostic| { diagnostic.contains("selecting linked harness `claude`") })
1888        );
1889    }
1890
1891    #[test]
1892    fn linked_default_harness_is_allowed_when_linked() {
1893        let installed = installed(&[]);
1894        let linked_harnesses = vec!["pi".to_string()];
1895        let trace = evaluate_candidates_with_auth(
1896            &routing_input(
1897                "gpt-5",
1898                Some("openai"),
1899                None,
1900                Some("pi"),
1901                &installed,
1902                Some(&linked_harnesses),
1903                (None, None, None),
1904            ),
1905            never_authed,
1906        );
1907
1908        assert_eq!(trace.source, RouteSource::ConfigDefault);
1909        assert_eq!(trace.harness, "pi");
1910    }
1911
1912    #[test]
1913    fn fixed_harness_evaluation_has_no_fallback() {
1914        let installed = installed(&[]);
1915        let input = routing_input(
1916            "gpt-5",
1917            Some("openai"),
1918            None,
1919            Some("pi"),
1920            &installed,
1921            None,
1922            (None, None, None),
1923        );
1924        let assessment = evaluate_fixed_harness_with_auth(&input, "codex", never_authed);
1925
1926        assert_eq!(assessment.harness, "codex");
1927        assert!(!assessment.installed);
1928        assert_eq!(assessment.match_evidence, None);
1929        assert_eq!(assessment.skip_reason, Some("not_installed"));
1930    }
1931
1932    #[test]
1933    fn fixed_native_harness_enforces_provider_constraint() {
1934        let installed = installed(&["codex"]);
1935        let input = RoutingInput {
1936            model_id: "gpt-5",
1937            provider_for_order: Some("openai"),
1938            provider_constraint: Some("anthropic"),
1939            settings_provider_order: None,
1940            settings_harness_order: None,
1941            config_default_harness: None,
1942            installed_harnesses: &installed,
1943            linked_harnesses: None,
1944            opencode_probe_result: None,
1945            pi_probe_result: None,
1946            cursor_probe_result: None,
1947            catalog_model_slugs: None,
1948        };
1949
1950        let assessment = evaluate_fixed_harness_with_auth(&input, "codex", always_authed);
1951
1952        assert_eq!(assessment.harness, "codex");
1953        assert!(assessment.installed);
1954        assert_eq!(assessment.match_evidence, None);
1955        assert_eq!(
1956            assessment.skip_reason,
1957            Some("provider_constraint_unsatisfied")
1958        );
1959    }
1960
1961    #[test]
1962    fn fixed_native_codex_accepts_openai_codex_provider_variant() {
1963        let installed = installed(&["codex"]);
1964        let input = RoutingInput {
1965            model_id: "gpt-5",
1966            provider_for_order: Some("openai-codex"),
1967            provider_constraint: Some("openai-codex"),
1968            settings_provider_order: None,
1969            settings_harness_order: None,
1970            config_default_harness: None,
1971            installed_harnesses: &installed,
1972            linked_harnesses: None,
1973            opencode_probe_result: None,
1974            pi_probe_result: None,
1975            cursor_probe_result: None,
1976            catalog_model_slugs: None,
1977        };
1978
1979        let assessment = evaluate_fixed_harness_with_auth(&input, "codex", always_authed);
1980
1981        assert_eq!(assessment.harness, "codex");
1982        assert!(assessment.installed);
1983        assert_eq!(assessment.match_evidence, Some(MatchEvidence::Constrained));
1984        assert_eq!(assessment.skip_reason, None);
1985    }
1986
1987    #[test]
1988    fn fixed_native_claude_accepts_anthropic_claude_provider_variant() {
1989        let installed = installed(&["claude"]);
1990        let input = RoutingInput {
1991            model_id: "claude-opus-4-7",
1992            provider_for_order: Some("anthropic-claude"),
1993            provider_constraint: Some("anthropic-claude"),
1994            settings_provider_order: None,
1995            settings_harness_order: None,
1996            config_default_harness: None,
1997            installed_harnesses: &installed,
1998            linked_harnesses: None,
1999            opencode_probe_result: None,
2000            pi_probe_result: None,
2001            cursor_probe_result: None,
2002            catalog_model_slugs: None,
2003        };
2004
2005        let assessment = evaluate_fixed_harness_with_auth(&input, "claude", always_authed);
2006
2007        assert_eq!(assessment.harness, "claude");
2008        assert!(assessment.installed);
2009        assert_eq!(assessment.match_evidence, Some(MatchEvidence::Constrained));
2010        assert_eq!(assessment.skip_reason, None);
2011    }
2012
2013    #[test]
2014    fn selected_chosen_slug_evidence_prefers_selected_harness_assessment() {
2015        let trace = RoutingTrace {
2016            source: RouteSource::Provider,
2017            selection_kind: SelectionKind::Auto,
2018            match_evidence: MatchEvidence::Confirmed,
2019            harness: "pi".to_string(),
2020            harness_order_position: None,
2021            candidates_tried: vec!["pi".to_string()],
2022            assessments: vec![
2023                CandidateAssessment {
2024                    harness: "opencode".to_string(),
2025                    installed: true,
2026                    candidate_slugs: vec!["openai/gpt-5.4-mini".to_string()],
2027                    filtered_slugs: vec!["openai/gpt-5.4-mini".to_string()],
2028                    chosen_slug: Some("openai/gpt-5.4-mini".to_string()),
2029                    chosen_model: Some("gpt-5.4-mini".to_string()),
2030                    match_evidence: Some(MatchEvidence::Confirmed),
2031                    skip_reason: None,
2032                },
2033                CandidateAssessment {
2034                    harness: "pi".to_string(),
2035                    installed: true,
2036                    candidate_slugs: vec!["openai/gpt-5.4-mini".to_string()],
2037                    filtered_slugs: vec!["openai/gpt-5.4-mini".to_string()],
2038                    chosen_slug: Some("openai/gpt-5.4-mini".to_string()),
2039                    chosen_model: Some("gpt-5.4-mini".to_string()),
2040                    match_evidence: Some(MatchEvidence::Constrained),
2041                    skip_reason: None,
2042                },
2043            ],
2044            diagnostics: vec!["diag".to_string()],
2045        };
2046
2047        let selected = trace
2048            .selected_chosen_slug_evidence()
2049            .expect("selected slug evidence should be present");
2050        assert_eq!(selected.slug, "openai/gpt-5.4-mini");
2051        assert_eq!(selected.match_evidence, Some(MatchEvidence::Constrained));
2052        assert_eq!(trace.selected_harness(), "pi");
2053        assert_eq!(trace.selected_selection_kind(), SelectionKind::Auto);
2054        assert_eq!(trace.selected_match_evidence(), MatchEvidence::Confirmed);
2055        assert_eq!(trace.selected_diagnostics(), vec!["diag".to_string()]);
2056    }
2057
2058    #[test]
2059    fn constrained_slug_selection_prefers_exact_provider_over_variant() {
2060        let installed = installed(&["pi"]);
2061        let pi_probe = PiProbeResult {
2062            compatible: true,
2063            model_slugs: HashSet::from([
2064                "openai-codex/gpt-5.4-mini".to_string(),
2065                "openai/gpt-5.4-mini".to_string(),
2066            ]),
2067            ..PiProbeResult::default()
2068        };
2069        let input = RoutingInput {
2070            model_id: "gpt-5.4-mini",
2071            provider_for_order: Some("openai"),
2072            provider_constraint: Some("openai"),
2073            settings_provider_order: None,
2074            settings_harness_order: None,
2075            config_default_harness: None,
2076            installed_harnesses: &installed,
2077            linked_harnesses: None,
2078            opencode_probe_result: None,
2079            pi_probe_result: Some(&pi_probe),
2080            cursor_probe_result: None,
2081            catalog_model_slugs: None,
2082        };
2083
2084        let trace = evaluate_candidates_with_auth(&input, always_authed);
2085        assert_eq!(trace.harness, "pi");
2086        assert_eq!(
2087            trace
2088                .selected_chosen_slug_evidence()
2089                .expect("selected chosen slug evidence")
2090                .slug,
2091            "openai/gpt-5.4-mini"
2092        );
2093    }
2094
2095    #[test]
2096    fn unconstrained_slug_selection_prefers_openai_codex_variant_for_pi() {
2097        let installed = installed(&["pi"]);
2098        let pi_probe = PiProbeResult {
2099            compatible: true,
2100            model_slugs: HashSet::from([
2101                "openai-codex/gpt-5.4-mini".to_string(),
2102                "openai/gpt-5.4-mini".to_string(),
2103            ]),
2104            ..PiProbeResult::default()
2105        };
2106        let input = RoutingInput {
2107            model_id: "gpt-5.4-mini",
2108            provider_for_order: Some("openai"),
2109            provider_constraint: None,
2110            settings_provider_order: None,
2111            settings_harness_order: None,
2112            config_default_harness: None,
2113            installed_harnesses: &installed,
2114            linked_harnesses: None,
2115            opencode_probe_result: None,
2116            pi_probe_result: Some(&pi_probe),
2117            cursor_probe_result: None,
2118            catalog_model_slugs: None,
2119        };
2120
2121        let trace = evaluate_candidates_with_auth(&input, always_authed);
2122        assert_eq!(trace.harness, "pi");
2123        assert_eq!(
2124            trace
2125                .selected_chosen_slug_evidence()
2126                .expect("selected chosen slug evidence")
2127                .slug,
2128            "openai-codex/gpt-5.4-mini"
2129        );
2130    }
2131}