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 stage_fallback(
757 &self,
758 route_name: &str,
759 capable: &str,
760 efficient: &str,
761 targets: &BTreeMap<String, ModelId>,
762 ) -> AlgorithmResult<LlmFallback> {
763 Ok(LlmFallback {
764 target: Some(resolve_target_model_id(route_name, &self.target, targets)?),
765 config: self.task_classifier_config(route_name, capable, efficient)?,
766 })
767 }
768
769 fn custom_classifier_config(
770 &self,
771 route_name: &str,
772 ) -> AlgorithmResult<CustomClassifierConfig> {
773 if self.fail_open == Some(false) {
774 return Err(AlgorithmConfigError::new(
775 "composite custom classifiers always fail open; remove fail_open = false",
776 ));
777 }
778 if self.decision.is_some()
779 || self.base_threshold.is_some()
780 || self.threshold_step.is_some()
781 || self.response_format_type != ClassifierResponseFormat::JsonSchema
782 {
783 return Err(AlgorithmConfigError::new(
784 "composite custom classifier cannot use capability thresholds, decision settings, or response_format_type",
785 ));
786 }
787 let schema =
788 required_classifier_field(route_name, "response_schema", &self.response_schema)?;
789 let schema = serde_json::from_str(&schema).map_err(|error| {
790 AlgorithmConfigError::with_source(
791 format!("composite route {route_name}: response_schema is invalid JSON"),
792 error,
793 )
794 })?;
795 let mut config = CustomClassifierConfig::new(
796 required_classifier_field(route_name, "prompt", &self.prompt)?,
797 schema,
798 required_classifier_field(route_name, "policy", &self.policy)?.into_libsy(),
799 );
800 config.classify_trigger = self.classify_trigger;
801 config.message_hash_fallback = self.message_hash_fallback;
802 config.recent_turn_window = self.recent_turn_window;
803 config.max_output_tokens = self.max_output_tokens;
804 Ok(config)
805 }
806}
807
808impl AlgorithmSpec {
809 pub(crate) fn restricted_to(&self, allowed: &BTreeSet<&str>) -> AlgorithmResult<Self> {
811 let mut restricted = self.clone();
812 match &mut restricted {
813 Self::Random {
814 targets, weights, ..
815 } => {
816 if let Some(weights) = weights {
817 if weights.len() != targets.len() {
818 return Err(AlgorithmConfigError::new(format!(
819 "random expected {} weights, got {}",
820 targets.len(),
821 weights.len()
822 )));
823 }
824 let mut index = 0;
826 weights.retain(|_| {
827 let keep = allowed.contains(targets[index].as_str());
828 index += 1;
829 keep
830 });
831 }
832 targets.retain(|target| allowed.contains(target.as_str()));
833 if targets.is_empty() {
834 return Err(AlgorithmConfigError::new(
835 "target requirements leave random without an eligible target".to_string(),
836 ));
837 }
838 }
839 Self::LlmClassifier { config } => {
840 if let Some(models) = &mut config.models {
841 models.restrict_to(allowed);
842 }
843 }
844 _ => {}
845 }
846 let subagents = match &mut restricted {
847 Self::Passthrough { subagents, .. }
848 | Self::StageRouter { subagents, .. }
849 | Self::Composite { subagents, .. } => subagents.as_mut(),
850 _ => None,
851 };
852 if let Some(SubagentRouteConfig::LlmClassifier(config)) = subagents
853 && let Some(models) = &mut config.models
854 {
855 models.restrict_to(allowed);
856 }
857 if let Some(target) = restricted
858 .callable_target_names()
859 .into_iter()
860 .find(|target| !allowed.contains(target))
861 {
862 return Err(AlgorithmConfigError::new(format!(
863 "required target {target} does not satisfy target requirements"
864 )));
865 }
866 Ok(restricted)
867 }
868
869 pub(crate) const fn supports_target_restrictions(&self) -> bool {
870 !matches!(self, Self::PrefillRouter { .. })
871 }
872
873 pub(crate) fn classifier_targets(
877 &self,
878 ) -> impl Iterator<Item = (&str, Option<&DecisionJudgeRouteConfig>)> {
879 let (primary, profiles) = match self {
880 Self::LlmClassifier { config, .. }
881 if !matches!(
882 config.classifier_mode(),
883 ClassifierMode::Custom | ClassifierMode::Direct
884 ) =>
885 {
886 (
887 Some((config.classifier_target.as_str(), config.decision.as_ref())),
888 [None, None],
889 )
890 }
891 Self::Composite {
892 classifier,
893 efficient,
894 capable,
895 ..
896 } => (
897 Some((classifier.target.as_str(), classifier.decision.as_ref())),
898 [efficient.as_deref(), capable.as_deref()],
899 ),
900 Self::StageRouter { classifier, .. } => (
901 classifier
902 .as_ref()
903 .map(|c| (c.target.as_str(), c.decision.as_ref())),
904 [None, None],
905 ),
906 _ => (None, [None, None]),
907 };
908 primary.into_iter().chain(
909 profiles
910 .into_iter()
911 .flatten()
912 .filter_map(|profile| profile.classifier.as_ref())
913 .map(|c| (c.target.as_str(), c.decision.as_ref())),
914 )
915 }
916
917 pub(crate) fn decision_target_names(&self) -> Vec<&str> {
919 let mut names: Vec<&str> = self
920 .classifier_targets()
921 .filter(|(_, decision)| decision.is_some())
922 .map(|(target, _)| target)
923 .collect();
924 if let Self::LlmClassifier { config, .. } = self
925 && matches!(config.classifier_mode(), ClassifierMode::Direct)
926 && let Some(models) = &config.models
927 {
928 names.extend(models.get(&Category::Judge).iter().map(String::as_str));
929 }
930 names
931 }
932
933 pub fn routing_target_names(&self) -> Vec<&str> {
935 match self {
936 Self::Noop { .. } => Vec::new(),
937 Self::Random { targets, .. } => targets.iter().map(String::as_str).collect(),
938 Self::Passthrough {
939 target, subagents, ..
940 } => {
941 let mut names = vec![target.as_str()];
942 if let Some(subagents) = subagents {
943 names.extend(subagents.routing_target_names());
944 }
945 names
946 }
947 Self::LlmClassifier { config, .. } => match config.classifier_mode() {
948 ClassifierMode::Capability => config
949 .weak_target
950 .iter()
951 .chain(&config.strong_target)
952 .map(String::as_str)
953 .collect(),
954 ClassifierMode::Escalation => config
955 .strong_target
956 .iter()
957 .chain(&config.weak_target)
958 .map(String::as_str)
959 .collect(),
960 ClassifierMode::Custom | ClassifierMode::Direct => config
961 .models
962 .as_ref()
963 .map(CategoryModelConfig::routing_names)
964 .unwrap_or_default(),
965 },
966 Self::StageRouter {
967 capable_target,
968 efficient_target,
969 subagents,
970 ..
971 } => {
972 let mut names = vec![capable_target.as_str(), efficient_target.as_str()];
973 if let Some(subagents) = subagents {
974 names.extend(subagents.routing_target_names());
975 }
976 names
977 }
978 Self::Auto {
979 capable_target,
980 efficient_target,
981 } => vec![capable_target.as_str(), efficient_target.as_str()],
982 Self::Composite {
983 stage, subagents, ..
984 } => {
985 let mut names = vec![
986 stage.capable_target.as_str(),
987 stage.efficient_target.as_str(),
988 ];
989 if let Some(subagents) = subagents {
990 names.extend(subagents.routing_target_names());
991 }
992 names
993 }
994 Self::Advisor {
997 executor_target, ..
998 } => vec![executor_target],
999 Self::Vgr { config } => {
1000 vec![
1001 config.efficient_target.as_str(),
1002 config.capable_target.as_str(),
1003 ]
1004 }
1005 Self::PrefillRouter { targets, .. } => targets.iter().map(String::as_str).collect(),
1006 }
1007 }
1008
1009 pub fn callable_target_names(&self) -> Vec<&str> {
1014 let mut names = self.routing_target_names();
1015 names.extend(self.judge_target_names());
1016 names
1017 }
1018
1019 pub fn judge_target_names(&self) -> Vec<&str> {
1022 let mut names = Vec::new();
1023 match self {
1024 Self::LlmClassifier { config, .. } => {
1027 if matches!(
1028 config.classifier_mode(),
1029 ClassifierMode::Custom | ClassifierMode::Direct
1030 ) {
1031 names.extend(
1032 config
1033 .models
1034 .as_ref()
1035 .map(|models| models.get(&Category::Judge))
1036 .unwrap_or_default()
1037 .iter()
1038 .map(String::as_str),
1039 );
1040 } else {
1041 names.push(config.classifier_target.as_str());
1042 }
1043 }
1044 Self::StageRouter { .. } | Self::Composite { .. } => {
1045 names.extend(self.classifier_targets().map(|(target, _)| target));
1046 }
1047 Self::Advisor { advisor_target, .. } => names.push(advisor_target),
1048 Self::Vgr { config } => {
1049 names.extend(config.judge_target.as_deref());
1050 names.extend(config.capable_judge_target.as_deref());
1051 }
1052 _ => {}
1053 }
1054 if let Self::Passthrough {
1056 subagents: Some(subagents),
1057 ..
1058 }
1059 | Self::StageRouter {
1060 subagents: Some(subagents),
1061 ..
1062 }
1063 | Self::Composite {
1064 subagents: Some(subagents),
1065 ..
1066 } = self
1067 {
1068 names.extend(subagents.judge_target_names());
1069 }
1070 names
1071 }
1072
1073 fn runtime_model_names(&self, route_name: &str) -> AlgorithmResult<RuntimeModelNames> {
1075 let parent = match self {
1076 Self::Noop { .. } => HashMap::new(),
1077 Self::Random { targets, .. } | Self::PrefillRouter { targets, .. } => {
1078 category_models([(Category::Any, targets.clone())])
1079 }
1080 Self::Passthrough { target, .. } => {
1081 category_models([(Category::Any, vec![target.clone()])])
1082 }
1083 Self::LlmClassifier { config } => {
1084 classifier_runtime_model_names(config.validated_classifier_mode(route_name)?)
1085 }
1086 Self::StageRouter {
1087 capable_target,
1088 efficient_target,
1089 classifier,
1090 ..
1091 } => {
1092 let mut models = category_models([
1093 (Category::Capable, vec![capable_target.clone()]),
1094 (Category::Efficient, vec![efficient_target.clone()]),
1095 (
1096 Category::Any,
1097 vec![capable_target.clone(), efficient_target.clone()],
1098 ),
1099 ]);
1100 if let Some(classifier) = classifier {
1101 models.insert(Category::Judge, vec![classifier.target.clone()]);
1102 }
1103 models
1104 }
1105 Self::Auto {
1106 capable_target,
1107 efficient_target,
1108 } => category_models([
1109 (Category::Capable, vec![capable_target.clone()]),
1110 (Category::Efficient, vec![efficient_target.clone()]),
1111 (
1112 Category::Any,
1113 vec![capable_target.clone(), efficient_target.clone()],
1114 ),
1115 ]),
1116 Self::Composite { stage, .. } => category_models([
1117 (
1118 Category::Judge,
1119 self.judge_target_names()
1120 .into_iter()
1121 .map(str::to_string)
1122 .collect(),
1123 ),
1124 (Category::Capable, vec![stage.capable_target.clone()]),
1125 (Category::Efficient, vec![stage.efficient_target.clone()]),
1126 (
1127 Category::Any,
1128 vec![stage.capable_target.clone(), stage.efficient_target.clone()],
1129 ),
1130 ]),
1131 Self::Advisor {
1132 executor_target,
1133 advisor_target,
1134 ..
1135 } => category_models([
1136 (Category::Efficient, vec![executor_target.clone()]),
1137 (Category::Any, vec![executor_target.clone()]),
1138 (Category::Judge, vec![advisor_target.clone()]),
1139 ]),
1140 Self::Vgr { config } => {
1141 let judge = config
1142 .judge_target
1143 .as_ref()
1144 .unwrap_or(&config.efficient_target);
1145 let judges = std::iter::once(judge)
1146 .chain(&config.capable_judge_target)
1147 .cloned()
1148 .collect();
1149 category_models([
1150 (Category::Efficient, vec![config.efficient_target.clone()]),
1151 (Category::Capable, vec![config.capable_target.clone()]),
1152 (
1153 Category::Any,
1154 vec![
1155 config.efficient_target.clone(),
1156 config.capable_target.clone(),
1157 ],
1158 ),
1159 (Category::Judge, judges),
1160 ])
1161 }
1162 };
1163
1164 let subagents = match self {
1165 Self::Passthrough { subagents, .. }
1166 | Self::StageRouter { subagents, .. }
1167 | Self::Composite { subagents, .. } => subagents.as_ref(),
1168 _ => None,
1169 };
1170 let subagent = subagents
1174 .map(|subagents| subagent_runtime_model_names(subagents, route_name))
1175 .transpose()?;
1176 Ok(RuntimeModelNames { parent, subagent })
1177 }
1178
1179 #[cfg(not(target_os = "wasi"))]
1181 pub(crate) fn routing_response_and_dependency(&self) -> Option<(&str, &str)> {
1182 match self {
1183 Self::LlmClassifier { config, .. }
1184 if matches!(config.classifier_mode(), ClassifierMode::Escalation) =>
1185 {
1186 Some((
1187 config.weak_target.as_deref()?,
1188 config.classifier_target.as_str(),
1189 ))
1190 }
1191 Self::Advisor {
1192 executor_target,
1193 advisor_target,
1194 ..
1195 } => Some((executor_target, advisor_target)),
1196 Self::Vgr { config } => Some((
1197 &config.efficient_target,
1198 config
1199 .judge_target
1200 .as_deref()
1201 .unwrap_or(&config.efficient_target),
1202 )),
1203 Self::Noop { .. }
1204 | Self::Random { .. }
1205 | Self::Passthrough { .. }
1206 | Self::LlmClassifier { .. }
1207 | Self::StageRouter { .. }
1208 | Self::Auto { .. }
1209 | Self::Composite { .. }
1210 | Self::PrefillRouter { .. } => None,
1211 }
1212 }
1213
1214 pub fn build(
1216 &self,
1217 route_name: &str,
1218 targets: &BTreeMap<String, ModelId>,
1219 ) -> AlgorithmResult<Arc<dyn Algorithm>> {
1220 build_algorithm(route_name, self, targets)
1221 }
1222
1223 pub fn build_with_runtime_models(
1225 &self,
1226 route_name: &str,
1227 targets: &BTreeMap<String, ModelId>,
1228 ) -> AlgorithmResult<(Arc<dyn Algorithm>, RuntimeModels)> {
1229 let algorithm = self.build(route_name, targets)?;
1230 let names = self.runtime_model_names(route_name)?;
1231 let mut models =
1232 RuntimeModels::new(resolve_runtime_models(route_name, names.parent, targets)?);
1233 if let Some(subagent) = names.subagent {
1234 models = models.with_subagent(resolve_runtime_models(route_name, subagent, targets)?);
1235 }
1236 Ok((algorithm, models))
1237 }
1238}
1239
1240struct RuntimeModelNames {
1242 parent: HashMap<Category, Vec<String>>,
1244 subagent: Option<HashMap<Category, Vec<String>>>,
1246}
1247
1248fn category_models(
1249 entries: impl IntoIterator<Item = (Category, Vec<String>)>,
1250) -> HashMap<Category, Vec<String>> {
1251 entries.into_iter().collect()
1252}
1253
1254fn custom_runtime_model_names(config: &CategoryModelConfig) -> HashMap<Category, Vec<String>> {
1255 config.groups().collect()
1256}
1257
1258fn classifier_runtime_model_names(
1259 config: LlmClassifierModeConfig,
1260) -> HashMap<Category, Vec<String>> {
1261 match config {
1262 LlmClassifierModeConfig::Capability(config) => category_models([
1263 (Category::Judge, vec![config.classifier_target]),
1264 (Category::Efficient, vec![config.weak_target.clone()]),
1265 (Category::Capable, vec![config.strong_target.clone()]),
1266 (
1267 Category::Any,
1268 vec![config.weak_target, config.strong_target],
1269 ),
1270 ]),
1271 LlmClassifierModeConfig::Escalation(config) => category_models([
1272 (Category::Judge, vec![config.classifier_target]),
1273 (Category::Efficient, vec![config.weak_target.clone()]),
1274 (Category::Capable, vec![config.strong_target.clone()]),
1275 (
1276 Category::Any,
1277 vec![config.strong_target, config.weak_target],
1278 ),
1279 ]),
1280 LlmClassifierModeConfig::Custom(config) => custom_runtime_model_names(&config.models),
1281 LlmClassifierModeConfig::Direct { models, .. } => custom_runtime_model_names(&models),
1282 }
1283}
1284
1285fn subagent_runtime_model_names(
1286 config: &SubagentRouteConfig,
1287 route_name: &str,
1288) -> AlgorithmResult<HashMap<Category, Vec<String>>> {
1289 match config {
1290 SubagentRouteConfig::Passthrough { target } => {
1291 Ok(category_models([(Category::Any, vec![target.clone()])]))
1292 }
1293 SubagentRouteConfig::LlmClassifier(config) => {
1294 let LlmClassifierModeConfig::Custom(config) =
1295 config.validated_classifier_mode(route_name)?
1296 else {
1297 return Err(AlgorithmConfigError::new(format!(
1298 "route {route_name}: subagents llm_classifier only supports mode custom"
1299 )));
1300 };
1301 Ok(custom_runtime_model_names(&config.models))
1302 }
1303 }
1304}
1305impl LlmClassifierRouteConfig {
1306 fn classifier_mode(&self) -> ClassifierMode {
1307 let default = if self.escalation.is_some() {
1308 ClassifierMode::Escalation
1309 } else {
1310 ClassifierMode::Capability
1311 };
1312 self.mode.unwrap_or(default)
1313 }
1314
1315 fn validated_classifier_mode(
1316 &self,
1317 route_name: &str,
1318 ) -> AlgorithmResult<LlmClassifierModeConfig> {
1319 let Self {
1320 classifier_target,
1321 mode,
1322 strong_target,
1323 weak_target,
1324 base_threshold,
1325 threshold_step,
1326 unmatched_steps,
1327 fail_open,
1328 classify_trigger,
1329 message_hash_fallback,
1330 recent_turn_window,
1331 prompt,
1332 response_format_type,
1333 max_output_tokens,
1334 judge_deadline_ms,
1335 escalation,
1336 decision,
1337 models,
1338 default_target,
1339 response_schema,
1340 policy,
1341 } = self;
1342
1343 let selected_mode = match (mode, escalation.is_some()) {
1344 (Some(mode), _) => *mode,
1345 (None, true) => ClassifierMode::Escalation,
1346 (None, false) => ClassifierMode::Capability,
1347 };
1348
1349 if decision.is_some() && !matches!(selected_mode, ClassifierMode::Capability) {
1350 return Err(AlgorithmConfigError::new(format!(
1351 "llm_classifier route {route_name}: decision requires capability mode"
1352 )));
1353 }
1354
1355 if !matches!(
1356 selected_mode,
1357 ClassifierMode::Capability | ClassifierMode::Direct
1358 ) && *fail_open == Some(true)
1359 {
1360 return Err(AlgorithmConfigError::new(format!(
1361 "llm_classifier route {route_name}: fail_open = true requires capability or direct mode"
1362 )));
1363 }
1364
1365 match selected_mode {
1366 ClassifierMode::Capability => {
1367 if escalation.is_some() {
1368 return Err(classifier_field_error(
1369 route_name,
1370 "escalation",
1371 "capability",
1372 ));
1373 }
1374 reject_custom_fields(
1375 route_name,
1376 "capability",
1377 models,
1378 default_target,
1379 response_schema,
1380 policy,
1381 )?;
1382 let judge = if let Some(decision) = decision {
1383 if base_threshold.is_some()
1384 || threshold_step.is_some()
1385 || unmatched_steps.is_some()
1386 || prompt.is_some()
1387 || *response_format_type != ClassifierResponseFormat::JsonSchema
1388 || *max_output_tokens != default_classifier_max_output_tokens()
1389 || judge_deadline_ms.is_some()
1390 {
1391 return Err(AlgorithmConfigError::new(format!(
1392 "llm_classifier route {route_name}: decision cannot use LLM judge settings; use decision.cutoff and decision.instructions"
1393 )));
1394 }
1395 CapabilityJudgeRouteConfig::Decision(decision.clone())
1396 } else {
1397 CapabilityJudgeRouteConfig::Llm(LlmCapabilityConfig {
1398 base_threshold: required_classifier_field(
1399 route_name,
1400 "base_threshold",
1401 base_threshold,
1402 )?,
1403 threshold_step: threshold_step.unwrap_or_default(),
1404 unmatched_steps: unmatched_steps.unwrap_or(DEFAULT_UNMATCHED_STEPS),
1405 contract: classifier_contract(prompt.as_deref())
1406 .with_response_format_type(*response_format_type),
1407 max_output_tokens: *max_output_tokens,
1408 judge_deadline_ms: *judge_deadline_ms,
1409 })
1410 };
1411 Ok(LlmClassifierModeConfig::Capability(
1412 CapabilityClassifierRouteConfig {
1413 classifier_target: classifier_target.clone(),
1414 fail_open: fail_open.unwrap_or(true),
1415 strong_target: required_classifier_field(
1416 route_name,
1417 "strong_target",
1418 strong_target,
1419 )?,
1420 weak_target: required_classifier_field(
1421 route_name,
1422 "weak_target",
1423 weak_target,
1424 )?,
1425 judge,
1426 classify_trigger: *classify_trigger,
1427 message_hash_fallback: *message_hash_fallback,
1428 recent_turn_window: *recent_turn_window,
1429 },
1430 ))
1431 }
1432 ClassifierMode::Escalation => {
1433 reject_custom_fields(
1434 route_name,
1435 "escalation",
1436 models,
1437 default_target,
1438 response_schema,
1439 policy,
1440 )?;
1441 if *classify_trigger != ClassifyTrigger::EveryRequest {
1442 return Err(AlgorithmConfigError::new(format!(
1443 "llm_classifier route {route_name} mode escalation cannot use classify_trigger"
1444 )));
1445 }
1446 let has_capability_settings = base_threshold.is_some()
1447 || threshold_step.is_some()
1448 || fail_open.is_some()
1449 || *message_hash_fallback
1450 || recent_turn_window.is_some();
1451 if unmatched_steps.is_some() || (mode.is_some() && has_capability_settings) {
1455 return Err(AlgorithmConfigError::new(format!(
1456 "llm_classifier route {route_name} mode escalation cannot use capability routing settings"
1457 )));
1458 }
1459 if has_capability_settings {
1460 tracing::warn!(
1463 "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."
1464 );
1465 }
1466 Ok(LlmClassifierModeConfig::Escalation(
1467 EscalationClassifierRouteConfig {
1468 classifier_target: classifier_target.clone(),
1469 strong_target: required_classifier_field(
1470 route_name,
1471 "strong_target",
1472 strong_target,
1473 )?,
1474 weak_target: required_classifier_field(
1475 route_name,
1476 "weak_target",
1477 weak_target,
1478 )?,
1479 prompt: prompt.clone(),
1480 response_format_type: *response_format_type,
1481 max_output_tokens: *max_output_tokens,
1482 judge_deadline_ms: *judge_deadline_ms,
1483 judge: required_classifier_field(route_name, "escalation", escalation)?,
1484 },
1485 ))
1486 }
1487 ClassifierMode::Custom => {
1488 if !classifier_target.is_empty()
1489 || strong_target.is_some()
1490 || weak_target.is_some()
1491 || base_threshold.is_some()
1492 || threshold_step.is_some()
1493 || unmatched_steps.is_some()
1494 || escalation.is_some()
1495 || *response_format_type != ClassifierResponseFormat::JsonSchema
1496 {
1497 return Err(AlgorithmConfigError::new(format!(
1498 "llm_classifier route {route_name} mode custom cannot use capability or escalation fields and response_format_type must be 'json_schema'"
1499 )));
1500 }
1501 let (models, default_target) =
1502 validated_models(route_name, models, default_target)?;
1503 Ok(LlmClassifierModeConfig::Custom(
1504 CustomClassifierRouteConfig {
1505 models,
1506 default_target,
1507 prompt: required_classifier_field(route_name, "prompt", prompt)?,
1508 response_schema: required_classifier_field(
1509 route_name,
1510 "response_schema",
1511 response_schema,
1512 )?,
1513 policy: required_classifier_field(route_name, "policy", policy)?,
1514 classify_trigger: *classify_trigger,
1515 message_hash_fallback: *message_hash_fallback,
1516 recent_turn_window: *recent_turn_window,
1517 max_output_tokens: *max_output_tokens,
1518 judge_deadline_ms: *judge_deadline_ms,
1519 },
1520 ))
1521 }
1522 ClassifierMode::Direct => {
1523 if !classifier_target.is_empty()
1524 || strong_target.is_some()
1525 || weak_target.is_some()
1526 || base_threshold.is_some()
1527 || threshold_step.is_some()
1528 || unmatched_steps.is_some()
1529 || escalation.is_some()
1530 || prompt.is_some()
1531 || response_schema.is_some()
1532 || policy.is_some()
1533 || *response_format_type != ClassifierResponseFormat::JsonSchema
1534 || *max_output_tokens != default_classifier_max_output_tokens()
1535 || judge_deadline_ms.is_some()
1536 {
1537 return Err(AlgorithmConfigError::new(format!(
1538 "llm_classifier route {route_name} mode direct cannot use capability, escalation, or LLM judge fields"
1539 )));
1540 }
1541 let (models, default_target) =
1542 validated_models(route_name, models, default_target)?;
1543 let options = models
1544 .groups()
1545 .filter(|(category, targets)| {
1546 !targets.is_empty() && !matches!(category, Category::Judge | Category::Any)
1547 })
1548 .map(|(category, _)| category)
1549 .collect();
1550 Ok(LlmClassifierModeConfig::Direct {
1551 models,
1552 default_target,
1553 config: DirectClassifierConfig {
1554 options,
1555 fail_open: fail_open.unwrap_or(true),
1556 classify_trigger: *classify_trigger,
1557 message_hash_fallback: *message_hash_fallback,
1558 recent_turn_window: *recent_turn_window,
1559 },
1560 })
1561 }
1562 }
1563 }
1564}
1565
1566fn validated_models(
1567 route_name: &str,
1568 models: &Option<CategoryModelConfig>,
1569 default_target: &Option<String>,
1570) -> AlgorithmResult<(CategoryModelConfig, Category)> {
1571 let models = required_classifier_field(route_name, "models", models)?;
1572 let default_target: Category =
1573 required_classifier_field(route_name, "default_target", default_target)?
1574 .parse()
1575 .map_err(|error| {
1576 AlgorithmConfigError::new(format!(
1577 "llm_classifier route {route_name} has invalid default_target: {error}"
1578 ))
1579 })?;
1580 if default_target == Category::Judge {
1581 return Err(AlgorithmConfigError::new(format!(
1582 "llm_classifier route {route_name} default_target cannot be judge"
1583 )));
1584 }
1585 models.validate(route_name, &default_target)?;
1586 Ok((models, default_target))
1587}
1588
1589fn reject_custom_fields(
1590 route_name: &str,
1591 mode: &str,
1592 models: &Option<CategoryModelConfig>,
1593 default_target: &Option<String>,
1594 response_schema: &Option<String>,
1595 policy: &Option<ClassifierPolicyConfig>,
1596) -> AlgorithmResult<()> {
1597 if models.is_some() || default_target.is_some() || response_schema.is_some() || policy.is_some()
1598 {
1599 return Err(AlgorithmConfigError::new(format!(
1600 "llm_classifier route {route_name} mode {mode} cannot use custom classifier fields"
1601 )));
1602 }
1603 Ok(())
1604}
1605
1606fn classifier_field_error(route_name: &str, field: &str, mode: &str) -> AlgorithmConfigError {
1607 AlgorithmConfigError::new(format!(
1608 "llm_classifier route {route_name} mode {mode} cannot use {field}"
1609 ))
1610}
1611
1612fn required_classifier_field<T: Clone>(
1613 route_name: &str,
1614 field: &str,
1615 value: &Option<T>,
1616) -> AlgorithmResult<T> {
1617 value.clone().ok_or_else(|| {
1618 AlgorithmConfigError::new(format!(
1619 "llm_classifier route {route_name} requires {field}"
1620 ))
1621 })
1622}
1623
1624fn build_subagent_router_config(
1625 route_name: &str,
1626 config: &SubagentRouteConfig,
1627 targets: &BTreeMap<String, ModelId>,
1628) -> AlgorithmResult<SubagentRouterConfig> {
1629 match config {
1630 SubagentRouteConfig::Passthrough { target } => {
1631 resolve_target_model_id(route_name, target, targets)?;
1632 Ok(SubagentRouterConfig::fixed_target())
1633 }
1634 SubagentRouteConfig::LlmClassifier(config) => {
1635 let LlmClassifierModeConfig::Custom(config) =
1636 config.validated_classifier_mode(route_name)?
1637 else {
1638 return Err(AlgorithmConfigError::new(format!(
1639 "route {route_name}: subagents llm_classifier only supports mode custom"
1640 )));
1641 };
1642 for name in config.models.all_names() {
1643 resolve_target_model_id(route_name, name, targets)?;
1644 }
1645 if config.models.get(&config.default_target).is_empty() {
1646 return Err(AlgorithmConfigError::new(format!(
1647 "route {route_name}: subagents llm_classifier has no model for default category {}",
1648 config.default_target.as_str()
1649 )));
1650 }
1651 let response_schema =
1652 serde_json::from_str(&config.response_schema).map_err(|error| {
1653 AlgorithmConfigError::with_source(
1654 format!(
1655 "route {route_name}: subagents llm_classifier response_schema is invalid JSON: {error}"
1656 ),
1657 error,
1658 )
1659 })?;
1660 warn_single_target_classifier(route_name, &config.models);
1661 let mut classifier_config = CustomClassifierConfig::new(
1662 config.prompt,
1663 response_schema,
1664 config.policy.into_libsy(),
1665 );
1666 classifier_config.recent_turn_window = config.recent_turn_window;
1667 classifier_config.max_output_tokens = config.max_output_tokens;
1668 classifier_config.judge_deadline_ms = config.judge_deadline_ms;
1669 let classifier = Arc::new(
1670 LlmTaskClassifier::new(LlmClassifierConfig::Custom {
1671 default_target: config.default_target.clone(),
1672 config: classifier_config,
1673 })
1674 .map_err(|error| {
1675 AlgorithmConfigError::with_source(
1676 format!("route {route_name}: subagents llm_classifier: {error}"),
1677 error,
1678 )
1679 })?,
1680 );
1681 Ok(SubagentRouterConfig {
1682 classifier,
1683 default_target: config.default_target,
1684 classify_trigger: config.classify_trigger,
1685 message_hash_fallback: config.message_hash_fallback,
1686 })
1687 }
1688 }
1689}
1690
1691fn attach_subagent_router(
1692 route_name: &str,
1693 parent: Arc<dyn Algorithm>,
1694 config: Option<&SubagentRouteConfig>,
1695 targets: &BTreeMap<String, ModelId>,
1696) -> AlgorithmResult<Arc<dyn Algorithm>> {
1697 let Some(config) = config else {
1698 return Ok(parent);
1699 };
1700 let config = build_subagent_router_config(route_name, config, targets)?;
1701 let algorithm = SubagentRouter::new(parent, config).map_err(|error| {
1702 AlgorithmConfigError::with_source(
1703 format!("route {route_name}: subagent routing: {error}"),
1704 error,
1705 )
1706 })?;
1707 Ok(Arc::new(algorithm))
1708}
1709
1710fn build_algorithm(
1714 route_name: &str,
1715 config: &AlgorithmSpec,
1716 targets: &BTreeMap<String, ModelId>,
1717) -> AlgorithmResult<Arc<dyn Algorithm>> {
1718 match config {
1719 AlgorithmSpec::Noop { .. } => Ok(Arc::new(Noop {})),
1720 AlgorithmSpec::Random {
1721 targets: names,
1722 weights,
1723 seed,
1724 } => {
1725 if let Some(weights) = weights
1728 && weights.len() != names.len()
1729 {
1730 return Err(AlgorithmConfigError::new(format!(
1731 "random route {route_name}: expected {} weights, got {}",
1732 names.len(),
1733 weights.len()
1734 )));
1735 }
1736 let mut seen = BTreeSet::new();
1737 if let Some(duplicate) = names.iter().find(|name| !seen.insert(*name)) {
1738 return Err(AlgorithmConfigError::new(format!(
1739 "random route {route_name}: targets must be unique, {duplicate} is repeated"
1740 )));
1741 }
1742 let algorithm = Random::new(weights.clone(), *seed).map_err(|error| {
1743 AlgorithmConfigError::with_source(
1744 format!("random route {route_name}: {error}"),
1745 error,
1746 )
1747 })?;
1748 Ok(Arc::new(algorithm))
1749 }
1750 AlgorithmSpec::Passthrough { subagents, .. } => {
1751 let algorithm = Passthrough;
1752 let parent: Arc<dyn Algorithm> = Arc::new(algorithm);
1753 attach_subagent_router(route_name, parent, subagents.as_ref(), targets)
1754 }
1755 AlgorithmSpec::LlmClassifier {
1756 config: classifier_config,
1757 ..
1758 } => {
1759 let mode = classifier_config.validated_classifier_mode(route_name)?;
1760 let algorithm = match mode {
1761 LlmClassifierModeConfig::Capability(config) => {
1762 let classifier_config = config.into_task_config();
1763 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1764 config: classifier_config,
1765 })
1766 }
1767 LlmClassifierModeConfig::Escalation(config) => {
1768 LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
1769 contract: classifier_contract(config.prompt.as_deref())
1770 .with_response_format_type(config.response_format_type),
1771 config: config.judge,
1772 max_output_tokens: config.max_output_tokens,
1773 judge_deadline_ms: config.judge_deadline_ms,
1774 })
1775 }
1776 LlmClassifierModeConfig::Custom(config) => {
1777 let response_schema = serde_json::from_str(&config.response_schema).map_err(
1778 |error| {
1779 AlgorithmConfigError::with_source(
1780 format!(
1781 "llm_classifier route {route_name}: response_schema is invalid JSON: {error}"
1782 ),
1783 error,
1784 )
1785 },
1786 )?;
1787 warn_single_target_classifier(route_name, &config.models);
1788 let mut classifier_config = CustomClassifierConfig::new(
1789 config.prompt,
1790 response_schema,
1791 config.policy.into_libsy(),
1792 );
1793 classifier_config.classify_trigger = config.classify_trigger;
1794 classifier_config.message_hash_fallback = config.message_hash_fallback;
1795 classifier_config.recent_turn_window = config.recent_turn_window;
1796 classifier_config.max_output_tokens = config.max_output_tokens;
1797 classifier_config.judge_deadline_ms = config.judge_deadline_ms;
1798 LlmTaskClassifier::new(LlmClassifierConfig::Custom {
1799 default_target: config.default_target,
1800 config: classifier_config,
1801 })
1802 }
1803 LlmClassifierModeConfig::Direct {
1804 default_target,
1805 config,
1806 ..
1807 } => LlmTaskClassifier::new(LlmClassifierConfig::Direct {
1808 default_target,
1809 config,
1810 }),
1811 }
1812 .map_err(|error| {
1813 AlgorithmConfigError::with_source(
1814 format!("llm_classifier route {route_name}: {error}"),
1815 error,
1816 )
1817 })?;
1818 Ok(Arc::new(algorithm))
1819 }
1820 AlgorithmSpec::StageRouter {
1821 capable_target,
1822 efficient_target,
1823 capable_first_threshold,
1824 settings,
1825 picker,
1826 classifier,
1827 subagents,
1828 ..
1829 } => {
1830 if matches!(picker, PickerMode::CapableFirst) {
1831 tracing::warn!(
1832 "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."
1833 );
1834 }
1835 warn_identical_tiers(route_name, capable_target, efficient_target);
1836 let threshold = settings.confidence_threshold.ok_or_else(|| {
1837 AlgorithmConfigError::new(format!(
1838 "stage_router route {route_name}: confidence_threshold is required"
1839 ))
1840 })?;
1841 let mut config = settings.stage_config(settings, *picker, threshold);
1842 config.capable_first_threshold = *capable_first_threshold;
1843 config.llm_fallback = classifier
1846 .as_ref()
1847 .map(|classifier| {
1848 classifier.stage_fallback(route_name, capable_target, efficient_target, targets)
1849 })
1850 .transpose()?;
1851 let algorithm = StageRouter::new(config).map_err(|error| {
1852 AlgorithmConfigError::with_source(
1853 format!("stage_router route {route_name}: {error}"),
1854 error,
1855 )
1856 })?;
1857 let parent: Arc<dyn Algorithm> = Arc::new(algorithm);
1858 attach_subagent_router(route_name, parent, subagents.as_ref(), targets)
1859 }
1860 AlgorithmSpec::Auto {
1861 capable_target,
1862 efficient_target,
1863 } => {
1864 warn_identical_tiers(route_name, capable_target, efficient_target);
1865 let config = StageRouterConfig::new(PickerMode::EfficientFirst, 0.5);
1866 let algorithm = StageRouter::new(config).map_err(|error| {
1867 AlgorithmConfigError::with_source(
1868 format!("auto route {route_name}: {error}"),
1869 error,
1870 )
1871 })?;
1872 Ok(Arc::new(algorithm))
1873 }
1874 AlgorithmSpec::Composite {
1875 classifier,
1876 stage,
1877 efficient,
1878 capable,
1879 subagents,
1880 } => {
1881 stage.warn_identical_tiers(route_name);
1882 let judge = if matches!(classifier.mode, Some(ClassifierMode::Custom)) {
1883 CompositeJudgeConfig::Custom(classifier.custom_classifier_config(route_name)?)
1884 } else {
1885 CompositeJudgeConfig::Capability(classifier.task_classifier_config(
1886 route_name,
1887 &stage.capable_target,
1888 &stage.efficient_target,
1889 )?)
1890 };
1891 let shared = &stage.settings;
1892 let algorithm = if efficient.is_some() || capable.is_some() {
1893 let defaults = StageProfileConfig::default();
1894 let profiles = [
1895 (
1896 efficient,
1897 PickerMode::EfficientFirst,
1898 shared.confidence_threshold.unwrap_or(0.5),
1899 ),
1900 (
1901 capable,
1902 PickerMode::CapableFirst,
1903 stage.capable_first_threshold,
1904 ),
1905 ]
1906 .map(|(profile, picker, threshold)| -> AlgorithmResult<_> {
1907 let profile = profile.as_deref().unwrap_or(&defaults);
1908 let mut config = profile.settings.stage_config(
1909 shared,
1910 profile.picker.unwrap_or(picker),
1911 threshold,
1912 );
1913 config.llm_fallback = profile
1914 .classifier
1915 .as_ref()
1916 .map(|classifier| {
1917 classifier.stage_fallback(
1918 route_name,
1919 &stage.capable_target,
1920 &stage.efficient_target,
1921 targets,
1922 )
1923 })
1924 .transpose()?;
1925 Ok(config)
1926 });
1927 let [profile_0, profile_1] = profiles;
1928 CompositeRouter::new_with_profiles(judge, profile_0?, profile_1?)
1929 } else {
1930 let mut config = shared.stage_config(shared, PickerMode::EfficientFirst, 0.5);
1931 config.capable_first_threshold = stage.capable_first_threshold;
1932 CompositeRouter::new(CompositeRouterConfig {
1933 judge,
1934 stage: config,
1935 })
1936 }
1937 .map_err(|error| {
1938 AlgorithmConfigError::with_source(
1939 format!("composite route {route_name}: {error}"),
1940 error,
1941 )
1942 })?;
1943 let parent: Arc<dyn Algorithm> = Arc::new(algorithm);
1944 attach_subagent_router(route_name, parent, subagents.as_ref(), targets)
1945 }
1946 AlgorithmSpec::Advisor {
1947 reviewer_system_prompt,
1948 redo_feedback_prefix,
1949 gate_trigger,
1950 gate_trigger_pattern,
1951 max_reviews,
1952 gate_stall_turns,
1953 gate_min_tool_results,
1954 advisor_max_tokens,
1955 advisor_temperature,
1956 transcript_max_chars,
1957 fail_open,
1958 ..
1959 } => {
1960 if *gate_trigger == AdvisorTriggerConfig::NoToolCall && gate_trigger_pattern.is_some() {
1963 return Err(AlgorithmConfigError::new(format!(
1964 "advisor route {route_name}: gate_trigger_pattern requires \
1965 gate_trigger = \"pattern\""
1966 )));
1967 }
1968 let mut config = AdvisorGateConfig::default();
1969 if let Some(prompt) = reviewer_system_prompt {
1970 config.reviewer_system_prompt = prompt.clone();
1971 }
1972 if let Some(prefix) = redo_feedback_prefix {
1973 config.redo_feedback_prefix = prefix.clone();
1974 }
1975 config.gate_trigger = match gate_trigger {
1976 AdvisorTriggerConfig::NoToolCall => GateTrigger::NoToolCall,
1977 AdvisorTriggerConfig::Pattern => {
1978 GateTrigger::Pattern(gate_trigger_pattern.clone().unwrap_or_default())
1979 }
1980 };
1981 config.max_reviews = *max_reviews;
1982 config.gate_stall_turns = *gate_stall_turns;
1983 config.gate_min_tool_results = *gate_min_tool_results;
1984 config.advisor_max_tokens = *advisor_max_tokens;
1985 config.advisor_temperature = *advisor_temperature;
1986 config.transcript_max_chars = *transcript_max_chars;
1987 config.fail_open = *fail_open;
1988 let algorithm = AdvisorGate::new(config).map_err(|error| {
1989 AlgorithmConfigError::with_source(
1990 format!("advisor route {route_name}: {error}"),
1991 error,
1992 )
1993 })?;
1994 Ok(Arc::new(algorithm))
1995 }
1996 AlgorithmSpec::Vgr { config } => build_vgr(route_name, config, targets),
1997 AlgorithmSpec::PrefillRouter {
1998 targets: names,
1999 checkpoint,
2000 device,
2001 cache_dir,
2002 max_length,
2003 batch_size,
2004 } => {
2005 #[cfg(feature = "prefill-router")]
2006 {
2007 let targets = names
2008 .iter()
2009 .map(|name| resolve_target_model_id(route_name, name, targets))
2010 .collect::<AlgorithmResult<Vec<_>>>()?;
2011 let mut config = prefill_router::PrefillRouterConfig::new(targets, checkpoint);
2012 config.device.clone_from(device);
2013 config.cache_dir.clone_from(cache_dir);
2014 if let Some(max_length) = max_length {
2015 config.max_length = *max_length;
2016 }
2017 if let Some(batch_size) = batch_size {
2018 config.batch_size = *batch_size;
2019 }
2020 let algorithm = config.build().map_err(|error| {
2021 AlgorithmConfigError::with_source(
2022 format!("prefill_router route {route_name}: {error}"),
2023 error,
2024 )
2025 })?;
2026 Ok(Arc::new(algorithm))
2027 }
2028
2029 #[cfg(not(feature = "prefill-router"))]
2030 {
2031 let _ = (
2032 names, checkpoint, device, cache_dir, max_length, batch_size, targets,
2033 );
2034 Err(AlgorithmConfigError::new(format!(
2035 "prefill_router route {route_name} requires the `prefill-router` Cargo feature"
2036 )))
2037 }
2038 }
2039 }
2040}
2041
2042fn build_vgr(
2043 route_name: &str,
2044 config: &VgrRouteConfig,
2045 targets: &BTreeMap<String, ModelId>,
2046) -> AlgorithmResult<Arc<dyn Algorithm>> {
2047 let efficient = resolve_target_model_id(route_name, &config.efficient_target, targets)?;
2048 let capable = resolve_target_model_id(route_name, &config.capable_target, targets)?;
2049 let optional = |name: &Option<String>| {
2050 name.as_deref()
2051 .map(|name| resolve_target_model_id(route_name, name, targets))
2052 .transpose()
2053 };
2054 let mut runtime = VgrConfig::new(efficient, capable);
2055 runtime.targets.judge = optional(&config.judge_target)?;
2056 runtime.targets.capable_judge = optional(&config.capable_judge_target)?;
2057 runtime.mode = match config.mode {
2058 VgrModeConfig::Off => libsy::ServingMode::Off,
2059 VgrModeConfig::Evaluate => libsy::ServingMode::Evaluate,
2060 VgrModeConfig::Shadow => libsy::ServingMode::Shadow,
2061 VgrModeConfig::Active => libsy::ServingMode::Active {
2062 approval: config.active_approval.clone().unwrap_or_default(),
2063 },
2064 };
2065 runtime.deadline = vgr_duration(route_name, "deadline_seconds", config.deadline_seconds)?;
2066 runtime.bounded_efficient = config.bounded_efficient;
2067 runtime.task_typing = config.task_typing;
2068 runtime.confirmed_recovery_min_clean_tail = config.confirmed_recovery_min_clean_tail;
2069 runtime.agentic_handoff = config.agentic_handoff;
2070 runtime.compact_handoff = config.compact_handoff;
2071 runtime.latch_escalation = config.latch_escalation;
2072 runtime.efficient_turn_budget = config
2073 .efficient_turn_budget_seconds
2074 .map(|seconds| vgr_duration(route_name, "efficient_turn_budget_seconds", seconds))
2075 .transpose()?;
2076 runtime.breaker = BreakerConfig {
2077 threshold: config.breaker_threshold,
2078 cooldown: vgr_duration(
2079 route_name,
2080 "breaker_cooldown_seconds",
2081 config.breaker_cooldown_seconds,
2082 )?,
2083 };
2084 Vgr::new(runtime)
2085 .map(|algorithm| Arc::new(algorithm) as Arc<dyn Algorithm>)
2086 .map_err(|error| {
2087 AlgorithmConfigError::with_source(format!("vgr route {route_name}: {error}"), error)
2088 })
2089}
2090
2091fn vgr_duration(route: &str, field: &str, seconds: f64) -> AlgorithmResult<std::time::Duration> {
2092 let message = || format!("vgr route {route}: {field} must be a positive number");
2093 let duration = std::time::Duration::try_from_secs_f64(seconds)
2094 .map_err(|error| AlgorithmConfigError::with_source(message(), error))?;
2095 if duration.is_zero() {
2096 return Err(AlgorithmConfigError::new(message()));
2097 }
2098 Ok(duration)
2099}
2100
2101const fn default_max_reviews() -> u32 {
2102 1
2103}
2104
2105const fn default_advisor_max_tokens() -> u64 {
2106 2048
2107}
2108
2109const fn default_transcript_max_chars() -> usize {
2110 200_000
2111}
2112
2113const fn default_fail_open() -> bool {
2114 true
2115}
2116
2117fn classifier_contract(prompt: Option<&str>) -> ClassifierContractConfig {
2118 prompt.map_or_else(ClassifierContractConfig::default, |prompt| {
2119 ClassifierContractConfig::default().with_prompt(prompt)
2120 })
2121}
2122
2123fn default_classifier_max_output_tokens() -> u64 {
2124 LlmCapabilityConfig::default().max_output_tokens
2125}
2126
2127fn warn_identical_tiers(route_name: &str, capable_target: &str, efficient_target: &str) {
2131 if capable_target == efficient_target {
2132 tracing::warn!(
2133 route = route_name,
2134 target = capable_target,
2135 "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"
2136 );
2137 }
2138}
2139
2140fn warn_single_target_classifier(route_name: &str, models: &CategoryModelConfig) {
2141 if models.routing_names().len() < 2 {
2142 tracing::warn!(
2143 route = route_name,
2144 "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."
2145 );
2146 }
2147}
2148
2149const fn default_vgr_deadline() -> f64 {
2150 30.0
2151}
2152
2153const fn default_true() -> bool {
2154 true
2155}
2156
2157const fn default_vgr_breaker_threshold() -> u32 {
2158 5
2159}
2160
2161const fn default_vgr_breaker_cooldown() -> f64 {
2162 30.0
2163}
2164
2165fn resolve_target_model_id(
2166 route_name: &str,
2167 name: &str,
2168 targets: &BTreeMap<String, ModelId>,
2169) -> AlgorithmResult<ModelId> {
2170 targets.get(name).cloned().ok_or_else(|| {
2171 AlgorithmConfigError::new(format!(
2172 "route {route_name} references unknown target {name}"
2173 ))
2174 })
2175}
2176
2177fn resolve_runtime_models(
2178 route_name: &str,
2179 names: HashMap<Category, Vec<String>>,
2180 targets: &BTreeMap<String, ModelId>,
2181) -> AlgorithmResult<HashMap<Category, Vec<ModelId>>> {
2182 names
2183 .into_iter()
2184 .map(|(category, names)| {
2185 let models = names
2186 .into_iter()
2187 .map(|name| resolve_target_model_id(route_name, &name, targets))
2188 .collect::<AlgorithmResult<Vec<_>>>()?;
2189 Ok((category, models))
2190 })
2191 .collect()
2192}
2193
2194#[cfg(test)]
2195mod tests {
2196 use super::*;
2197
2198 #[test]
2199 fn restricted_random_preserves_weight_positions() -> AlgorithmResult<()> {
2200 let allowed = BTreeSet::from(["private_a", "private_b"]);
2201 let mut algorithm = AlgorithmSpec::Random {
2202 targets: ["public_a", "private_a", "public_b", "private_b"]
2203 .map(String::from)
2204 .to_vec(),
2205 weights: Some(vec![1.0, 2.0, 3.0, 4.0]),
2206 seed: Some(42),
2207 };
2208 let AlgorithmSpec::Random {
2209 targets,
2210 weights,
2211 seed,
2212 } = algorithm.restricted_to(&allowed)?
2213 else {
2214 unreachable!();
2215 };
2216 assert_eq!(targets, ["private_a", "private_b"]);
2217 assert_eq!(weights, Some(vec![2.0, 4.0]));
2218 assert_eq!(seed, Some(42));
2219 if let AlgorithmSpec::Random { weights, .. } = &mut algorithm {
2220 *weights = Some(vec![1.0]);
2221 }
2222 assert!(algorithm.restricted_to(&allowed).is_err());
2223 Ok(())
2224 }
2225
2226 fn custom_classifier() -> LlmClassifierRouteConfig {
2227 LlmClassifierRouteConfig {
2228 mode: Some(ClassifierMode::Custom),
2229 models: Some(CategoryModelConfig(BTreeMap::from([
2230 ("any".into(), vec!["public".into(), "private".into()]),
2231 ("efficient".into(), vec!["public".into(), "private".into()]),
2232 ("judge".into(), vec!["judge".into()]),
2233 ("research".into(), vec!["public".into()]),
2234 ]))),
2235 default_target: Some("efficient".into()),
2236 prompt: Some("Select an available target.".into()),
2237 response_schema: Some(r#"{"type":"object"}"#.into()),
2238 max_output_tokens: default_classifier_max_output_tokens(),
2239 policy: Some(ClassifierPolicyConfig::TargetSelector {
2240 selector: "/target".into(),
2241 }),
2242 ..LlmClassifierRouteConfig::default()
2243 }
2244 }
2245
2246 #[test]
2247 fn restricted_custom_pools_cover_parent_and_subagents() -> AlgorithmResult<()> {
2248 let allowed = BTreeSet::from(["private", "judge"]);
2249 let targets = BTreeMap::from([
2250 ("private".into(), ModelId::from("private")),
2251 ("judge".into(), ModelId::from("judge")),
2252 ]);
2253 let parent = AlgorithmSpec::LlmClassifier {
2254 config: custom_classifier(),
2255 };
2256 let child = AlgorithmSpec::Passthrough {
2257 target: "private".into(),
2258 subagents: Some(SubagentRouteConfig::LlmClassifier(Box::new(
2259 custom_classifier(),
2260 ))),
2261 };
2262 for algorithm in [parent, child] {
2263 let restricted = algorithm.restricted_to(&allowed)?;
2264 let groups = restricted.runtime_model_names("test")?;
2265 let groups = groups.subagent.unwrap_or(groups.parent);
2266 assert_eq!(groups[&Category::Any], ["private"]);
2267 assert_eq!(groups[&Category::Efficient], ["private"]);
2268 assert_eq!(groups[&Category::Judge], ["judge"]);
2269 restricted.build("test", &targets)?;
2270 let missing_judge = algorithm.restricted_to(&BTreeSet::from(["private"]))?;
2271 assert!(missing_judge.build("test", &targets).is_err());
2272 }
2273 Ok(())
2274 }
2275
2276 #[test]
2277 fn restricted_roles_are_not_replaced() {
2278 let stage = AlgorithmSpec::Auto {
2279 capable_target: "private".into(),
2280 efficient_target: "public".into(),
2281 };
2282 assert!(stage.restricted_to(&BTreeSet::from(["private"])).is_err());
2283 }
2284}