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