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