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