1use std::collections::{BTreeMap, BTreeSet, HashMap};
7use std::error::Error;
8use std::fmt::{Display, Formatter};
9use std::path::PathBuf;
10use std::sync::Arc;
11
12use libsy::{
13 AdvisorGate, AdvisorGateConfig, Algorithm, BreakerConfig, CapabilityJudgeConfig,
14 ClassifierContractConfig, ClassifierResponseFormat, ClassifyTrigger, CompositeJudgeConfig,
15 CompositeRouter, CompositeRouterConfig, CustomClassifierConfig, CustomClassifierPolicy,
16 DEFAULT_UNMATCHED_STEPS, DecisionChoice, DecisionJudgeConfig, DirectClassifierConfig,
17 EscalationJudgeConfig, GateTrigger, HandoffNoteConfig, LlmCapabilityConfig,
18 LlmClassifierConfig, LlmFallback, LlmTaskClassifier, Noop, Passthrough, PickerMode, Random,
19 RuntimeModels, StageRouter, StageRouterConfig, SubagentRouter, SubagentRouterConfig,
20 TaskClassifierConfig, ToolSemantics, Vgr, VgrConfig,
21};
22use serde::Deserialize;
23use strum_macros::IntoStaticStr;
24use switchyard_protocol::{Category, ModelId};
25
26#[derive(Debug)]
28pub struct AlgorithmConfigError {
29 message: String,
30 source: Option<Box<dyn Error + Send + Sync>>,
31}
32
33impl AlgorithmConfigError {
34 fn new(message: impl Into<String>) -> Self {
35 Self {
36 message: message.into(),
37 source: None,
38 }
39 }
40
41 fn with_source(message: impl Into<String>, source: impl Error + Send + Sync + 'static) -> Self {
42 Self {
43 message: message.into(),
44 source: Some(Box::new(source)),
45 }
46 }
47}
48
49impl Display for AlgorithmConfigError {
50 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
51 formatter.write_str(&self.message)
52 }
53}
54
55impl Error for AlgorithmConfigError {
56 fn source(&self) -> Option<&(dyn Error + 'static)> {
57 self.source.as_deref().map(|source| source as _)
58 }
59}
60
61type AlgorithmResult<T> = Result<T, AlgorithmConfigError>;
62
63#[derive(Clone, Debug, Deserialize)]
65#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
66pub enum ClassifierPolicyConfig {
67 TargetSelector {
69 selector: String,
71 },
72}
73
74#[derive(Clone, Copy, Debug, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum ClassifierMode {
78 Capability,
80 Escalation,
83 Custom,
85 Direct,
87}
88
89impl ClassifierPolicyConfig {
90 fn into_libsy(self) -> CustomClassifierPolicy {
91 match self {
92 Self::TargetSelector { selector } => CustomClassifierPolicy::target_selector(selector),
93 }
94 }
95}
96
97#[derive(Clone, Debug)]
98enum LlmClassifierModeConfig {
99 Capability(CapabilityClassifierRouteConfig),
100 Escalation(EscalationClassifierRouteConfig),
101 Custom(CustomClassifierRouteConfig),
102 Direct {
103 models: CategoryModelConfig,
104 default_target: Category,
105 config: DirectClassifierConfig,
106 },
107}
108
109#[derive(Clone, Debug)]
110struct CapabilityClassifierRouteConfig {
111 classifier_target: String,
112 fail_open: bool,
113 strong_target: String,
114 weak_target: String,
115 judge: CapabilityJudgeRouteConfig,
116 classify_trigger: ClassifyTrigger,
117 message_hash_fallback: bool,
118 recent_turn_window: Option<usize>,
119}
120
121impl CapabilityClassifierRouteConfig {
122 fn into_task_config(self) -> TaskClassifierConfig {
123 let judge = match self.judge {
124 CapabilityJudgeRouteConfig::Llm(judge) => CapabilityJudgeConfig::Llm(judge),
125 CapabilityJudgeRouteConfig::Decision(judge) => {
126 let defaults = DecisionJudgeConfig::default();
127 CapabilityJudgeConfig::Decision(DecisionJudgeConfig {
128 cutoff: judge.cutoff,
129 instructions: judge.instructions,
130 choices: judge.choices.unwrap_or(defaults.choices),
131 apply_cutoff_to: judge.apply_cutoff_to.unwrap_or(defaults.apply_cutoff_to),
132 })
133 }
134 };
135 TaskClassifierConfig {
136 judge,
137 fail_open: self.fail_open,
138 classify_trigger: self.classify_trigger,
139 message_hash_fallback: self.message_hash_fallback,
140 recent_turn_window: self.recent_turn_window,
141 }
142 }
143}
144
145#[derive(Clone, Debug)]
146enum CapabilityJudgeRouteConfig {
147 Llm(LlmCapabilityConfig),
148 Decision(DecisionJudgeRouteConfig),
149}
150
151#[derive(Clone, Debug)]
152struct EscalationClassifierRouteConfig {
153 classifier_target: String,
154 strong_target: String,
155 weak_target: String,
156 prompt: Option<String>,
157 response_format_type: ClassifierResponseFormat,
158 max_output_tokens: u64,
159 judge_deadline_ms: Option<u64>,
160 judge: EscalationJudgeConfig,
161}
162
163#[derive(Clone, Debug)]
164struct CustomClassifierRouteConfig {
165 models: CategoryModelConfig,
166 default_target: Category,
167 prompt: String,
168 response_schema: String,
169 policy: ClassifierPolicyConfig,
170 classify_trigger: ClassifyTrigger,
171 message_hash_fallback: bool,
172 recent_turn_window: Option<usize>,
173 max_output_tokens: u64,
174 judge_deadline_ms: Option<u64>,
175}
176
177#[derive(Clone, Debug, Default, Deserialize)]
185#[serde(transparent)]
186pub struct CategoryModelConfig(BTreeMap<String, Vec<String>>);
187
188impl CategoryModelConfig {
189 fn restrict_to(&mut self, allowed: &BTreeSet<&str>) {
190 for models in self.0.values_mut() {
191 models.retain(|model| allowed.contains(model.as_str()));
192 }
193 }
194
195 fn validate(&self, route_name: &str, default_target: &Category) -> AlgorithmResult<()> {
196 for category in [Category::Any, Category::Judge] {
197 if self.get(&category).is_empty() {
198 return Err(AlgorithmConfigError::new(format!(
199 "llm_classifier route {route_name} models.{} must contain at least one target",
200 category.as_str()
201 )));
202 }
203 }
204 if self.get(default_target).is_empty() {
205 return Err(AlgorithmConfigError::new(format!(
206 "llm_classifier route {route_name} models.{} must contain at least one target because it is the default_target",
207 default_target.as_str()
208 )));
209 }
210 let any = self.get(&Category::Any);
214 for (name, models) in &self.0 {
215 if name == Category::Judge.as_str() || name == Category::Any.as_str() {
216 continue;
217 }
218 if let Some(missing) = models.iter().find(|model| !any.contains(model)) {
219 return Err(AlgorithmConfigError::new(format!(
220 "llm_classifier route {route_name} models.{name} lists target {missing}, which must also appear in models.any"
221 )));
222 }
223 }
224 Ok(())
225 }
226
227 fn get(&self, category: &Category) -> &[String] {
228 self.0.get(category.as_str()).map_or(&[], Vec::as_slice)
229 }
230
231 fn all_names(&self) -> Vec<&str> {
233 Self::deduped(self.0.values().flatten())
234 }
235
236 fn routing_names(&self) -> Vec<&str> {
241 let others = self
242 .0
243 .iter()
244 .filter(|(name, _)| {
245 *name != Category::Judge.as_str() && *name != Category::Any.as_str()
246 })
247 .flat_map(|(_, models)| models);
248 Self::deduped(self.get(&Category::Any).iter().chain(others))
249 }
250
251 fn deduped<'a>(names: impl Iterator<Item = &'a String>) -> Vec<&'a str> {
252 let mut seen = BTreeSet::new();
253 names
254 .map(String::as_str)
255 .filter(|name| seen.insert(*name))
256 .collect()
257 }
258
259 fn groups(&self) -> impl Iterator<Item = (Category, Vec<String>)> + '_ {
260 self.0
261 .iter()
262 .filter_map(|(name, models)| Some((name.parse::<Category>().ok()?, models.clone())))
263 }
264}
265
266#[derive(Clone, Debug, Default, Deserialize)]
268#[serde(deny_unknown_fields)]
269pub struct DecisionJudgeRouteConfig {
270 pub cutoff: Option<f64>,
273 pub instructions: Option<String>,
275 pub choices: Option<Vec<DecisionChoice>>,
277 pub apply_cutoff_to: Option<String>,
279}
280
281#[derive(Clone, Debug, Default, Deserialize)]
284#[serde(default, deny_unknown_fields)]
285pub struct LlmClassifierRouteConfig {
286 pub classifier_target: String,
288 pub mode: Option<ClassifierMode>,
290 pub strong_target: Option<String>,
292 pub weak_target: Option<String>,
294 pub base_threshold: Option<f64>,
297 pub threshold_step: Option<f64>,
300 pub unmatched_steps: Option<u8>,
303 pub fail_open: Option<bool>,
306 pub classify_trigger: ClassifyTrigger,
308 pub message_hash_fallback: bool,
311 pub recent_turn_window: Option<usize>,
314 pub prompt: Option<String>,
316 pub response_format_type: ClassifierResponseFormat,
319 #[serde(default = "default_classifier_max_output_tokens")]
321 pub max_output_tokens: u64,
322 #[serde(default)]
325 pub judge_deadline_ms: Option<u64>,
326 pub escalation: Option<EscalationJudgeConfig>,
329 pub decision: Option<DecisionJudgeRouteConfig>,
331 pub models: Option<CategoryModelConfig>,
334 pub default_target: Option<String>,
337 pub response_schema: Option<String>,
339 pub policy: Option<ClassifierPolicyConfig>,
341}
342
343#[derive(Clone, Debug, Deserialize)]
346#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
347pub enum SubagentRouteConfig {
348 Passthrough {
350 target: String,
352 },
353 LlmClassifier(Box<LlmClassifierRouteConfig>),
355}
356
357impl SubagentRouteConfig {
358 fn routing_target_names(&self) -> Vec<&str> {
359 match self {
360 Self::Passthrough { target } => vec![target],
361 Self::LlmClassifier(classifier) => classifier
362 .models
363 .as_ref()
364 .map(CategoryModelConfig::routing_names)
365 .unwrap_or_default(),
366 }
367 }
368
369 fn judge_target_names(&self) -> Vec<&str> {
370 match self {
371 Self::Passthrough { .. } => Vec::new(),
372 Self::LlmClassifier(classifier) => classifier
373 .models
374 .as_ref()
375 .map(|models| models.get(&Category::Judge))
376 .unwrap_or_default()
377 .iter()
378 .map(String::as_str)
379 .collect(),
380 }
381 }
382}
383
384#[derive(Clone, Debug, Deserialize, IntoStaticStr)]
387#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
388#[strum(serialize_all = "snake_case")]
389pub enum AlgorithmSpec {
390 Noop {},
392 Random {
394 targets: Vec<String>,
396 weights: Option<Vec<f64>>,
398 seed: Option<u64>,
400 },
401 Passthrough {
403 target: String,
405 #[serde(default)]
407 subagents: Option<SubagentRouteConfig>,
408 },
409 LlmClassifier {
411 #[serde(flatten)]
413 config: LlmClassifierRouteConfig,
414 },
415 StageRouter {
417 capable_target: String,
418 efficient_target: String,
419 #[serde(default)]
420 capable_first_threshold: f64,
421 #[serde(flatten)]
422 settings: StageSettingsConfig,
423 picker: PickerMode,
425 #[serde(default)]
427 classifier: Option<StageClassifierConfig>,
428 #[serde(default)]
430 subagents: Option<SubagentRouteConfig>,
431 },
432 Auto {
437 capable_target: String,
439 efficient_target: String,
441 },
442 Composite {
444 classifier: StageClassifierConfig,
446 stage: StageTierConfig,
448 #[serde(default)]
450 efficient: Option<Box<StageProfileConfig>>,
451 #[serde(default)]
453 capable: Option<Box<StageProfileConfig>>,
454 #[serde(default)]
456 subagents: Option<SubagentRouteConfig>,
457 },
458 Advisor {
461 executor_target: String,
463 advisor_target: String,
465 #[serde(default)]
467 reviewer_system_prompt: Option<String>,
468 #[serde(default)]
470 redo_feedback_prefix: Option<String>,
471 #[serde(default)]
473 gate_trigger: AdvisorTriggerConfig,
474 #[serde(default)]
476 gate_trigger_pattern: Option<String>,
477 #[serde(default = "default_max_reviews")]
479 max_reviews: u32,
480 #[serde(default)]
483 gate_stall_turns: u32,
484 #[serde(default)]
486 gate_min_tool_results: u32,
487 #[serde(default = "default_advisor_max_tokens")]
489 advisor_max_tokens: u64,
490 #[serde(default)]
492 advisor_temperature: Option<f64>,
493 #[serde(default = "default_transcript_max_chars")]
496 transcript_max_chars: usize,
497 #[serde(default = "default_fail_open")]
499 fail_open: bool,
500 },
501 Vgr {
502 #[serde(flatten)]
503 config: VgrRouteConfig,
504 },
505 PrefillRouter {
507 targets: Vec<String>,
509 checkpoint: PathBuf,
511 device: Option<String>,
514 cache_dir: Option<PathBuf>,
516 max_length: Option<usize>,
518 batch_size: Option<usize>,
520 },
521}
522
523#[derive(Clone, Debug, Deserialize)]
524#[serde(deny_unknown_fields)]
525pub struct VgrRouteConfig {
526 pub efficient_target: String,
527 pub capable_target: String,
528 #[serde(default)]
530 pub judge_target: Option<String>,
531 #[serde(default)]
532 pub capable_judge_target: Option<String>,
533 #[serde(default)]
534 pub mode: VgrModeConfig,
535 #[serde(default)]
536 pub active_approval: Option<String>,
537 #[serde(default = "default_vgr_deadline")]
538 pub deadline_seconds: f64,
539 #[serde(default = "default_true")]
541 pub bounded_efficient: bool,
542 #[serde(default = "default_true")]
543 pub task_typing: bool,
544 #[serde(default = "default_vgr_breaker_threshold")]
545 pub breaker_threshold: u32,
546 #[serde(default = "default_vgr_breaker_cooldown")]
547 pub breaker_cooldown_seconds: f64,
548 #[serde(default)]
550 pub confirmed_recovery_min_clean_tail: Option<u32>,
551 #[serde(default)]
553 pub agentic_handoff: bool,
554 #[serde(default)]
556 pub compact_handoff: bool,
557 #[serde(default)]
559 pub efficient_turn_budget_seconds: Option<f64>,
560 #[serde(default)]
562 pub latch_escalation: bool,
563}
564
565#[derive(Clone, Debug, Default, Deserialize)]
566#[serde(rename_all = "snake_case")]
567pub enum VgrModeConfig {
568 #[default]
569 Off,
570 Evaluate,
571 Shadow,
572 Active,
573}
574
575#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
577#[serde(rename_all = "snake_case")]
578pub enum AdvisorTriggerConfig {
579 #[default]
581 NoToolCall,
582 Pattern,
584}
585
586#[derive(Clone, Debug, Deserialize)]
588#[serde(deny_unknown_fields)]
589pub struct StageClassifierConfig {
590 pub target: String,
592 #[serde(default)]
594 pub mode: Option<ClassifierMode>,
595 #[serde(default)]
596 pub response_schema: Option<String>,
597 #[serde(default)]
598 pub policy: Option<ClassifierPolicyConfig>,
599 #[serde(default)]
600 pub decision: Option<DecisionJudgeRouteConfig>,
601 #[serde(default)]
602 pub fail_open: Option<bool>,
603 pub base_threshold: Option<f64>,
605 #[serde(default)]
608 pub threshold_step: Option<f64>,
609 #[serde(default)]
613 pub classify_trigger: ClassifyTrigger,
614 #[serde(default)]
617 pub message_hash_fallback: bool,
618 #[serde(default)]
621 pub recent_turn_window: Option<usize>,
622 #[serde(default)]
624 pub prompt: Option<String>,
625 #[serde(default)]
628 pub response_format_type: ClassifierResponseFormat,
629 #[serde(default = "default_classifier_max_output_tokens")]
631 pub max_output_tokens: u64,
632 #[serde(default)]
635 pub judge_deadline_ms: Option<u64>,
636}
637
638#[derive(Clone, Debug, Deserialize)]
640#[serde(deny_unknown_fields)]
641pub struct StageTierConfig {
642 pub capable_target: String,
643 pub efficient_target: String,
644 #[serde(default)]
645 pub capable_first_threshold: f64,
646 #[serde(flatten)]
647 pub settings: StageSettingsConfig,
648}
649
650#[derive(Clone, Debug, Default, Deserialize)]
652#[serde(default, deny_unknown_fields)]
653pub struct StageSettingsConfig {
654 #[serde(alias = "efficient_first_threshold")]
655 pub confidence_threshold: Option<f64>,
656 pub recent_turn_window: Option<usize>,
657 pub tool_semantics: Option<ToolSemantics>,
658 pub capable_hold_turns: Option<i64>,
659 pub efficient_hold_turns: Option<i64>,
660 pub handoff_notes: Option<HandoffNoteConfig>,
661 pub capable_system_prompt: Option<String>,
662 pub efficient_system_prompt: Option<String>,
663}
664
665#[derive(Clone, Debug, Default, Deserialize)]
666#[serde(deny_unknown_fields)]
667pub struct StageProfileConfig {
668 pub classifier: Option<StageClassifierConfig>,
669 pub picker: Option<PickerMode>,
670 #[serde(flatten)]
671 pub settings: StageSettingsConfig,
672}
673
674impl StageSettingsConfig {
675 fn stage_config(&self, shared: &Self, mode: PickerMode, threshold: f64) -> StageRouterConfig {
676 let mut config =
677 StageRouterConfig::new(mode, self.confidence_threshold.unwrap_or(threshold));
678 config.recent_window = self.recent_turn_window.or(shared.recent_turn_window);
679 config.tool_semantics = self
680 .tool_semantics
681 .as_ref()
682 .or(shared.tool_semantics.as_ref())
683 .cloned()
684 .unwrap_or_default();
685 config.capable_hold_turns = self
686 .capable_hold_turns
687 .or(shared.capable_hold_turns)
688 .unwrap_or(2);
689 config.efficient_hold_turns = self.efficient_hold_turns.or(shared.efficient_hold_turns);
690 config.handoff_notes = self
691 .handoff_notes
692 .as_ref()
693 .or(shared.handoff_notes.as_ref())
694 .cloned();
695 config.capable_system_prompt = self
696 .capable_system_prompt
697 .as_ref()
698 .or(shared.capable_system_prompt.as_ref())
699 .cloned();
700 config.efficient_system_prompt = self
701 .efficient_system_prompt
702 .as_ref()
703 .or(shared.efficient_system_prompt.as_ref())
704 .cloned();
705 config
706 }
707}
708
709impl StageTierConfig {
710 fn warn_identical_tiers(&self, route_name: &str) {
712 warn_identical_tiers(route_name, &self.capable_target, &self.efficient_target);
713 }
714}
715
716impl StageClassifierConfig {
717 fn task_classifier_config(
718 &self,
719 route_name: &str,
720 capable_target: &str,
721 efficient_target: &str,
722 ) -> AlgorithmResult<TaskClassifierConfig> {
723 if self
724 .mode
725 .is_some_and(|mode| !matches!(mode, ClassifierMode::Capability))
726 || self.response_schema.is_some()
727 || self.policy.is_some()
728 {
729 return Err(AlgorithmConfigError::new(
730 "custom classifier settings require composite with mode = custom",
731 ));
732 }
733 let config = LlmClassifierRouteConfig {
734 classifier_target: self.target.clone(),
735 strong_target: Some(capable_target.to_string()),
736 weak_target: Some(efficient_target.to_string()),
737 base_threshold: self.base_threshold,
738 threshold_step: self.threshold_step,
739 fail_open: self.fail_open,
740 decision: self.decision.clone(),
741 classify_trigger: self.classify_trigger,
742 message_hash_fallback: self.message_hash_fallback,
743 recent_turn_window: self.recent_turn_window,
744 prompt: self.prompt.clone(),
745 response_format_type: self.response_format_type,
746 max_output_tokens: self.max_output_tokens,
747 judge_deadline_ms: self.judge_deadline_ms,
748 ..Default::default()
749 };
750 match config.validated_classifier_mode(route_name)? {
751 LlmClassifierModeConfig::Capability(config) => Ok(config.into_task_config()),
752 _ => Err(AlgorithmConfigError::new("expected a capability judge")),
753 }
754 }
755
756 fn infer_decision(
757 &mut self,
758 infer: &mut impl FnMut(&str, &mut Option<DecisionJudgeRouteConfig>),
759 ) {
760 if matches!(self.mode, None | Some(ClassifierMode::Capability)) {
761 infer(&self.target, &mut self.decision);
762 }
763 }
764
765 fn stage_fallback(
766 &self,
767 route_name: &str,
768 capable: &str,
769 efficient: &str,
770 targets: &BTreeMap<String, ModelId>,
771 ) -> AlgorithmResult<LlmFallback> {
772 Ok(LlmFallback {
773 target: Some(resolve_target_model_id(route_name, &self.target, targets)?),
774 config: self.task_classifier_config(route_name, capable, efficient)?,
775 })
776 }
777
778 fn custom_classifier_config(
779 &self,
780 route_name: &str,
781 ) -> AlgorithmResult<CustomClassifierConfig> {
782 if self.fail_open == Some(false) {
783 return Err(AlgorithmConfigError::new(
784 "composite custom classifiers always fail open; remove fail_open = false",
785 ));
786 }
787 if self.decision.is_some()
788 || self.base_threshold.is_some()
789 || self.threshold_step.is_some()
790 || self.response_format_type != ClassifierResponseFormat::JsonSchema
791 {
792 return Err(AlgorithmConfigError::new(
793 "composite custom classifier cannot use capability thresholds, decision settings, or response_format_type",
794 ));
795 }
796 let schema =
797 required_classifier_field(route_name, "response_schema", &self.response_schema)?;
798 let schema = serde_json::from_str(&schema).map_err(|error| {
799 AlgorithmConfigError::with_source(
800 format!("composite route {route_name}: response_schema is invalid JSON"),
801 error,
802 )
803 })?;
804 let mut config = CustomClassifierConfig::new(
805 required_classifier_field(route_name, "prompt", &self.prompt)?,
806 schema,
807 required_classifier_field(route_name, "policy", &self.policy)?.into_libsy(),
808 );
809 config.classify_trigger = self.classify_trigger;
810 config.message_hash_fallback = self.message_hash_fallback;
811 config.recent_turn_window = self.recent_turn_window;
812 config.max_output_tokens = self.max_output_tokens;
813 Ok(config)
814 }
815}
816
817impl AlgorithmSpec {
818 pub(crate) fn restricted_to(&self, allowed: &BTreeSet<&str>) -> AlgorithmResult<Self> {
820 let mut restricted = self.clone();
821 match &mut restricted {
822 Self::Random {
823 targets, weights, ..
824 } => {
825 if let Some(weights) = weights {
826 if weights.len() != targets.len() {
827 return Err(AlgorithmConfigError::new(format!(
828 "random expected {} weights, got {}",
829 targets.len(),
830 weights.len()
831 )));
832 }
833 let mut index = 0;
835 weights.retain(|_| {
836 let keep = allowed.contains(targets[index].as_str());
837 index += 1;
838 keep
839 });
840 }
841 targets.retain(|target| allowed.contains(target.as_str()));
842 if targets.is_empty() {
843 return Err(AlgorithmConfigError::new(
844 "target requirements leave random without an eligible target".to_string(),
845 ));
846 }
847 }
848 Self::LlmClassifier { config } => {
849 if let Some(models) = &mut config.models {
850 models.restrict_to(allowed);
851 }
852 }
853 _ => {}
854 }
855 let subagents = match &mut restricted {
856 Self::Passthrough { subagents, .. }
857 | Self::StageRouter { subagents, .. }
858 | Self::Composite { subagents, .. } => subagents.as_mut(),
859 _ => None,
860 };
861 if let Some(SubagentRouteConfig::LlmClassifier(config)) = subagents
862 && let Some(models) = &mut config.models
863 {
864 models.restrict_to(allowed);
865 }
866 if let Some(target) = restricted
867 .callable_target_names()
868 .into_iter()
869 .find(|target| !allowed.contains(target))
870 {
871 return Err(AlgorithmConfigError::new(format!(
872 "required target {target} does not satisfy target requirements"
873 )));
874 }
875 Ok(restricted)
876 }
877
878 pub(crate) const fn supports_target_restrictions(&self) -> bool {
879 !matches!(self, Self::PrefillRouter { .. })
880 }
881
882 pub(crate) fn classifier_targets(
886 &self,
887 ) -> impl Iterator<Item = (&str, Option<&DecisionJudgeRouteConfig>)> {
888 let (primary, profiles) = match self {
889 Self::LlmClassifier { config, .. }
890 if !matches!(
891 config.classifier_mode(),
892 ClassifierMode::Custom | ClassifierMode::Direct
893 ) =>
894 {
895 (
896 Some((config.classifier_target.as_str(), config.decision.as_ref())),
897 [None, None],
898 )
899 }
900 Self::Composite {
901 classifier,
902 efficient,
903 capable,
904 ..
905 } => (
906 Some((classifier.target.as_str(), classifier.decision.as_ref())),
907 [efficient.as_deref(), capable.as_deref()],
908 ),
909 Self::StageRouter { classifier, .. } => (
910 classifier
911 .as_ref()
912 .map(|c| (c.target.as_str(), c.decision.as_ref())),
913 [None, None],
914 ),
915 _ => (None, [None, None]),
916 };
917 primary.into_iter().chain(
918 profiles
919 .into_iter()
920 .flatten()
921 .filter_map(|profile| profile.classifier.as_ref())
922 .map(|c| (c.target.as_str(), c.decision.as_ref())),
923 )
924 }
925
926 pub(crate) fn infer_decision_judges(&mut self, is_decision_target: impl Fn(&str) -> bool) {
928 let mut infer = |target: &str, decision: &mut Option<DecisionJudgeRouteConfig>| {
929 if is_decision_target(target) {
930 decision.get_or_insert_with(Default::default);
931 }
932 };
933 match self {
934 Self::LlmClassifier { config }
935 if matches!(config.classifier_mode(), ClassifierMode::Capability) =>
936 {
937 infer(&config.classifier_target, &mut config.decision);
938 }
939 Self::StageRouter {
940 classifier: Some(classifier),
941 ..
942 } => classifier.infer_decision(&mut infer),
943 Self::Composite {
944 classifier,
945 efficient,
946 capable,
947 ..
948 } => {
949 classifier.infer_decision(&mut infer);
950 for profile in [efficient, capable].into_iter().flatten() {
951 if let Some(classifier) = &mut profile.classifier {
952 classifier.infer_decision(&mut infer);
953 }
954 }
955 }
956 _ => {}
957 }
958 }
959
960 pub(crate) fn decision_target_names(&self) -> Vec<&str> {
962 let mut names: Vec<&str> = self
963 .classifier_targets()
964 .filter(|(_, decision)| decision.is_some())
965 .map(|(target, _)| target)
966 .collect();
967 if let Self::LlmClassifier { config, .. } = self
968 && matches!(config.classifier_mode(), ClassifierMode::Direct)
969 && let Some(models) = &config.models
970 {
971 names.extend(models.get(&Category::Judge).iter().map(String::as_str));
972 }
973 names
974 }
975
976 pub fn routing_target_names(&self) -> Vec<&str> {
978 match self {
979 Self::Noop { .. } => Vec::new(),
980 Self::Random { targets, .. } => targets.iter().map(String::as_str).collect(),
981 Self::Passthrough {
982 target, subagents, ..
983 } => {
984 let mut names = vec![target.as_str()];
985 if let Some(subagents) = subagents {
986 names.extend(subagents.routing_target_names());
987 }
988 names
989 }
990 Self::LlmClassifier { config, .. } => match config.classifier_mode() {
991 ClassifierMode::Capability => config
992 .weak_target
993 .iter()
994 .chain(&config.strong_target)
995 .map(String::as_str)
996 .collect(),
997 ClassifierMode::Escalation => config
998 .strong_target
999 .iter()
1000 .chain(&config.weak_target)
1001 .map(String::as_str)
1002 .collect(),
1003 ClassifierMode::Custom | ClassifierMode::Direct => config
1004 .models
1005 .as_ref()
1006 .map(CategoryModelConfig::routing_names)
1007 .unwrap_or_default(),
1008 },
1009 Self::StageRouter {
1010 capable_target,
1011 efficient_target,
1012 subagents,
1013 ..
1014 } => {
1015 let mut names = vec![capable_target.as_str(), efficient_target.as_str()];
1016 if let Some(subagents) = subagents {
1017 names.extend(subagents.routing_target_names());
1018 }
1019 names
1020 }
1021 Self::Auto {
1022 capable_target,
1023 efficient_target,
1024 } => vec![capable_target.as_str(), efficient_target.as_str()],
1025 Self::Composite {
1026 stage, subagents, ..
1027 } => {
1028 let mut names = vec![
1029 stage.capable_target.as_str(),
1030 stage.efficient_target.as_str(),
1031 ];
1032 if let Some(subagents) = subagents {
1033 names.extend(subagents.routing_target_names());
1034 }
1035 names
1036 }
1037 Self::Advisor {
1040 executor_target, ..
1041 } => vec![executor_target],
1042 Self::Vgr { config } => {
1043 vec![
1044 config.efficient_target.as_str(),
1045 config.capable_target.as_str(),
1046 ]
1047 }
1048 Self::PrefillRouter { targets, .. } => targets.iter().map(String::as_str).collect(),
1049 }
1050 }
1051
1052 pub fn callable_target_names(&self) -> Vec<&str> {
1057 let mut names = self.routing_target_names();
1058 names.extend(self.judge_target_names());
1059 names
1060 }
1061
1062 pub fn judge_target_names(&self) -> Vec<&str> {
1065 let mut names = Vec::new();
1066 match self {
1067 Self::LlmClassifier { config, .. } => {
1070 if matches!(
1071 config.classifier_mode(),
1072 ClassifierMode::Custom | ClassifierMode::Direct
1073 ) {
1074 names.extend(
1075 config
1076 .models
1077 .as_ref()
1078 .map(|models| models.get(&Category::Judge))
1079 .unwrap_or_default()
1080 .iter()
1081 .map(String::as_str),
1082 );
1083 } else {
1084 names.push(config.classifier_target.as_str());
1085 }
1086 }
1087 Self::StageRouter { .. } | Self::Composite { .. } => {
1088 names.extend(self.classifier_targets().map(|(target, _)| target));
1089 }
1090 Self::Advisor { advisor_target, .. } => names.push(advisor_target),
1091 Self::Vgr { config } => {
1092 names.extend(config.judge_target.as_deref());
1093 names.extend(config.capable_judge_target.as_deref());
1094 }
1095 _ => {}
1096 }
1097 if let Self::Passthrough {
1099 subagents: Some(subagents),
1100 ..
1101 }
1102 | Self::StageRouter {
1103 subagents: Some(subagents),
1104 ..
1105 }
1106 | Self::Composite {
1107 subagents: Some(subagents),
1108 ..
1109 } = self
1110 {
1111 names.extend(subagents.judge_target_names());
1112 }
1113 names
1114 }
1115
1116 fn runtime_model_names(&self, route_name: &str) -> AlgorithmResult<RuntimeModelNames> {
1118 let parent = match self {
1119 Self::Noop { .. } => HashMap::new(),
1120 Self::Random { targets, .. } | Self::PrefillRouter { targets, .. } => {
1121 category_models([(Category::Any, targets.clone())])
1122 }
1123 Self::Passthrough { target, .. } => {
1124 category_models([(Category::Any, vec![target.clone()])])
1125 }
1126 Self::LlmClassifier { config } => {
1127 classifier_runtime_model_names(config.validated_classifier_mode(route_name)?)
1128 }
1129 Self::StageRouter {
1130 capable_target,
1131 efficient_target,
1132 classifier,
1133 ..
1134 } => {
1135 let mut models = category_models([
1136 (Category::Capable, vec![capable_target.clone()]),
1137 (Category::Efficient, vec![efficient_target.clone()]),
1138 (
1139 Category::Any,
1140 vec![capable_target.clone(), efficient_target.clone()],
1141 ),
1142 ]);
1143 if let Some(classifier) = classifier {
1144 models.insert(Category::Judge, vec![classifier.target.clone()]);
1145 }
1146 models
1147 }
1148 Self::Auto {
1149 capable_target,
1150 efficient_target,
1151 } => category_models([
1152 (Category::Capable, vec![capable_target.clone()]),
1153 (Category::Efficient, vec![efficient_target.clone()]),
1154 (
1155 Category::Any,
1156 vec![capable_target.clone(), efficient_target.clone()],
1157 ),
1158 ]),
1159 Self::Composite { stage, .. } => category_models([
1160 (
1161 Category::Judge,
1162 self.judge_target_names()
1163 .into_iter()
1164 .map(str::to_string)
1165 .collect(),
1166 ),
1167 (Category::Capable, vec![stage.capable_target.clone()]),
1168 (Category::Efficient, vec![stage.efficient_target.clone()]),
1169 (
1170 Category::Any,
1171 vec![stage.capable_target.clone(), stage.efficient_target.clone()],
1172 ),
1173 ]),
1174 Self::Advisor {
1175 executor_target,
1176 advisor_target,
1177 ..
1178 } => category_models([
1179 (Category::Efficient, vec![executor_target.clone()]),
1180 (Category::Any, vec![executor_target.clone()]),
1181 (Category::Judge, vec![advisor_target.clone()]),
1182 ]),
1183 Self::Vgr { config } => {
1184 let judge = config
1185 .judge_target
1186 .as_ref()
1187 .unwrap_or(&config.efficient_target);
1188 let judges = std::iter::once(judge)
1189 .chain(&config.capable_judge_target)
1190 .cloned()
1191 .collect();
1192 category_models([
1193 (Category::Efficient, vec![config.efficient_target.clone()]),
1194 (Category::Capable, vec![config.capable_target.clone()]),
1195 (
1196 Category::Any,
1197 vec![
1198 config.efficient_target.clone(),
1199 config.capable_target.clone(),
1200 ],
1201 ),
1202 (Category::Judge, judges),
1203 ])
1204 }
1205 };
1206
1207 let subagents = match self {
1208 Self::Passthrough { subagents, .. }
1209 | Self::StageRouter { subagents, .. }
1210 | Self::Composite { subagents, .. } => subagents.as_ref(),
1211 _ => None,
1212 };
1213 let subagent = subagents
1217 .map(|subagents| subagent_runtime_model_names(subagents, route_name))
1218 .transpose()?;
1219 Ok(RuntimeModelNames { parent, subagent })
1220 }
1221
1222 #[cfg(not(target_os = "wasi"))]
1224 pub(crate) fn routing_response_and_dependency(&self) -> Option<(&str, &str)> {
1225 match self {
1226 Self::LlmClassifier { config, .. }
1227 if matches!(config.classifier_mode(), ClassifierMode::Escalation) =>
1228 {
1229 Some((
1230 config.weak_target.as_deref()?,
1231 config.classifier_target.as_str(),
1232 ))
1233 }
1234 Self::Advisor {
1235 executor_target,
1236 advisor_target,
1237 ..
1238 } => Some((executor_target, advisor_target)),
1239 Self::Vgr { config } => Some((
1240 &config.efficient_target,
1241 config
1242 .judge_target
1243 .as_deref()
1244 .unwrap_or(&config.efficient_target),
1245 )),
1246 Self::Noop { .. }
1247 | Self::Random { .. }
1248 | Self::Passthrough { .. }
1249 | Self::LlmClassifier { .. }
1250 | Self::StageRouter { .. }
1251 | Self::Auto { .. }
1252 | Self::Composite { .. }
1253 | Self::PrefillRouter { .. } => None,
1254 }
1255 }
1256
1257 pub fn build(
1259 &self,
1260 route_name: &str,
1261 targets: &BTreeMap<String, ModelId>,
1262 ) -> AlgorithmResult<Arc<dyn Algorithm>> {
1263 build_algorithm(route_name, self, targets)
1264 }
1265
1266 pub fn build_with_runtime_models(
1268 &self,
1269 route_name: &str,
1270 targets: &BTreeMap<String, ModelId>,
1271 ) -> AlgorithmResult<(Arc<dyn Algorithm>, RuntimeModels)> {
1272 let algorithm = self.build(route_name, targets)?;
1273 let names = self.runtime_model_names(route_name)?;
1274 let mut models =
1275 RuntimeModels::new(resolve_runtime_models(route_name, names.parent, targets)?);
1276 if let Some(subagent) = names.subagent {
1277 models = models.with_subagent(resolve_runtime_models(route_name, subagent, targets)?);
1278 }
1279 Ok((algorithm, models))
1280 }
1281}
1282
1283struct RuntimeModelNames {
1285 parent: HashMap<Category, Vec<String>>,
1287 subagent: Option<HashMap<Category, Vec<String>>>,
1289}
1290
1291fn category_models(
1292 entries: impl IntoIterator<Item = (Category, Vec<String>)>,
1293) -> HashMap<Category, Vec<String>> {
1294 entries.into_iter().collect()
1295}
1296
1297fn custom_runtime_model_names(config: &CategoryModelConfig) -> HashMap<Category, Vec<String>> {
1298 config.groups().collect()
1299}
1300
1301fn classifier_runtime_model_names(
1302 config: LlmClassifierModeConfig,
1303) -> HashMap<Category, Vec<String>> {
1304 match config {
1305 LlmClassifierModeConfig::Capability(config) => category_models([
1306 (Category::Judge, vec![config.classifier_target]),
1307 (Category::Efficient, vec![config.weak_target.clone()]),
1308 (Category::Capable, vec![config.strong_target.clone()]),
1309 (
1310 Category::Any,
1311 vec![config.weak_target, config.strong_target],
1312 ),
1313 ]),
1314 LlmClassifierModeConfig::Escalation(config) => category_models([
1315 (Category::Judge, vec![config.classifier_target]),
1316 (Category::Efficient, vec![config.weak_target.clone()]),
1317 (Category::Capable, vec![config.strong_target.clone()]),
1318 (
1319 Category::Any,
1320 vec![config.strong_target, config.weak_target],
1321 ),
1322 ]),
1323 LlmClassifierModeConfig::Custom(config) => custom_runtime_model_names(&config.models),
1324 LlmClassifierModeConfig::Direct { models, .. } => custom_runtime_model_names(&models),
1325 }
1326}
1327
1328fn subagent_runtime_model_names(
1329 config: &SubagentRouteConfig,
1330 route_name: &str,
1331) -> AlgorithmResult<HashMap<Category, Vec<String>>> {
1332 match config {
1333 SubagentRouteConfig::Passthrough { target } => {
1334 Ok(category_models([(Category::Any, vec![target.clone()])]))
1335 }
1336 SubagentRouteConfig::LlmClassifier(config) => {
1337 let LlmClassifierModeConfig::Custom(config) =
1338 config.validated_classifier_mode(route_name)?
1339 else {
1340 return Err(AlgorithmConfigError::new(format!(
1341 "route {route_name}: subagents llm_classifier only supports mode custom"
1342 )));
1343 };
1344 Ok(custom_runtime_model_names(&config.models))
1345 }
1346 }
1347}
1348impl LlmClassifierRouteConfig {
1349 fn classifier_mode(&self) -> ClassifierMode {
1350 let default = if self.escalation.is_some() {
1351 ClassifierMode::Escalation
1352 } else {
1353 ClassifierMode::Capability
1354 };
1355 self.mode.unwrap_or(default)
1356 }
1357
1358 fn validated_classifier_mode(
1359 &self,
1360 route_name: &str,
1361 ) -> AlgorithmResult<LlmClassifierModeConfig> {
1362 let Self {
1363 classifier_target,
1364 mode,
1365 strong_target,
1366 weak_target,
1367 base_threshold,
1368 threshold_step,
1369 unmatched_steps,
1370 fail_open,
1371 classify_trigger,
1372 message_hash_fallback,
1373 recent_turn_window,
1374 prompt,
1375 response_format_type,
1376 max_output_tokens,
1377 judge_deadline_ms,
1378 escalation,
1379 decision,
1380 models,
1381 default_target,
1382 response_schema,
1383 policy,
1384 } = self;
1385
1386 let selected_mode = match (mode, escalation.is_some()) {
1387 (Some(mode), _) => *mode,
1388 (None, true) => ClassifierMode::Escalation,
1389 (None, false) => ClassifierMode::Capability,
1390 };
1391
1392 if decision.is_some() && !matches!(selected_mode, ClassifierMode::Capability) {
1393 return Err(AlgorithmConfigError::new(format!(
1394 "llm_classifier route {route_name}: decision requires capability mode"
1395 )));
1396 }
1397
1398 if !matches!(
1399 selected_mode,
1400 ClassifierMode::Capability | ClassifierMode::Direct
1401 ) && *fail_open == Some(true)
1402 {
1403 return Err(AlgorithmConfigError::new(format!(
1404 "llm_classifier route {route_name}: fail_open = true requires capability or direct mode"
1405 )));
1406 }
1407
1408 match selected_mode {
1409 ClassifierMode::Capability => {
1410 if escalation.is_some() {
1411 return Err(classifier_field_error(
1412 route_name,
1413 "escalation",
1414 "capability",
1415 ));
1416 }
1417 reject_custom_fields(
1418 route_name,
1419 "capability",
1420 models,
1421 default_target,
1422 response_schema,
1423 policy,
1424 )?;
1425 let judge = if let Some(decision) = decision {
1426 if base_threshold.is_some()
1427 || threshold_step.is_some()
1428 || unmatched_steps.is_some()
1429 || prompt.is_some()
1430 || *response_format_type != ClassifierResponseFormat::JsonSchema
1431 || *max_output_tokens != default_classifier_max_output_tokens()
1432 || judge_deadline_ms.is_some()
1433 {
1434 return Err(AlgorithmConfigError::new(format!(
1435 "llm_classifier route {route_name}: decision cannot use LLM judge settings; use decision.cutoff and decision.instructions"
1436 )));
1437 }
1438 CapabilityJudgeRouteConfig::Decision(decision.clone())
1439 } else {
1440 CapabilityJudgeRouteConfig::Llm(LlmCapabilityConfig {
1441 base_threshold: required_classifier_field(
1442 route_name,
1443 "base_threshold",
1444 base_threshold,
1445 )?,
1446 threshold_step: threshold_step.unwrap_or_default(),
1447 unmatched_steps: unmatched_steps.unwrap_or(DEFAULT_UNMATCHED_STEPS),
1448 contract: classifier_contract(prompt.as_deref())
1449 .with_response_format_type(*response_format_type),
1450 max_output_tokens: *max_output_tokens,
1451 judge_deadline_ms: *judge_deadline_ms,
1452 })
1453 };
1454 Ok(LlmClassifierModeConfig::Capability(
1455 CapabilityClassifierRouteConfig {
1456 classifier_target: classifier_target.clone(),
1457 fail_open: fail_open.unwrap_or(true),
1458 strong_target: required_classifier_field(
1459 route_name,
1460 "strong_target",
1461 strong_target,
1462 )?,
1463 weak_target: required_classifier_field(
1464 route_name,
1465 "weak_target",
1466 weak_target,
1467 )?,
1468 judge,
1469 classify_trigger: *classify_trigger,
1470 message_hash_fallback: *message_hash_fallback,
1471 recent_turn_window: *recent_turn_window,
1472 },
1473 ))
1474 }
1475 ClassifierMode::Escalation => {
1476 reject_custom_fields(
1477 route_name,
1478 "escalation",
1479 models,
1480 default_target,
1481 response_schema,
1482 policy,
1483 )?;
1484 if *classify_trigger != ClassifyTrigger::EveryRequest {
1485 return Err(AlgorithmConfigError::new(format!(
1486 "llm_classifier route {route_name} mode escalation cannot use classify_trigger"
1487 )));
1488 }
1489 let has_capability_settings = base_threshold.is_some()
1490 || threshold_step.is_some()
1491 || fail_open.is_some()
1492 || *message_hash_fallback
1493 || recent_turn_window.is_some();
1494 if unmatched_steps.is_some() || (mode.is_some() && has_capability_settings) {
1498 return Err(AlgorithmConfigError::new(format!(
1499 "llm_classifier route {route_name} mode escalation cannot use capability routing settings"
1500 )));
1501 }
1502 if has_capability_settings {
1503 tracing::warn!(
1506 "llm_classifier route {route_name} runs in escalation mode because it has an escalation table; base_threshold, threshold_step, fail_open, recent_turn_window and message_hash_fallback are ignored in this mode. Set mode = \"escalation\" and remove them."
1507 );
1508 }
1509 Ok(LlmClassifierModeConfig::Escalation(
1510 EscalationClassifierRouteConfig {
1511 classifier_target: classifier_target.clone(),
1512 strong_target: required_classifier_field(
1513 route_name,
1514 "strong_target",
1515 strong_target,
1516 )?,
1517 weak_target: required_classifier_field(
1518 route_name,
1519 "weak_target",
1520 weak_target,
1521 )?,
1522 prompt: prompt.clone(),
1523 response_format_type: *response_format_type,
1524 max_output_tokens: *max_output_tokens,
1525 judge_deadline_ms: *judge_deadline_ms,
1526 judge: required_classifier_field(route_name, "escalation", escalation)?,
1527 },
1528 ))
1529 }
1530 ClassifierMode::Custom => {
1531 if !classifier_target.is_empty()
1532 || strong_target.is_some()
1533 || weak_target.is_some()
1534 || base_threshold.is_some()
1535 || threshold_step.is_some()
1536 || unmatched_steps.is_some()
1537 || escalation.is_some()
1538 || *response_format_type != ClassifierResponseFormat::JsonSchema
1539 {
1540 return Err(AlgorithmConfigError::new(format!(
1541 "llm_classifier route {route_name} mode custom cannot use capability or escalation fields and response_format_type must be 'json_schema'"
1542 )));
1543 }
1544 let (models, default_target) =
1545 validated_models(route_name, models, default_target)?;
1546 Ok(LlmClassifierModeConfig::Custom(
1547 CustomClassifierRouteConfig {
1548 models,
1549 default_target,
1550 prompt: required_classifier_field(route_name, "prompt", prompt)?,
1551 response_schema: required_classifier_field(
1552 route_name,
1553 "response_schema",
1554 response_schema,
1555 )?,
1556 policy: required_classifier_field(route_name, "policy", policy)?,
1557 classify_trigger: *classify_trigger,
1558 message_hash_fallback: *message_hash_fallback,
1559 recent_turn_window: *recent_turn_window,
1560 max_output_tokens: *max_output_tokens,
1561 judge_deadline_ms: *judge_deadline_ms,
1562 },
1563 ))
1564 }
1565 ClassifierMode::Direct => {
1566 if !classifier_target.is_empty()
1567 || strong_target.is_some()
1568 || weak_target.is_some()
1569 || base_threshold.is_some()
1570 || threshold_step.is_some()
1571 || unmatched_steps.is_some()
1572 || escalation.is_some()
1573 || prompt.is_some()
1574 || response_schema.is_some()
1575 || policy.is_some()
1576 || *response_format_type != ClassifierResponseFormat::JsonSchema
1577 || *max_output_tokens != default_classifier_max_output_tokens()
1578 || judge_deadline_ms.is_some()
1579 {
1580 return Err(AlgorithmConfigError::new(format!(
1581 "llm_classifier route {route_name} mode direct cannot use capability, escalation, or LLM judge fields"
1582 )));
1583 }
1584 let (models, default_target) =
1585 validated_models(route_name, models, default_target)?;
1586 let options = models
1587 .groups()
1588 .filter(|(category, targets)| {
1589 !targets.is_empty() && !matches!(category, Category::Judge | Category::Any)
1590 })
1591 .map(|(category, _)| category)
1592 .collect();
1593 Ok(LlmClassifierModeConfig::Direct {
1594 models,
1595 default_target,
1596 config: DirectClassifierConfig {
1597 options,
1598 fail_open: fail_open.unwrap_or(true),
1599 classify_trigger: *classify_trigger,
1600 message_hash_fallback: *message_hash_fallback,
1601 recent_turn_window: *recent_turn_window,
1602 },
1603 })
1604 }
1605 }
1606 }
1607}
1608
1609fn validated_models(
1610 route_name: &str,
1611 models: &Option<CategoryModelConfig>,
1612 default_target: &Option<String>,
1613) -> AlgorithmResult<(CategoryModelConfig, Category)> {
1614 let models = required_classifier_field(route_name, "models", models)?;
1615 let default_target: Category =
1616 required_classifier_field(route_name, "default_target", default_target)?
1617 .parse()
1618 .map_err(|error| {
1619 AlgorithmConfigError::new(format!(
1620 "llm_classifier route {route_name} has invalid default_target: {error}"
1621 ))
1622 })?;
1623 if default_target == Category::Judge {
1624 return Err(AlgorithmConfigError::new(format!(
1625 "llm_classifier route {route_name} default_target cannot be judge"
1626 )));
1627 }
1628 models.validate(route_name, &default_target)?;
1629 Ok((models, default_target))
1630}
1631
1632fn reject_custom_fields(
1633 route_name: &str,
1634 mode: &str,
1635 models: &Option<CategoryModelConfig>,
1636 default_target: &Option<String>,
1637 response_schema: &Option<String>,
1638 policy: &Option<ClassifierPolicyConfig>,
1639) -> AlgorithmResult<()> {
1640 if models.is_some() || default_target.is_some() || response_schema.is_some() || policy.is_some()
1641 {
1642 return Err(AlgorithmConfigError::new(format!(
1643 "llm_classifier route {route_name} mode {mode} cannot use custom classifier fields"
1644 )));
1645 }
1646 Ok(())
1647}
1648
1649fn classifier_field_error(route_name: &str, field: &str, mode: &str) -> AlgorithmConfigError {
1650 AlgorithmConfigError::new(format!(
1651 "llm_classifier route {route_name} mode {mode} cannot use {field}"
1652 ))
1653}
1654
1655fn required_classifier_field<T: Clone>(
1656 route_name: &str,
1657 field: &str,
1658 value: &Option<T>,
1659) -> AlgorithmResult<T> {
1660 value.clone().ok_or_else(|| {
1661 AlgorithmConfigError::new(format!(
1662 "llm_classifier route {route_name} requires {field}"
1663 ))
1664 })
1665}
1666
1667fn build_subagent_router_config(
1668 route_name: &str,
1669 config: &SubagentRouteConfig,
1670 targets: &BTreeMap<String, ModelId>,
1671) -> AlgorithmResult<SubagentRouterConfig> {
1672 match config {
1673 SubagentRouteConfig::Passthrough { target } => {
1674 resolve_target_model_id(route_name, target, targets)?;
1675 Ok(SubagentRouterConfig::fixed_target())
1676 }
1677 SubagentRouteConfig::LlmClassifier(config) => {
1678 let LlmClassifierModeConfig::Custom(config) =
1679 config.validated_classifier_mode(route_name)?
1680 else {
1681 return Err(AlgorithmConfigError::new(format!(
1682 "route {route_name}: subagents llm_classifier only supports mode custom"
1683 )));
1684 };
1685 for name in config.models.all_names() {
1686 resolve_target_model_id(route_name, name, targets)?;
1687 }
1688 if config.models.get(&config.default_target).is_empty() {
1689 return Err(AlgorithmConfigError::new(format!(
1690 "route {route_name}: subagents llm_classifier has no model for default category {}",
1691 config.default_target.as_str()
1692 )));
1693 }
1694 let response_schema =
1695 serde_json::from_str(&config.response_schema).map_err(|error| {
1696 AlgorithmConfigError::with_source(
1697 format!(
1698 "route {route_name}: subagents llm_classifier response_schema is invalid JSON: {error}"
1699 ),
1700 error,
1701 )
1702 })?;
1703 warn_single_target_classifier(route_name, &config.models);
1704 let mut classifier_config = CustomClassifierConfig::new(
1705 config.prompt,
1706 response_schema,
1707 config.policy.into_libsy(),
1708 );
1709 classifier_config.recent_turn_window = config.recent_turn_window;
1710 classifier_config.max_output_tokens = config.max_output_tokens;
1711 classifier_config.judge_deadline_ms = config.judge_deadline_ms;
1712 let classifier = Arc::new(
1713 LlmTaskClassifier::new(LlmClassifierConfig::Custom {
1714 default_target: config.default_target.clone(),
1715 config: classifier_config,
1716 })
1717 .map_err(|error| {
1718 AlgorithmConfigError::with_source(
1719 format!("route {route_name}: subagents llm_classifier: {error}"),
1720 error,
1721 )
1722 })?,
1723 );
1724 Ok(SubagentRouterConfig {
1725 classifier,
1726 default_target: config.default_target,
1727 classify_trigger: config.classify_trigger,
1728 message_hash_fallback: config.message_hash_fallback,
1729 })
1730 }
1731 }
1732}
1733
1734fn attach_subagent_router(
1735 route_name: &str,
1736 parent: Arc<dyn Algorithm>,
1737 config: Option<&SubagentRouteConfig>,
1738 targets: &BTreeMap<String, ModelId>,
1739) -> AlgorithmResult<Arc<dyn Algorithm>> {
1740 let Some(config) = config else {
1741 return Ok(parent);
1742 };
1743 let config = build_subagent_router_config(route_name, config, targets)?;
1744 let algorithm = SubagentRouter::new(parent, config).map_err(|error| {
1745 AlgorithmConfigError::with_source(
1746 format!("route {route_name}: subagent routing: {error}"),
1747 error,
1748 )
1749 })?;
1750 Ok(Arc::new(algorithm))
1751}
1752
1753fn build_algorithm(
1757 route_name: &str,
1758 config: &AlgorithmSpec,
1759 targets: &BTreeMap<String, ModelId>,
1760) -> AlgorithmResult<Arc<dyn Algorithm>> {
1761 match config {
1762 AlgorithmSpec::Noop { .. } => Ok(Arc::new(Noop {})),
1763 AlgorithmSpec::Random {
1764 targets: names,
1765 weights,
1766 seed,
1767 } => {
1768 if let Some(weights) = weights
1771 && weights.len() != names.len()
1772 {
1773 return Err(AlgorithmConfigError::new(format!(
1774 "random route {route_name}: expected {} weights, got {}",
1775 names.len(),
1776 weights.len()
1777 )));
1778 }
1779 let mut seen = BTreeSet::new();
1780 if let Some(duplicate) = names.iter().find(|name| !seen.insert(*name)) {
1781 return Err(AlgorithmConfigError::new(format!(
1782 "random route {route_name}: targets must be unique, {duplicate} is repeated"
1783 )));
1784 }
1785 let algorithm = Random::new(weights.clone(), *seed).map_err(|error| {
1786 AlgorithmConfigError::with_source(
1787 format!("random route {route_name}: {error}"),
1788 error,
1789 )
1790 })?;
1791 Ok(Arc::new(algorithm))
1792 }
1793 AlgorithmSpec::Passthrough { subagents, .. } => {
1794 let algorithm = Passthrough;
1795 let parent: Arc<dyn Algorithm> = Arc::new(algorithm);
1796 attach_subagent_router(route_name, parent, subagents.as_ref(), targets)
1797 }
1798 AlgorithmSpec::LlmClassifier {
1799 config: classifier_config,
1800 ..
1801 } => {
1802 let mode = classifier_config.validated_classifier_mode(route_name)?;
1803 let algorithm = match mode {
1804 LlmClassifierModeConfig::Capability(config) => {
1805 let classifier_config = config.into_task_config();
1806 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1807 config: classifier_config,
1808 })
1809 }
1810 LlmClassifierModeConfig::Escalation(config) => {
1811 LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
1812 contract: classifier_contract(config.prompt.as_deref())
1813 .with_response_format_type(config.response_format_type),
1814 config: config.judge,
1815 max_output_tokens: config.max_output_tokens,
1816 judge_deadline_ms: config.judge_deadline_ms,
1817 })
1818 }
1819 LlmClassifierModeConfig::Custom(config) => {
1820 let response_schema = serde_json::from_str(&config.response_schema).map_err(
1821 |error| {
1822 AlgorithmConfigError::with_source(
1823 format!(
1824 "llm_classifier route {route_name}: response_schema is invalid JSON: {error}"
1825 ),
1826 error,
1827 )
1828 },
1829 )?;
1830 warn_single_target_classifier(route_name, &config.models);
1831 let mut classifier_config = CustomClassifierConfig::new(
1832 config.prompt,
1833 response_schema,
1834 config.policy.into_libsy(),
1835 );
1836 classifier_config.classify_trigger = config.classify_trigger;
1837 classifier_config.message_hash_fallback = config.message_hash_fallback;
1838 classifier_config.recent_turn_window = config.recent_turn_window;
1839 classifier_config.max_output_tokens = config.max_output_tokens;
1840 classifier_config.judge_deadline_ms = config.judge_deadline_ms;
1841 LlmTaskClassifier::new(LlmClassifierConfig::Custom {
1842 default_target: config.default_target,
1843 config: classifier_config,
1844 })
1845 }
1846 LlmClassifierModeConfig::Direct {
1847 default_target,
1848 config,
1849 ..
1850 } => LlmTaskClassifier::new(LlmClassifierConfig::Direct {
1851 default_target,
1852 config,
1853 }),
1854 }
1855 .map_err(|error| {
1856 AlgorithmConfigError::with_source(
1857 format!("llm_classifier route {route_name}: {error}"),
1858 error,
1859 )
1860 })?;
1861 Ok(Arc::new(algorithm))
1862 }
1863 AlgorithmSpec::StageRouter {
1864 capable_target,
1865 efficient_target,
1866 capable_first_threshold,
1867 settings,
1868 picker,
1869 classifier,
1870 subagents,
1871 ..
1872 } => {
1873 if matches!(picker, PickerMode::CapableFirst) {
1874 tracing::warn!(
1875 "stage_router route {route_name} uses picker \"capable_first\", which is experimental: published thresholds and routing results all come from \"efficient_first\", so there is no calibrated confidence_threshold for it and no measured accuracy or cost. Use \"efficient_first\" unless you are running your own calibration."
1876 );
1877 }
1878 warn_identical_tiers(route_name, capable_target, efficient_target);
1879 let threshold = settings.confidence_threshold.ok_or_else(|| {
1880 AlgorithmConfigError::new(format!(
1881 "stage_router route {route_name}: confidence_threshold is required"
1882 ))
1883 })?;
1884 let mut config = settings.stage_config(settings, *picker, threshold);
1885 config.capable_first_threshold = *capable_first_threshold;
1886 config.llm_fallback = classifier
1889 .as_ref()
1890 .map(|classifier| {
1891 classifier.stage_fallback(route_name, capable_target, efficient_target, targets)
1892 })
1893 .transpose()?;
1894 let algorithm = StageRouter::new(config).map_err(|error| {
1895 AlgorithmConfigError::with_source(
1896 format!("stage_router route {route_name}: {error}"),
1897 error,
1898 )
1899 })?;
1900 let parent: Arc<dyn Algorithm> = Arc::new(algorithm);
1901 attach_subagent_router(route_name, parent, subagents.as_ref(), targets)
1902 }
1903 AlgorithmSpec::Auto {
1904 capable_target,
1905 efficient_target,
1906 } => {
1907 warn_identical_tiers(route_name, capable_target, efficient_target);
1908 let config = StageRouterConfig::new(PickerMode::EfficientFirst, 0.5);
1909 let algorithm = StageRouter::new(config).map_err(|error| {
1910 AlgorithmConfigError::with_source(
1911 format!("auto route {route_name}: {error}"),
1912 error,
1913 )
1914 })?;
1915 Ok(Arc::new(algorithm))
1916 }
1917 AlgorithmSpec::Composite {
1918 classifier,
1919 stage,
1920 efficient,
1921 capable,
1922 subagents,
1923 } => {
1924 stage.warn_identical_tiers(route_name);
1925 let judge = if matches!(classifier.mode, Some(ClassifierMode::Custom)) {
1926 CompositeJudgeConfig::Custom(classifier.custom_classifier_config(route_name)?)
1927 } else {
1928 CompositeJudgeConfig::Capability(classifier.task_classifier_config(
1929 route_name,
1930 &stage.capable_target,
1931 &stage.efficient_target,
1932 )?)
1933 };
1934 let shared = &stage.settings;
1935 let algorithm = if efficient.is_some() || capable.is_some() {
1936 let defaults = StageProfileConfig::default();
1937 let profiles = [
1938 (
1939 efficient,
1940 PickerMode::EfficientFirst,
1941 shared.confidence_threshold.unwrap_or(0.5),
1942 ),
1943 (
1944 capable,
1945 PickerMode::CapableFirst,
1946 stage.capable_first_threshold,
1947 ),
1948 ]
1949 .map(|(profile, picker, threshold)| -> AlgorithmResult<_> {
1950 let profile = profile.as_deref().unwrap_or(&defaults);
1951 let mut config = profile.settings.stage_config(
1952 shared,
1953 profile.picker.unwrap_or(picker),
1954 threshold,
1955 );
1956 config.llm_fallback = profile
1957 .classifier
1958 .as_ref()
1959 .map(|classifier| {
1960 classifier.stage_fallback(
1961 route_name,
1962 &stage.capable_target,
1963 &stage.efficient_target,
1964 targets,
1965 )
1966 })
1967 .transpose()?;
1968 Ok(config)
1969 });
1970 let [profile_0, profile_1] = profiles;
1971 CompositeRouter::new_with_profiles(judge, profile_0?, profile_1?)
1972 } else {
1973 let mut config = shared.stage_config(shared, PickerMode::EfficientFirst, 0.5);
1974 config.capable_first_threshold = stage.capable_first_threshold;
1975 CompositeRouter::new(CompositeRouterConfig {
1976 judge,
1977 stage: config,
1978 })
1979 }
1980 .map_err(|error| {
1981 AlgorithmConfigError::with_source(
1982 format!("composite route {route_name}: {error}"),
1983 error,
1984 )
1985 })?;
1986 let parent: Arc<dyn Algorithm> = Arc::new(algorithm);
1987 attach_subagent_router(route_name, parent, subagents.as_ref(), targets)
1988 }
1989 AlgorithmSpec::Advisor {
1990 reviewer_system_prompt,
1991 redo_feedback_prefix,
1992 gate_trigger,
1993 gate_trigger_pattern,
1994 max_reviews,
1995 gate_stall_turns,
1996 gate_min_tool_results,
1997 advisor_max_tokens,
1998 advisor_temperature,
1999 transcript_max_chars,
2000 fail_open,
2001 ..
2002 } => {
2003 if *gate_trigger == AdvisorTriggerConfig::NoToolCall && gate_trigger_pattern.is_some() {
2006 return Err(AlgorithmConfigError::new(format!(
2007 "advisor route {route_name}: gate_trigger_pattern requires \
2008 gate_trigger = \"pattern\""
2009 )));
2010 }
2011 let mut config = AdvisorGateConfig::default();
2012 if let Some(prompt) = reviewer_system_prompt {
2013 config.reviewer_system_prompt = prompt.clone();
2014 }
2015 if let Some(prefix) = redo_feedback_prefix {
2016 config.redo_feedback_prefix = prefix.clone();
2017 }
2018 config.gate_trigger = match gate_trigger {
2019 AdvisorTriggerConfig::NoToolCall => GateTrigger::NoToolCall,
2020 AdvisorTriggerConfig::Pattern => {
2021 GateTrigger::Pattern(gate_trigger_pattern.clone().unwrap_or_default())
2022 }
2023 };
2024 config.max_reviews = *max_reviews;
2025 config.gate_stall_turns = *gate_stall_turns;
2026 config.gate_min_tool_results = *gate_min_tool_results;
2027 config.advisor_max_tokens = *advisor_max_tokens;
2028 config.advisor_temperature = *advisor_temperature;
2029 config.transcript_max_chars = *transcript_max_chars;
2030 config.fail_open = *fail_open;
2031 let algorithm = AdvisorGate::new(config).map_err(|error| {
2032 AlgorithmConfigError::with_source(
2033 format!("advisor route {route_name}: {error}"),
2034 error,
2035 )
2036 })?;
2037 Ok(Arc::new(algorithm))
2038 }
2039 AlgorithmSpec::Vgr { config } => build_vgr(route_name, config, targets),
2040 AlgorithmSpec::PrefillRouter {
2041 targets: names,
2042 checkpoint,
2043 device,
2044 cache_dir,
2045 max_length,
2046 batch_size,
2047 } => {
2048 #[cfg(feature = "prefill-router")]
2049 {
2050 let targets = names
2051 .iter()
2052 .map(|name| resolve_target_model_id(route_name, name, targets))
2053 .collect::<AlgorithmResult<Vec<_>>>()?;
2054 let mut config = prefill_router::PrefillRouterConfig::new(targets, checkpoint);
2055 config.device.clone_from(device);
2056 config.cache_dir.clone_from(cache_dir);
2057 if let Some(max_length) = max_length {
2058 config.max_length = *max_length;
2059 }
2060 if let Some(batch_size) = batch_size {
2061 config.batch_size = *batch_size;
2062 }
2063 let algorithm = config.build().map_err(|error| {
2064 AlgorithmConfigError::with_source(
2065 format!("prefill_router route {route_name}: {error}"),
2066 error,
2067 )
2068 })?;
2069 Ok(Arc::new(algorithm))
2070 }
2071
2072 #[cfg(not(feature = "prefill-router"))]
2073 {
2074 let _ = (
2075 names, checkpoint, device, cache_dir, max_length, batch_size, targets,
2076 );
2077 Err(AlgorithmConfigError::new(format!(
2078 "prefill_router route {route_name} requires the `prefill-router` Cargo feature"
2079 )))
2080 }
2081 }
2082 }
2083}
2084
2085fn build_vgr(
2086 route_name: &str,
2087 config: &VgrRouteConfig,
2088 targets: &BTreeMap<String, ModelId>,
2089) -> AlgorithmResult<Arc<dyn Algorithm>> {
2090 let efficient = resolve_target_model_id(route_name, &config.efficient_target, targets)?;
2091 let capable = resolve_target_model_id(route_name, &config.capable_target, targets)?;
2092 let optional = |name: &Option<String>| {
2093 name.as_deref()
2094 .map(|name| resolve_target_model_id(route_name, name, targets))
2095 .transpose()
2096 };
2097 let mut runtime = VgrConfig::new(efficient, capable);
2098 runtime.targets.judge = optional(&config.judge_target)?;
2099 runtime.targets.capable_judge = optional(&config.capable_judge_target)?;
2100 runtime.mode = match config.mode {
2101 VgrModeConfig::Off => libsy::ServingMode::Off,
2102 VgrModeConfig::Evaluate => libsy::ServingMode::Evaluate,
2103 VgrModeConfig::Shadow => libsy::ServingMode::Shadow,
2104 VgrModeConfig::Active => libsy::ServingMode::Active {
2105 approval: config.active_approval.clone().unwrap_or_default(),
2106 },
2107 };
2108 runtime.deadline = vgr_duration(route_name, "deadline_seconds", config.deadline_seconds)?;
2109 runtime.bounded_efficient = config.bounded_efficient;
2110 runtime.task_typing = config.task_typing;
2111 runtime.confirmed_recovery_min_clean_tail = config.confirmed_recovery_min_clean_tail;
2112 runtime.agentic_handoff = config.agentic_handoff;
2113 runtime.compact_handoff = config.compact_handoff;
2114 runtime.latch_escalation = config.latch_escalation;
2115 runtime.efficient_turn_budget = config
2116 .efficient_turn_budget_seconds
2117 .map(|seconds| vgr_duration(route_name, "efficient_turn_budget_seconds", seconds))
2118 .transpose()?;
2119 runtime.breaker = BreakerConfig {
2120 threshold: config.breaker_threshold,
2121 cooldown: vgr_duration(
2122 route_name,
2123 "breaker_cooldown_seconds",
2124 config.breaker_cooldown_seconds,
2125 )?,
2126 };
2127 Vgr::new(runtime)
2128 .map(|algorithm| Arc::new(algorithm) as Arc<dyn Algorithm>)
2129 .map_err(|error| {
2130 AlgorithmConfigError::with_source(format!("vgr route {route_name}: {error}"), error)
2131 })
2132}
2133
2134fn vgr_duration(route: &str, field: &str, seconds: f64) -> AlgorithmResult<std::time::Duration> {
2135 let message = || format!("vgr route {route}: {field} must be a positive number");
2136 let duration = std::time::Duration::try_from_secs_f64(seconds)
2137 .map_err(|error| AlgorithmConfigError::with_source(message(), error))?;
2138 if duration.is_zero() {
2139 return Err(AlgorithmConfigError::new(message()));
2140 }
2141 Ok(duration)
2142}
2143
2144const fn default_max_reviews() -> u32 {
2145 1
2146}
2147
2148const fn default_advisor_max_tokens() -> u64 {
2149 2048
2150}
2151
2152const fn default_transcript_max_chars() -> usize {
2153 200_000
2154}
2155
2156const fn default_fail_open() -> bool {
2157 true
2158}
2159
2160fn classifier_contract(prompt: Option<&str>) -> ClassifierContractConfig {
2161 prompt.map_or_else(ClassifierContractConfig::default, |prompt| {
2162 ClassifierContractConfig::default().with_prompt(prompt)
2163 })
2164}
2165
2166fn default_classifier_max_output_tokens() -> u64 {
2167 LlmCapabilityConfig::default().max_output_tokens
2168}
2169
2170fn warn_identical_tiers(route_name: &str, capable_target: &str, efficient_target: &str) {
2174 if capable_target == efficient_target {
2175 tracing::warn!(
2176 route = route_name,
2177 target = capable_target,
2178 "capable_target and efficient_target name the same target, so every request goes to one model; use passthrough for a single model, or point the tiers at two targets"
2179 );
2180 }
2181}
2182
2183fn warn_single_target_classifier(route_name: &str, models: &CategoryModelConfig) {
2184 if models.routing_names().len() < 2 {
2185 tracing::warn!(
2186 route = route_name,
2187 "custom classifier has only one routing target; judge calls add cost without a routing choice. Use passthrough or add another completion target in routes.<name>, or in routes.<name>.subagents for a subagent classifier."
2188 );
2189 }
2190}
2191
2192const fn default_vgr_deadline() -> f64 {
2193 30.0
2194}
2195
2196const fn default_true() -> bool {
2197 true
2198}
2199
2200const fn default_vgr_breaker_threshold() -> u32 {
2201 5
2202}
2203
2204const fn default_vgr_breaker_cooldown() -> f64 {
2205 30.0
2206}
2207
2208fn resolve_target_model_id(
2209 route_name: &str,
2210 name: &str,
2211 targets: &BTreeMap<String, ModelId>,
2212) -> AlgorithmResult<ModelId> {
2213 targets.get(name).cloned().ok_or_else(|| {
2214 AlgorithmConfigError::new(format!(
2215 "route {route_name} references unknown target {name}"
2216 ))
2217 })
2218}
2219
2220fn resolve_runtime_models(
2221 route_name: &str,
2222 names: HashMap<Category, Vec<String>>,
2223 targets: &BTreeMap<String, ModelId>,
2224) -> AlgorithmResult<HashMap<Category, Vec<ModelId>>> {
2225 names
2226 .into_iter()
2227 .map(|(category, names)| {
2228 let models = names
2229 .into_iter()
2230 .map(|name| resolve_target_model_id(route_name, &name, targets))
2231 .collect::<AlgorithmResult<Vec<_>>>()?;
2232 Ok((category, models))
2233 })
2234 .collect()
2235}
2236
2237#[cfg(test)]
2238mod tests {
2239 use super::*;
2240
2241 #[test]
2242 fn restricted_random_preserves_weight_positions() -> AlgorithmResult<()> {
2243 let allowed = BTreeSet::from(["private_a", "private_b"]);
2244 let mut algorithm = AlgorithmSpec::Random {
2245 targets: ["public_a", "private_a", "public_b", "private_b"]
2246 .map(String::from)
2247 .to_vec(),
2248 weights: Some(vec![1.0, 2.0, 3.0, 4.0]),
2249 seed: Some(42),
2250 };
2251 let AlgorithmSpec::Random {
2252 targets,
2253 weights,
2254 seed,
2255 } = algorithm.restricted_to(&allowed)?
2256 else {
2257 unreachable!();
2258 };
2259 assert_eq!(targets, ["private_a", "private_b"]);
2260 assert_eq!(weights, Some(vec![2.0, 4.0]));
2261 assert_eq!(seed, Some(42));
2262 if let AlgorithmSpec::Random { weights, .. } = &mut algorithm {
2263 *weights = Some(vec![1.0]);
2264 }
2265 assert!(algorithm.restricted_to(&allowed).is_err());
2266 Ok(())
2267 }
2268
2269 fn custom_classifier() -> LlmClassifierRouteConfig {
2270 LlmClassifierRouteConfig {
2271 mode: Some(ClassifierMode::Custom),
2272 models: Some(CategoryModelConfig(BTreeMap::from([
2273 ("any".into(), vec!["public".into(), "private".into()]),
2274 ("efficient".into(), vec!["public".into(), "private".into()]),
2275 ("judge".into(), vec!["judge".into()]),
2276 ("research".into(), vec!["public".into()]),
2277 ]))),
2278 default_target: Some("efficient".into()),
2279 prompt: Some("Select an available target.".into()),
2280 response_schema: Some(r#"{"type":"object"}"#.into()),
2281 max_output_tokens: default_classifier_max_output_tokens(),
2282 policy: Some(ClassifierPolicyConfig::TargetSelector {
2283 selector: "/target".into(),
2284 }),
2285 ..LlmClassifierRouteConfig::default()
2286 }
2287 }
2288
2289 #[test]
2290 fn restricted_custom_pools_cover_parent_and_subagents() -> AlgorithmResult<()> {
2291 let allowed = BTreeSet::from(["private", "judge"]);
2292 let targets = BTreeMap::from([
2293 ("private".into(), ModelId::from("private")),
2294 ("judge".into(), ModelId::from("judge")),
2295 ]);
2296 let parent = AlgorithmSpec::LlmClassifier {
2297 config: custom_classifier(),
2298 };
2299 let child = AlgorithmSpec::Passthrough {
2300 target: "private".into(),
2301 subagents: Some(SubagentRouteConfig::LlmClassifier(Box::new(
2302 custom_classifier(),
2303 ))),
2304 };
2305 for algorithm in [parent, child] {
2306 let restricted = algorithm.restricted_to(&allowed)?;
2307 let groups = restricted.runtime_model_names("test")?;
2308 let groups = groups.subagent.unwrap_or(groups.parent);
2309 assert_eq!(groups[&Category::Any], ["private"]);
2310 assert_eq!(groups[&Category::Efficient], ["private"]);
2311 assert_eq!(groups[&Category::Judge], ["judge"]);
2312 restricted.build("test", &targets)?;
2313 let missing_judge = algorithm.restricted_to(&BTreeSet::from(["private"]))?;
2314 assert!(missing_judge.build("test", &targets).is_err());
2315 }
2316 Ok(())
2317 }
2318
2319 #[test]
2320 fn restricted_roles_are_not_replaced() {
2321 let stage = AlgorithmSpec::Auto {
2322 capable_target: "private".into(),
2323 efficient_target: "public".into(),
2324 };
2325 assert!(stage.restricted_to(&BTreeSet::from(["private"])).is_err());
2326 }
2327}