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 DecisionJudgeConfig, EscalationJudgeConfig, GateTrigger, HandoffNoteConfig,
17 LlmCapabilityConfig, LlmClassifierConfig, LlmFallback, LlmTaskClassifier, Noop, Passthrough,
18 PickerMode, PlanExecute, PlanExecuteConfig, Random, RuntimeModels, StageRouter,
19 StageRouterConfig, SubagentRouter, SubagentRouterConfig, TaskClassifierConfig, ToolSemantics,
20 Vgr, VgrConfig,
21};
22use serde::Deserialize;
23use serde_json::Value;
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}
86
87impl ClassifierPolicyConfig {
88 fn into_libsy(self) -> CustomClassifierPolicy {
89 match self {
90 Self::TargetSelector { selector } => CustomClassifierPolicy::target_selector(selector),
91 }
92 }
93}
94
95#[derive(Clone, Debug)]
96enum LlmClassifierModeConfig {
97 Capability(CapabilityClassifierRouteConfig),
98 Escalation(EscalationClassifierRouteConfig),
99 Custom(CustomClassifierRouteConfig),
100}
101
102#[derive(Clone, Debug)]
103struct CapabilityClassifierRouteConfig {
104 classifier_target: String,
105 fail_open: bool,
106 strong_target: String,
107 weak_target: String,
108 judge: CapabilityJudgeRouteConfig,
109 classify_trigger: ClassifyTrigger,
110 message_hash_fallback: bool,
111 recent_turn_window: Option<usize>,
112}
113
114impl CapabilityClassifierRouteConfig {
115 fn into_task_config(
116 self,
117 route_name: &str,
118 targets: &BTreeMap<String, ModelId>,
119 ) -> AlgorithmResult<TaskClassifierConfig> {
120 let config = self;
121 let judge = match config.judge {
122 CapabilityJudgeRouteConfig::Llm(judge) => CapabilityJudgeConfig::Llm(judge),
123 CapabilityJudgeRouteConfig::Decision(judge) => {
124 for target in [&config.strong_target, &config.weak_target] {
125 if !judge.candidates.values().any(|name| name == target) {
126 return Err(AlgorithmConfigError::new(format!(
127 "route {route_name}: decision.candidates must include target {target}"
128 )));
129 }
130 }
131 let candidates = judge
132 .candidates
133 .into_iter()
134 .map(|(label, name)| {
135 resolve_target_model_id(route_name, &name, targets)
136 .map(|model| (label, model))
137 })
138 .collect::<AlgorithmResult<_>>()?;
139 CapabilityJudgeConfig::Decision(DecisionJudgeConfig {
140 cutoff: judge.cutoff,
141 instructions: judge.instructions,
142 candidates,
143 evidence: judge.evidence,
144 })
145 }
146 };
147 Ok(TaskClassifierConfig {
148 judge,
149 fail_open: config.fail_open,
150 classify_trigger: config.classify_trigger,
151 message_hash_fallback: config.message_hash_fallback,
152 recent_turn_window: config.recent_turn_window,
153 })
154 }
155}
156
157#[derive(Clone, Debug)]
158enum CapabilityJudgeRouteConfig {
159 Llm(LlmCapabilityConfig),
160 Decision(DecisionJudgeRouteConfig),
161}
162
163#[derive(Clone, Debug)]
164struct EscalationClassifierRouteConfig {
165 classifier_target: String,
166 strong_target: String,
167 weak_target: String,
168 prompt: Option<String>,
169 response_format_type: ClassifierResponseFormat,
170 max_output_tokens: u64,
171 judge: EscalationJudgeConfig,
172}
173
174#[derive(Clone, Debug)]
175struct CustomClassifierRouteConfig {
176 models: CategoryModelConfig,
177 default_target: Category,
178 prompt: String,
179 response_schema: String,
180 policy: ClassifierPolicyConfig,
181 classify_trigger: ClassifyTrigger,
182 message_hash_fallback: bool,
183 recent_turn_window: Option<usize>,
184 max_output_tokens: u64,
185}
186
187#[derive(Clone, Debug, Default, Deserialize)]
195#[serde(transparent)]
196pub struct CategoryModelConfig(BTreeMap<String, Vec<String>>);
197
198impl CategoryModelConfig {
199 fn validate(&self, route_name: &str, default_target: &Category) -> AlgorithmResult<()> {
200 for category in [Category::Any, Category::Judge] {
201 if self.get(&category).is_empty() {
202 return Err(AlgorithmConfigError::new(format!(
203 "llm_classifier route {route_name} models.{} must contain at least one target",
204 category.as_str()
205 )));
206 }
207 }
208 if self.get(default_target).is_empty() {
209 return Err(AlgorithmConfigError::new(format!(
210 "llm_classifier route {route_name} models.{} must contain at least one target because it is the default_target",
211 default_target.as_str()
212 )));
213 }
214 let any = self.get(&Category::Any);
218 for (name, models) in &self.0 {
219 if name == Category::Judge.as_str() || name == Category::Any.as_str() {
220 continue;
221 }
222 if let Some(missing) = models.iter().find(|model| !any.contains(model)) {
223 return Err(AlgorithmConfigError::new(format!(
224 "llm_classifier route {route_name} models.{name} lists target {missing}, which must also appear in models.any"
225 )));
226 }
227 }
228 Ok(())
229 }
230
231 fn get(&self, category: &Category) -> &[String] {
232 self.0.get(category.as_str()).map_or(&[], Vec::as_slice)
233 }
234
235 fn all_names(&self) -> Vec<&str> {
237 Self::deduped(self.0.values().flatten())
238 }
239
240 fn routing_names(&self) -> Vec<&str> {
245 let others = self
246 .0
247 .iter()
248 .filter(|(name, _)| {
249 *name != Category::Judge.as_str() && *name != Category::Any.as_str()
250 })
251 .flat_map(|(_, models)| models);
252 Self::deduped(self.get(&Category::Any).iter().chain(others))
253 }
254
255 fn deduped<'a>(names: impl Iterator<Item = &'a String>) -> Vec<&'a str> {
256 let mut seen = BTreeSet::new();
257 names
258 .map(String::as_str)
259 .filter(|name| seen.insert(*name))
260 .collect()
261 }
262
263 fn groups(&self) -> impl Iterator<Item = (Category, Vec<String>)> + '_ {
264 self.0
265 .iter()
266 .filter_map(|(name, models)| Some((name.parse::<Category>().ok()?, models.clone())))
267 }
268}
269
270#[derive(Clone, Debug, Deserialize)]
272#[serde(deny_unknown_fields)]
273pub struct DecisionJudgeRouteConfig {
274 pub cutoff: f64,
276 pub instructions: Option<Value>,
278 pub candidates: BTreeMap<String, String>,
280 pub evidence: Value,
282}
283
284#[derive(Clone, Debug, Default, Deserialize)]
287#[serde(default, deny_unknown_fields)]
288pub struct LlmClassifierRouteConfig {
289 pub classifier_target: String,
291 pub mode: Option<ClassifierMode>,
293 pub strong_target: Option<String>,
295 pub weak_target: Option<String>,
297 pub base_threshold: Option<f64>,
300 pub threshold_step: Option<f64>,
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 pub escalation: Option<EscalationJudgeConfig>,
325 pub decision: Option<DecisionJudgeRouteConfig>,
327 pub models: Option<CategoryModelConfig>,
329 pub default_target: Option<String>,
331 pub response_schema: Option<String>,
333 pub policy: Option<ClassifierPolicyConfig>,
335}
336
337#[derive(Clone, Debug, Deserialize)]
340#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
341pub enum SubagentRouteConfig {
342 Passthrough {
344 target: String,
346 },
347 LlmClassifier(Box<LlmClassifierRouteConfig>),
349}
350
351impl SubagentRouteConfig {
352 fn routing_target_names(&self) -> Vec<&str> {
353 match self {
354 Self::Passthrough { target } => vec![target],
355 Self::LlmClassifier(classifier) => classifier
356 .models
357 .as_ref()
358 .map(CategoryModelConfig::routing_names)
359 .unwrap_or_default(),
360 }
361 }
362
363 fn judge_target_names(&self) -> Vec<&str> {
364 match self {
365 Self::Passthrough { .. } => Vec::new(),
366 Self::LlmClassifier(classifier) => classifier
367 .models
368 .as_ref()
369 .map(|models| models.get(&Category::Judge))
370 .unwrap_or_default()
371 .iter()
372 .map(String::as_str)
373 .collect(),
374 }
375 }
376}
377
378#[derive(Clone, Debug, Deserialize)]
380#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
381pub enum AlgorithmSpec {
382 Noop {},
384 Random {
386 targets: Vec<String>,
388 weights: Option<Vec<f64>>,
390 seed: Option<u64>,
392 },
393 Passthrough {
395 target: String,
397 #[serde(default)]
399 subagents: Option<SubagentRouteConfig>,
400 },
401 PlanExecute {
403 capable_target: String,
405 efficient_target: String,
407 #[serde(default)]
409 tool_semantics: ToolSemantics,
410 #[serde(default)]
412 planning_prompt: Option<String>,
413 #[serde(default)]
415 handoff_prompt: Option<String>,
416 #[serde(default)]
418 planner_reasoning_as_text: bool,
419 },
420 LlmClassifier {
422 #[serde(flatten)]
424 config: LlmClassifierRouteConfig,
425 },
426 StageRouter {
428 #[serde(flatten)]
429 tiers: StageTierConfig,
430 picker: PickerMode,
432 #[serde(default)]
434 classifier: Option<StageClassifierConfig>,
435 #[serde(default)]
437 subagents: Option<SubagentRouteConfig>,
438 },
439 Auto {
444 capable_target: String,
446 efficient_target: String,
448 },
449 Composite {
451 classifier: StageClassifierConfig,
453 stage: StageTierConfig,
455 #[serde(default)]
457 subagents: Option<SubagentRouteConfig>,
458 },
459 Advisor {
462 executor_target: String,
464 advisor_target: String,
466 #[serde(default)]
468 reviewer_system_prompt: Option<String>,
469 #[serde(default)]
471 redo_feedback_prefix: Option<String>,
472 #[serde(default)]
474 gate_trigger: AdvisorTriggerConfig,
475 #[serde(default)]
477 gate_trigger_pattern: Option<String>,
478 #[serde(default = "default_max_reviews")]
480 max_reviews: u32,
481 #[serde(default)]
484 gate_stall_turns: u32,
485 #[serde(default)]
487 gate_min_tool_results: u32,
488 #[serde(default = "default_advisor_max_tokens")]
490 advisor_max_tokens: u64,
491 #[serde(default)]
493 advisor_temperature: Option<f64>,
494 #[serde(default = "default_transcript_max_chars")]
497 transcript_max_chars: usize,
498 #[serde(default = "default_fail_open")]
500 fail_open: bool,
501 },
502 Vgr {
503 #[serde(flatten)]
504 config: VgrRouteConfig,
505 },
506 PrefillRouter {
508 targets: Vec<String>,
510 checkpoint: PathBuf,
512 device: Option<String>,
515 cache_dir: Option<PathBuf>,
517 max_length: Option<usize>,
519 batch_size: Option<usize>,
521 },
522}
523
524#[derive(Clone, Debug, Deserialize)]
525#[serde(deny_unknown_fields)]
526pub struct VgrRouteConfig {
527 pub efficient_target: String,
528 pub capable_target: String,
529 #[serde(default)]
531 pub judge_target: Option<String>,
532 #[serde(default)]
533 pub capable_judge_target: Option<String>,
534 #[serde(default)]
535 pub mode: VgrModeConfig,
536 #[serde(default)]
537 pub active_approval: Option<String>,
538 #[serde(default = "default_vgr_deadline")]
539 pub deadline_seconds: f64,
540 #[serde(default = "default_true")]
541 pub task_typing: bool,
542 #[serde(default = "default_vgr_breaker_threshold")]
543 pub breaker_threshold: u32,
544 #[serde(default = "default_vgr_breaker_cooldown")]
545 pub breaker_cooldown_seconds: f64,
546 #[serde(default)]
548 pub confirmed_recovery_min_clean_tail: Option<u32>,
549 #[serde(default)]
551 pub agentic_handoff: bool,
552 #[serde(default)]
554 pub compact_handoff: bool,
555 #[serde(default)]
557 pub efficient_turn_budget_seconds: Option<f64>,
558 #[serde(default)]
560 pub latch_escalation: bool,
561}
562
563#[derive(Clone, Debug, Default, Deserialize)]
564#[serde(rename_all = "snake_case")]
565pub enum VgrModeConfig {
566 #[default]
567 Off,
568 Evaluate,
569 Shadow,
570 Active,
571}
572
573#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
575#[serde(rename_all = "snake_case")]
576pub enum AdvisorTriggerConfig {
577 #[default]
579 NoToolCall,
580 Pattern,
582}
583
584#[derive(Clone, Debug, Deserialize)]
586#[serde(deny_unknown_fields)]
587pub struct StageClassifierConfig {
588 pub target: String,
590 #[serde(default)]
592 pub mode: Option<ClassifierMode>,
593 #[serde(default)]
594 pub response_schema: Option<String>,
595 #[serde(default)]
596 pub policy: Option<ClassifierPolicyConfig>,
597 #[serde(default)]
598 pub decision: Option<DecisionJudgeRouteConfig>,
599 #[serde(default)]
600 pub fail_open: Option<bool>,
601 pub base_threshold: Option<f64>,
603 #[serde(default)]
606 pub threshold_step: Option<f64>,
607 #[serde(default)]
611 pub classify_trigger: ClassifyTrigger,
612 #[serde(default)]
615 pub message_hash_fallback: bool,
616 #[serde(default)]
619 pub recent_turn_window: Option<usize>,
620 #[serde(default)]
622 pub prompt: Option<String>,
623 #[serde(default)]
626 pub response_format_type: ClassifierResponseFormat,
627 #[serde(default = "default_classifier_max_output_tokens")]
629 pub max_output_tokens: u64,
630}
631
632#[derive(Clone, Debug, Deserialize)]
637#[serde(deny_unknown_fields)]
638pub struct StageTierConfig {
639 pub capable_target: String,
641 pub efficient_target: String,
643 #[serde(default, alias = "efficient_first_threshold")]
645 pub confidence_threshold: Option<f64>,
646 #[serde(default)]
648 pub capable_first_threshold: f64,
649 #[serde(default)]
651 pub recent_turn_window: Option<usize>,
652 #[serde(default)]
654 pub tool_semantics: ToolSemantics,
655 #[serde(default)]
657 pub capable_hold_turns: Option<i64>,
658 #[serde(default)]
661 pub efficient_hold_turns: Option<i64>,
662 #[serde(default)]
664 pub handoff_notes: Option<HandoffNoteConfig>,
665}
666
667impl StageTierConfig {
668 fn warn_identical_tiers(&self, route_name: &str) {
670 warn_identical_tiers(route_name, &self.capable_target, &self.efficient_target);
671 }
672}
673
674impl StageClassifierConfig {
675 fn task_classifier_config(
676 &self,
677 route_name: &str,
678 tiers: &StageTierConfig,
679 targets: &BTreeMap<String, ModelId>,
680 ) -> AlgorithmResult<TaskClassifierConfig> {
681 if self
682 .mode
683 .is_some_and(|mode| !matches!(mode, ClassifierMode::Capability))
684 || self.response_schema.is_some()
685 || self.policy.is_some()
686 {
687 return Err(AlgorithmConfigError::new(
688 "custom classifier settings require composite with mode = custom",
689 ));
690 }
691 let config = LlmClassifierRouteConfig {
692 classifier_target: self.target.clone(),
693 strong_target: Some(tiers.capable_target.clone()),
694 weak_target: Some(tiers.efficient_target.clone()),
695 base_threshold: self.base_threshold,
696 threshold_step: self.threshold_step,
697 fail_open: self.fail_open,
698 decision: self.decision.clone(),
699 classify_trigger: self.classify_trigger,
700 message_hash_fallback: self.message_hash_fallback,
701 recent_turn_window: self.recent_turn_window,
702 prompt: self.prompt.clone(),
703 response_format_type: self.response_format_type,
704 max_output_tokens: self.max_output_tokens,
705 ..Default::default()
706 };
707 match config.validated_classifier_mode(route_name)? {
708 LlmClassifierModeConfig::Capability(config) => {
709 config.into_task_config(route_name, targets)
710 }
711 _ => Err(AlgorithmConfigError::new("expected a capability judge")),
712 }
713 }
714
715 fn custom_classifier_config(
716 &self,
717 route_name: &str,
718 ) -> AlgorithmResult<CustomClassifierConfig> {
719 if self.fail_open == Some(false) {
720 return Err(AlgorithmConfigError::new(
721 "composite custom classifiers always fail open; remove fail_open = false",
722 ));
723 }
724 if self.decision.is_some()
725 || self.base_threshold.is_some()
726 || self.threshold_step.is_some()
727 || self.response_format_type != ClassifierResponseFormat::JsonSchema
728 {
729 return Err(AlgorithmConfigError::new(
730 "composite custom classifier cannot use capability thresholds, decision settings, or response_format_type",
731 ));
732 }
733 let schema =
734 required_classifier_field(route_name, "response_schema", &self.response_schema)?;
735 let schema = serde_json::from_str(&schema).map_err(|error| {
736 AlgorithmConfigError::with_source(
737 format!("composite route {route_name}: response_schema is invalid JSON"),
738 error,
739 )
740 })?;
741 let mut config = CustomClassifierConfig::new(
742 required_classifier_field(route_name, "prompt", &self.prompt)?,
743 schema,
744 required_classifier_field(route_name, "policy", &self.policy)?.into_libsy(),
745 );
746 config.classify_trigger = self.classify_trigger;
747 config.message_hash_fallback = self.message_hash_fallback;
748 config.recent_turn_window = self.recent_turn_window;
749 config.max_output_tokens = self.max_output_tokens;
750 Ok(config)
751 }
752}
753
754impl AlgorithmSpec {
755 pub(crate) fn decision_judge(&self) -> Option<(&str, &DecisionJudgeRouteConfig)> {
756 match self {
757 Self::LlmClassifier { config, .. } => config
758 .decision
759 .as_ref()
760 .map(|decision| (config.classifier_target.as_str(), decision)),
761 Self::Composite { classifier, .. }
762 | Self::StageRouter {
763 classifier: Some(classifier),
764 ..
765 } => classifier
766 .decision
767 .as_ref()
768 .map(|decision| (classifier.target.as_str(), decision)),
769 _ => None,
770 }
771 }
772
773 pub fn routing_target_names(&self) -> Vec<&str> {
775 match self {
776 Self::Noop { .. } => Vec::new(),
777 Self::Random { targets, .. } => targets.iter().map(String::as_str).collect(),
778 Self::Passthrough {
779 target, subagents, ..
780 } => {
781 let mut names = vec![target.as_str()];
782 if let Some(subagents) = subagents {
783 names.extend(subagents.routing_target_names());
784 }
785 names
786 }
787 Self::PlanExecute {
788 capable_target,
789 efficient_target,
790 ..
791 } => vec![capable_target.as_str(), efficient_target.as_str()],
792 Self::LlmClassifier { config, .. } => match config.classifier_mode() {
793 ClassifierMode::Capability => config
794 .weak_target
795 .iter()
796 .chain(&config.strong_target)
797 .map(String::as_str)
798 .collect(),
799 ClassifierMode::Escalation => config
800 .strong_target
801 .iter()
802 .chain(&config.weak_target)
803 .map(String::as_str)
804 .collect(),
805 ClassifierMode::Custom => config
806 .models
807 .as_ref()
808 .map(CategoryModelConfig::routing_names)
809 .unwrap_or_default(),
810 },
811 Self::StageRouter {
812 tiers, subagents, ..
813 } => {
814 let mut names = vec![
815 tiers.capable_target.as_str(),
816 tiers.efficient_target.as_str(),
817 ];
818 if let Some(subagents) = subagents {
819 names.extend(subagents.routing_target_names());
820 }
821 names
822 }
823 Self::Auto {
824 capable_target,
825 efficient_target,
826 } => vec![capable_target.as_str(), efficient_target.as_str()],
827 Self::Composite {
828 stage, subagents, ..
829 } => {
830 let mut names = vec![
831 stage.capable_target.as_str(),
832 stage.efficient_target.as_str(),
833 ];
834 if let Some(subagents) = subagents {
835 names.extend(subagents.routing_target_names());
836 }
837 names
838 }
839 Self::Advisor {
842 executor_target, ..
843 } => vec![executor_target],
844 Self::Vgr { config } => {
845 vec![
846 config.efficient_target.as_str(),
847 config.capable_target.as_str(),
848 ]
849 }
850 Self::PrefillRouter { targets, .. } => targets.iter().map(String::as_str).collect(),
851 }
852 }
853
854 pub fn callable_target_names(&self) -> Vec<&str> {
859 let mut names = self.routing_target_names();
860 match self {
861 Self::LlmClassifier { config, .. } => {
864 if matches!(config.classifier_mode(), ClassifierMode::Custom) {
865 names.extend(
866 config
867 .models
868 .as_ref()
869 .map(|models| models.get(&Category::Judge))
870 .unwrap_or_default()
871 .iter()
872 .map(String::as_str),
873 );
874 } else {
875 names.push(&config.classifier_target);
876 }
877 }
878 Self::StageRouter {
879 classifier: Some(classifier),
880 ..
881 } => names.push(&classifier.target),
882 Self::Composite { classifier, .. } => {
883 names.push(&classifier.target);
884 }
885 Self::Advisor { advisor_target, .. } => names.push(advisor_target),
886 Self::Vgr { config } => {
887 names.extend(config.judge_target.as_deref());
888 names.extend(config.capable_judge_target.as_deref());
889 }
890 _ => {}
891 }
892 if let Self::Passthrough {
894 subagents: Some(subagents),
895 ..
896 }
897 | Self::StageRouter {
898 subagents: Some(subagents),
899 ..
900 }
901 | Self::Composite {
902 subagents: Some(subagents),
903 ..
904 } = self
905 {
906 names.extend(subagents.judge_target_names());
907 }
908 names
909 }
910
911 fn runtime_model_names(&self, route_name: &str) -> AlgorithmResult<RuntimeModelNames> {
913 let parent = match self {
914 Self::Noop { .. } => HashMap::new(),
915 Self::Random { targets, .. } | Self::PrefillRouter { targets, .. } => {
916 category_models([(Category::Any, targets.clone())])
917 }
918 Self::Passthrough { target, .. } => {
919 category_models([(Category::Any, vec![target.clone()])])
920 }
921 Self::PlanExecute {
922 capable_target,
923 efficient_target,
924 ..
925 } => category_models([
926 (Category::Capable, vec![capable_target.clone()]),
927 (Category::Efficient, vec![efficient_target.clone()]),
928 (
929 Category::Any,
930 vec![capable_target.clone(), efficient_target.clone()],
931 ),
932 ]),
933 Self::LlmClassifier { config } => {
934 classifier_runtime_model_names(config.validated_classifier_mode(route_name)?)
935 }
936 Self::StageRouter {
937 tiers, classifier, ..
938 } => {
939 let mut models = category_models([
940 (Category::Capable, vec![tiers.capable_target.clone()]),
941 (Category::Efficient, vec![tiers.efficient_target.clone()]),
942 (
943 Category::Any,
944 vec![tiers.capable_target.clone(), tiers.efficient_target.clone()],
945 ),
946 ]);
947 if let Some(classifier) = classifier {
948 models.insert(Category::Judge, vec![classifier.target.clone()]);
949 }
950 models
951 }
952 Self::Auto {
953 capable_target,
954 efficient_target,
955 } => category_models([
956 (Category::Capable, vec![capable_target.clone()]),
957 (Category::Efficient, vec![efficient_target.clone()]),
958 (
959 Category::Any,
960 vec![capable_target.clone(), efficient_target.clone()],
961 ),
962 ]),
963 Self::Composite {
964 classifier, stage, ..
965 } => category_models([
966 (Category::Judge, vec![classifier.target.clone()]),
967 (Category::Capable, vec![stage.capable_target.clone()]),
968 (Category::Efficient, vec![stage.efficient_target.clone()]),
969 (
970 Category::Any,
971 vec![stage.capable_target.clone(), stage.efficient_target.clone()],
972 ),
973 ]),
974 Self::Advisor {
975 executor_target,
976 advisor_target,
977 ..
978 } => category_models([
979 (Category::Efficient, vec![executor_target.clone()]),
980 (Category::Any, vec![executor_target.clone()]),
981 (Category::Judge, vec![advisor_target.clone()]),
982 ]),
983 Self::Vgr { config } => {
984 let judge = config
985 .judge_target
986 .as_ref()
987 .unwrap_or(&config.efficient_target);
988 let judges = std::iter::once(judge)
989 .chain(&config.capable_judge_target)
990 .cloned()
991 .collect();
992 category_models([
993 (Category::Efficient, vec![config.efficient_target.clone()]),
994 (Category::Capable, vec![config.capable_target.clone()]),
995 (
996 Category::Any,
997 vec![
998 config.efficient_target.clone(),
999 config.capable_target.clone(),
1000 ],
1001 ),
1002 (Category::Judge, judges),
1003 ])
1004 }
1005 };
1006
1007 let subagents = match self {
1008 Self::Passthrough { subagents, .. }
1009 | Self::StageRouter { subagents, .. }
1010 | Self::Composite { subagents, .. } => subagents.as_ref(),
1011 _ => None,
1012 };
1013 let subagent = subagents
1017 .map(|subagents| subagent_runtime_model_names(subagents, route_name))
1018 .transpose()?;
1019 Ok(RuntimeModelNames { parent, subagent })
1020 }
1021
1022 pub(crate) fn routing_response_and_dependency(&self) -> Option<(&str, &str)> {
1024 match self {
1025 Self::LlmClassifier { config, .. }
1026 if matches!(config.classifier_mode(), ClassifierMode::Escalation) =>
1027 {
1028 Some((
1029 config.weak_target.as_deref()?,
1030 config.classifier_target.as_str(),
1031 ))
1032 }
1033 Self::Advisor {
1034 executor_target,
1035 advisor_target,
1036 ..
1037 } => Some((executor_target, advisor_target)),
1038 Self::Vgr { config } => Some((
1039 &config.efficient_target,
1040 config
1041 .judge_target
1042 .as_deref()
1043 .unwrap_or(&config.efficient_target),
1044 )),
1045 Self::Noop { .. }
1046 | Self::Random { .. }
1047 | Self::Passthrough { .. }
1048 | Self::PlanExecute { .. }
1049 | Self::LlmClassifier { .. }
1050 | Self::StageRouter { .. }
1051 | Self::Auto { .. }
1052 | Self::Composite { .. }
1053 | Self::PrefillRouter { .. } => None,
1054 }
1055 }
1056
1057 pub fn build(
1059 &self,
1060 route_name: &str,
1061 targets: &BTreeMap<String, ModelId>,
1062 ) -> AlgorithmResult<Arc<dyn Algorithm>> {
1063 build_algorithm(route_name, self, targets)
1064 }
1065
1066 pub fn build_with_runtime_models(
1068 &self,
1069 route_name: &str,
1070 targets: &BTreeMap<String, ModelId>,
1071 ) -> AlgorithmResult<(Arc<dyn Algorithm>, RuntimeModels)> {
1072 let algorithm = self.build(route_name, targets)?;
1073 let names = self.runtime_model_names(route_name)?;
1074 let mut models =
1075 RuntimeModels::new(resolve_runtime_models(route_name, names.parent, targets)?);
1076 if let Some(subagent) = names.subagent {
1077 models = models.with_subagent(resolve_runtime_models(route_name, subagent, targets)?);
1078 }
1079 Ok((algorithm, models))
1080 }
1081}
1082
1083struct RuntimeModelNames {
1085 parent: HashMap<Category, Vec<String>>,
1087 subagent: Option<HashMap<Category, Vec<String>>>,
1089}
1090
1091fn category_models(
1092 entries: impl IntoIterator<Item = (Category, Vec<String>)>,
1093) -> HashMap<Category, Vec<String>> {
1094 entries.into_iter().collect()
1095}
1096
1097fn custom_runtime_model_names(config: &CategoryModelConfig) -> HashMap<Category, Vec<String>> {
1098 config.groups().collect()
1099}
1100
1101fn classifier_runtime_model_names(
1102 config: LlmClassifierModeConfig,
1103) -> HashMap<Category, Vec<String>> {
1104 match config {
1105 LlmClassifierModeConfig::Capability(config) => category_models([
1106 (Category::Judge, vec![config.classifier_target]),
1107 (Category::Efficient, vec![config.weak_target.clone()]),
1108 (Category::Capable, vec![config.strong_target.clone()]),
1109 (
1110 Category::Any,
1111 vec![config.weak_target, config.strong_target],
1112 ),
1113 ]),
1114 LlmClassifierModeConfig::Escalation(config) => category_models([
1115 (Category::Judge, vec![config.classifier_target]),
1116 (Category::Efficient, vec![config.weak_target.clone()]),
1117 (Category::Capable, vec![config.strong_target.clone()]),
1118 (
1119 Category::Any,
1120 vec![config.strong_target, config.weak_target],
1121 ),
1122 ]),
1123 LlmClassifierModeConfig::Custom(config) => custom_runtime_model_names(&config.models),
1124 }
1125}
1126
1127fn subagent_runtime_model_names(
1128 config: &SubagentRouteConfig,
1129 route_name: &str,
1130) -> AlgorithmResult<HashMap<Category, Vec<String>>> {
1131 match config {
1132 SubagentRouteConfig::Passthrough { target } => {
1133 Ok(category_models([(Category::Any, vec![target.clone()])]))
1134 }
1135 SubagentRouteConfig::LlmClassifier(config) => {
1136 let LlmClassifierModeConfig::Custom(config) =
1137 config.validated_classifier_mode(route_name)?
1138 else {
1139 return Err(AlgorithmConfigError::new(format!(
1140 "route {route_name}: subagents llm_classifier only supports mode custom"
1141 )));
1142 };
1143 Ok(custom_runtime_model_names(&config.models))
1144 }
1145 }
1146}
1147impl LlmClassifierRouteConfig {
1148 fn classifier_mode(&self) -> ClassifierMode {
1149 let default = if self.escalation.is_some() {
1150 ClassifierMode::Escalation
1151 } else {
1152 ClassifierMode::Capability
1153 };
1154 self.mode.unwrap_or(default)
1155 }
1156
1157 fn validated_classifier_mode(
1158 &self,
1159 route_name: &str,
1160 ) -> AlgorithmResult<LlmClassifierModeConfig> {
1161 let Self {
1162 classifier_target,
1163 mode,
1164 strong_target,
1165 weak_target,
1166 base_threshold,
1167 threshold_step,
1168 fail_open,
1169 classify_trigger,
1170 message_hash_fallback,
1171 recent_turn_window,
1172 prompt,
1173 response_format_type,
1174 max_output_tokens,
1175 escalation,
1176 decision,
1177 models,
1178 default_target,
1179 response_schema,
1180 policy,
1181 } = self;
1182
1183 let selected_mode = match (mode, escalation.is_some()) {
1184 (Some(mode), _) => *mode,
1185 (None, true) => ClassifierMode::Escalation,
1186 (None, false) => ClassifierMode::Capability,
1187 };
1188
1189 if decision.is_some() && !matches!(selected_mode, ClassifierMode::Capability) {
1190 return Err(AlgorithmConfigError::new(format!(
1191 "llm_classifier route {route_name}: decision requires capability mode"
1192 )));
1193 }
1194
1195 if !matches!(selected_mode, ClassifierMode::Capability) && *fail_open == Some(true) {
1196 return Err(AlgorithmConfigError::new(format!(
1197 "llm_classifier route {route_name}: fail_open = true requires capability mode"
1198 )));
1199 }
1200
1201 match selected_mode {
1202 ClassifierMode::Capability => {
1203 if escalation.is_some() {
1204 return Err(classifier_field_error(
1205 route_name,
1206 "escalation",
1207 "capability",
1208 ));
1209 }
1210 reject_custom_fields(
1211 route_name,
1212 "capability",
1213 models,
1214 default_target,
1215 response_schema,
1216 policy,
1217 )?;
1218 let judge = if let Some(decision) = decision {
1219 if base_threshold.is_some()
1220 || threshold_step.is_some()
1221 || prompt.is_some()
1222 || *response_format_type != ClassifierResponseFormat::JsonSchema
1223 || *max_output_tokens != default_classifier_max_output_tokens()
1224 {
1225 return Err(AlgorithmConfigError::new(format!(
1226 "llm_classifier route {route_name}: decision cannot use LLM judge settings; use decision.cutoff and decision.instructions"
1227 )));
1228 }
1229 CapabilityJudgeRouteConfig::Decision(decision.clone())
1230 } else {
1231 CapabilityJudgeRouteConfig::Llm(LlmCapabilityConfig {
1232 base_threshold: required_classifier_field(
1233 route_name,
1234 "base_threshold",
1235 base_threshold,
1236 )?,
1237 threshold_step: threshold_step.unwrap_or_default(),
1238 contract: classifier_contract(prompt.as_deref())
1239 .with_response_format_type(*response_format_type),
1240 max_output_tokens: *max_output_tokens,
1241 })
1242 };
1243 Ok(LlmClassifierModeConfig::Capability(
1244 CapabilityClassifierRouteConfig {
1245 classifier_target: classifier_target.clone(),
1246 fail_open: fail_open.unwrap_or(true),
1247 strong_target: required_classifier_field(
1248 route_name,
1249 "strong_target",
1250 strong_target,
1251 )?,
1252 weak_target: required_classifier_field(
1253 route_name,
1254 "weak_target",
1255 weak_target,
1256 )?,
1257 judge,
1258 classify_trigger: *classify_trigger,
1259 message_hash_fallback: *message_hash_fallback,
1260 recent_turn_window: *recent_turn_window,
1261 },
1262 ))
1263 }
1264 ClassifierMode::Escalation => {
1265 reject_custom_fields(
1266 route_name,
1267 "escalation",
1268 models,
1269 default_target,
1270 response_schema,
1271 policy,
1272 )?;
1273 if *classify_trigger != ClassifyTrigger::EveryRequest {
1274 return Err(AlgorithmConfigError::new(format!(
1275 "llm_classifier route {route_name} mode escalation cannot use classify_trigger"
1276 )));
1277 }
1278 let has_capability_settings = base_threshold.is_some()
1279 || threshold_step.is_some()
1280 || fail_open.is_some()
1281 || *message_hash_fallback
1282 || recent_turn_window.is_some();
1283 if mode.is_some() && has_capability_settings {
1284 return Err(AlgorithmConfigError::new(format!(
1285 "llm_classifier route {route_name} mode escalation cannot use capability routing settings"
1286 )));
1287 }
1288 if has_capability_settings {
1289 tracing::warn!(
1292 "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."
1293 );
1294 }
1295 Ok(LlmClassifierModeConfig::Escalation(
1296 EscalationClassifierRouteConfig {
1297 classifier_target: classifier_target.clone(),
1298 strong_target: required_classifier_field(
1299 route_name,
1300 "strong_target",
1301 strong_target,
1302 )?,
1303 weak_target: required_classifier_field(
1304 route_name,
1305 "weak_target",
1306 weak_target,
1307 )?,
1308 prompt: prompt.clone(),
1309 response_format_type: *response_format_type,
1310 max_output_tokens: *max_output_tokens,
1311 judge: required_classifier_field(route_name, "escalation", escalation)?,
1312 },
1313 ))
1314 }
1315 ClassifierMode::Custom => {
1316 if !classifier_target.is_empty()
1317 || strong_target.is_some()
1318 || weak_target.is_some()
1319 || base_threshold.is_some()
1320 || threshold_step.is_some()
1321 || escalation.is_some()
1322 || *response_format_type != ClassifierResponseFormat::JsonSchema
1323 {
1324 return Err(AlgorithmConfigError::new(format!(
1325 "llm_classifier route {route_name} mode custom cannot use capability or escalation fields and response_format_type must be 'json_schema'"
1326 )));
1327 }
1328 let models = required_classifier_field(route_name, "models", models)?;
1329 let default_target: Category = required_classifier_field(
1330 route_name,
1331 "default_target",
1332 default_target,
1333 )?
1334 .parse()
1335 .map_err(|error| {
1336 AlgorithmConfigError::new(format!(
1337 "llm_classifier route {route_name} has invalid default_target: {error}"
1338 ))
1339 })?;
1340 if default_target == Category::Judge {
1341 return Err(AlgorithmConfigError::new(format!(
1342 "llm_classifier route {route_name} default_target cannot be judge"
1343 )));
1344 }
1345 models.validate(route_name, &default_target)?;
1346 Ok(LlmClassifierModeConfig::Custom(
1347 CustomClassifierRouteConfig {
1348 models,
1349 default_target,
1350 prompt: required_classifier_field(route_name, "prompt", prompt)?,
1351 response_schema: required_classifier_field(
1352 route_name,
1353 "response_schema",
1354 response_schema,
1355 )?,
1356 policy: required_classifier_field(route_name, "policy", policy)?,
1357 classify_trigger: *classify_trigger,
1358 message_hash_fallback: *message_hash_fallback,
1359 recent_turn_window: *recent_turn_window,
1360 max_output_tokens: *max_output_tokens,
1361 },
1362 ))
1363 }
1364 }
1365 }
1366}
1367
1368fn reject_custom_fields(
1369 route_name: &str,
1370 mode: &str,
1371 models: &Option<CategoryModelConfig>,
1372 default_target: &Option<String>,
1373 response_schema: &Option<String>,
1374 policy: &Option<ClassifierPolicyConfig>,
1375) -> AlgorithmResult<()> {
1376 if models.is_some() || default_target.is_some() || response_schema.is_some() || policy.is_some()
1377 {
1378 return Err(AlgorithmConfigError::new(format!(
1379 "llm_classifier route {route_name} mode {mode} cannot use custom classifier fields"
1380 )));
1381 }
1382 Ok(())
1383}
1384
1385fn classifier_field_error(route_name: &str, field: &str, mode: &str) -> AlgorithmConfigError {
1386 AlgorithmConfigError::new(format!(
1387 "llm_classifier route {route_name} mode {mode} cannot use {field}"
1388 ))
1389}
1390
1391fn required_classifier_field<T: Clone>(
1392 route_name: &str,
1393 field: &str,
1394 value: &Option<T>,
1395) -> AlgorithmResult<T> {
1396 value.clone().ok_or_else(|| {
1397 AlgorithmConfigError::new(format!(
1398 "llm_classifier route {route_name} requires {field}"
1399 ))
1400 })
1401}
1402
1403fn build_subagent_router_config(
1404 route_name: &str,
1405 config: &SubagentRouteConfig,
1406 targets: &BTreeMap<String, ModelId>,
1407) -> AlgorithmResult<SubagentRouterConfig> {
1408 match config {
1409 SubagentRouteConfig::Passthrough { target } => {
1410 resolve_target_model_id(route_name, target, targets)?;
1411 Ok(SubagentRouterConfig::fixed_target())
1412 }
1413 SubagentRouteConfig::LlmClassifier(config) => {
1414 let LlmClassifierModeConfig::Custom(config) =
1415 config.validated_classifier_mode(route_name)?
1416 else {
1417 return Err(AlgorithmConfigError::new(format!(
1418 "route {route_name}: subagents llm_classifier only supports mode custom"
1419 )));
1420 };
1421 for name in config.models.all_names() {
1422 resolve_target_model_id(route_name, name, targets)?;
1423 }
1424 if config.models.get(&config.default_target).is_empty() {
1425 return Err(AlgorithmConfigError::new(format!(
1426 "route {route_name}: subagents llm_classifier has no model for default category {}",
1427 config.default_target.as_str()
1428 )));
1429 }
1430 let response_schema =
1431 serde_json::from_str(&config.response_schema).map_err(|error| {
1432 AlgorithmConfigError::with_source(
1433 format!(
1434 "route {route_name}: subagents llm_classifier response_schema is invalid JSON: {error}"
1435 ),
1436 error,
1437 )
1438 })?;
1439 warn_single_target_classifier(route_name, &config.models);
1440 let mut classifier_config = CustomClassifierConfig::new(
1441 config.prompt,
1442 response_schema,
1443 config.policy.into_libsy(),
1444 );
1445 classifier_config.recent_turn_window = config.recent_turn_window;
1446 classifier_config.max_output_tokens = config.max_output_tokens;
1447 let classifier = Arc::new(
1448 LlmTaskClassifier::new(LlmClassifierConfig::Custom {
1449 default_target: config.default_target.clone(),
1450 config: classifier_config,
1451 })
1452 .map_err(|error| {
1453 AlgorithmConfigError::with_source(
1454 format!("route {route_name}: subagents llm_classifier: {error}"),
1455 error,
1456 )
1457 })?,
1458 );
1459 Ok(SubagentRouterConfig {
1460 classifier,
1461 default_target: config.default_target,
1462 classify_trigger: config.classify_trigger,
1463 message_hash_fallback: config.message_hash_fallback,
1464 })
1465 }
1466 }
1467}
1468
1469fn attach_subagent_router(
1470 route_name: &str,
1471 parent: Arc<dyn Algorithm>,
1472 config: Option<&SubagentRouteConfig>,
1473 targets: &BTreeMap<String, ModelId>,
1474) -> AlgorithmResult<Arc<dyn Algorithm>> {
1475 let Some(config) = config else {
1476 return Ok(parent);
1477 };
1478 let config = build_subagent_router_config(route_name, config, targets)?;
1479 let algorithm = SubagentRouter::new(parent, config).map_err(|error| {
1480 AlgorithmConfigError::with_source(
1481 format!("route {route_name}: subagent routing: {error}"),
1482 error,
1483 )
1484 })?;
1485 Ok(Arc::new(algorithm))
1486}
1487
1488fn build_algorithm(
1489 route_name: &str,
1490 config: &AlgorithmSpec,
1491 targets: &BTreeMap<String, ModelId>,
1492) -> AlgorithmResult<Arc<dyn Algorithm>> {
1493 match config {
1494 AlgorithmSpec::Noop { .. } => Ok(Arc::new(Noop {})),
1495 AlgorithmSpec::Random {
1496 targets: names,
1497 weights,
1498 seed,
1499 } => {
1500 if let Some(weights) = weights
1503 && weights.len() != names.len()
1504 {
1505 return Err(AlgorithmConfigError::new(format!(
1506 "random route {route_name}: expected {} weights, got {}",
1507 names.len(),
1508 weights.len()
1509 )));
1510 }
1511 let mut seen = BTreeSet::new();
1512 if let Some(duplicate) = names.iter().find(|name| !seen.insert(*name)) {
1513 return Err(AlgorithmConfigError::new(format!(
1514 "random route {route_name}: targets must be unique, {duplicate} is repeated"
1515 )));
1516 }
1517 let algorithm = Random::new(weights.clone(), *seed).map_err(|error| {
1518 AlgorithmConfigError::with_source(
1519 format!("random route {route_name}: {error}"),
1520 error,
1521 )
1522 })?;
1523 Ok(Arc::new(algorithm))
1524 }
1525 AlgorithmSpec::Passthrough { subagents, .. } => {
1526 let algorithm = Passthrough;
1527 let parent: Arc<dyn Algorithm> = Arc::new(algorithm);
1528 attach_subagent_router(route_name, parent, subagents.as_ref(), targets)
1529 }
1530 AlgorithmSpec::PlanExecute {
1531 tool_semantics,
1532 planning_prompt,
1533 handoff_prompt,
1534 planner_reasoning_as_text,
1535 ..
1536 } => {
1537 let mut config = PlanExecuteConfig::default();
1538 config.tool_semantics.clone_from(tool_semantics);
1539 if let Some(prompt) = planning_prompt {
1540 config.planning_prompt = prompt.clone();
1541 }
1542 config.handoff_prompt.clone_from(handoff_prompt);
1543 config.planner_reasoning_as_text = *planner_reasoning_as_text;
1544 let algorithm = PlanExecute::new(config).map_err(|error| {
1545 AlgorithmConfigError::with_source(
1546 format!("plan_execute route {route_name}: {error}"),
1547 error,
1548 )
1549 })?;
1550 Ok(Arc::new(algorithm))
1551 }
1552 AlgorithmSpec::LlmClassifier {
1553 config: classifier_config,
1554 ..
1555 } => {
1556 let mode = classifier_config.validated_classifier_mode(route_name)?;
1557 let algorithm = match mode {
1558 LlmClassifierModeConfig::Capability(config) => {
1559 let classifier_config = config.into_task_config(route_name, targets)?;
1560 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1561 config: classifier_config,
1562 })
1563 }
1564 LlmClassifierModeConfig::Escalation(config) => {
1565 LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
1566 contract: classifier_contract(config.prompt.as_deref())
1567 .with_response_format_type(config.response_format_type),
1568 config: config.judge,
1569 max_output_tokens: config.max_output_tokens,
1570 })
1571 }
1572 LlmClassifierModeConfig::Custom(config) => {
1573 let response_schema = serde_json::from_str(&config.response_schema).map_err(
1574 |error| {
1575 AlgorithmConfigError::with_source(
1576 format!(
1577 "llm_classifier route {route_name}: response_schema is invalid JSON: {error}"
1578 ),
1579 error,
1580 )
1581 },
1582 )?;
1583 warn_single_target_classifier(route_name, &config.models);
1584 let mut classifier_config = CustomClassifierConfig::new(
1585 config.prompt,
1586 response_schema,
1587 config.policy.into_libsy(),
1588 );
1589 classifier_config.classify_trigger = config.classify_trigger;
1590 classifier_config.message_hash_fallback = config.message_hash_fallback;
1591 classifier_config.recent_turn_window = config.recent_turn_window;
1592 classifier_config.max_output_tokens = config.max_output_tokens;
1593 LlmTaskClassifier::new(LlmClassifierConfig::Custom {
1594 default_target: config.default_target,
1595 config: classifier_config,
1596 })
1597 }
1598 }
1599 .map_err(|error| {
1600 AlgorithmConfigError::with_source(
1601 format!("llm_classifier route {route_name}: {error}"),
1602 error,
1603 )
1604 })?;
1605 Ok(Arc::new(algorithm))
1606 }
1607 AlgorithmSpec::StageRouter {
1608 tiers,
1609 picker,
1610 classifier,
1611 subagents,
1612 ..
1613 } => {
1614 let StageTierConfig {
1615 confidence_threshold,
1616 recent_turn_window,
1617 tool_semantics,
1618 capable_hold_turns,
1619 efficient_hold_turns,
1620 handoff_notes,
1621 ..
1622 } = tiers;
1623 if matches!(picker, PickerMode::CapableFirst) {
1624 tracing::warn!(
1625 "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."
1626 );
1627 }
1628 tiers.warn_identical_tiers(route_name);
1629 let threshold = confidence_threshold.ok_or_else(|| {
1630 AlgorithmConfigError::new(format!(
1631 "stage_router route {route_name}: confidence_threshold is required"
1632 ))
1633 })?;
1634 let mut config = StageRouterConfig::new(*picker, threshold);
1635 config.capable_first_threshold = tiers.capable_first_threshold;
1636 config.recent_window = *recent_turn_window;
1637 if let Some(turns) = capable_hold_turns {
1638 config.capable_hold_turns = *turns;
1639 }
1640 config.efficient_hold_turns = *efficient_hold_turns;
1641 config.tool_semantics = tool_semantics.clone();
1642 config.handoff_notes = handoff_notes.clone();
1643 config.llm_fallback = classifier
1646 .as_ref()
1647 .map(|classifier| {
1648 classifier
1649 .task_classifier_config(route_name, tiers, targets)
1650 .map(|config| LlmFallback { config })
1651 })
1652 .transpose()?;
1653 let algorithm = StageRouter::new(config).map_err(|error| {
1654 AlgorithmConfigError::with_source(
1655 format!("stage_router route {route_name}: {error}"),
1656 error,
1657 )
1658 })?;
1659 let parent: Arc<dyn Algorithm> = Arc::new(algorithm);
1660 attach_subagent_router(route_name, parent, subagents.as_ref(), targets)
1661 }
1662 AlgorithmSpec::Auto {
1663 capable_target,
1664 efficient_target,
1665 } => {
1666 warn_identical_tiers(route_name, capable_target, efficient_target);
1667 let config = StageRouterConfig::new(PickerMode::EfficientFirst, 0.5);
1668 let algorithm = StageRouter::new(config).map_err(|error| {
1669 AlgorithmConfigError::with_source(
1670 format!("auto route {route_name}: {error}"),
1671 error,
1672 )
1673 })?;
1674 Ok(Arc::new(algorithm))
1675 }
1676 AlgorithmSpec::Composite {
1677 classifier,
1678 stage,
1679 subagents,
1680 } => {
1681 stage.warn_identical_tiers(route_name);
1682 let mut stage_config = StageRouterConfig::new(
1683 PickerMode::EfficientFirst,
1684 stage.confidence_threshold.unwrap_or(0.5),
1685 );
1686 stage_config.capable_first_threshold = stage.capable_first_threshold;
1687 stage_config.recent_window = stage.recent_turn_window;
1688 if let Some(turns) = stage.capable_hold_turns {
1689 stage_config.capable_hold_turns = turns;
1690 }
1691 stage_config.efficient_hold_turns = stage.efficient_hold_turns;
1692 stage_config.tool_semantics = stage.tool_semantics.clone();
1693 stage_config.handoff_notes = stage.handoff_notes.clone();
1694 let judge = if matches!(classifier.mode, Some(ClassifierMode::Custom)) {
1695 CompositeJudgeConfig::Custom(classifier.custom_classifier_config(route_name)?)
1696 } else {
1697 CompositeJudgeConfig::Capability(
1698 classifier.task_classifier_config(route_name, stage, targets)?,
1699 )
1700 };
1701 let algorithm = CompositeRouter::new(CompositeRouterConfig {
1702 judge,
1703 stage: stage_config,
1704 })
1705 .map_err(|error| {
1706 AlgorithmConfigError::with_source(
1707 format!("composite route {route_name}: {error}"),
1708 error,
1709 )
1710 })?;
1711 let parent: Arc<dyn Algorithm> = Arc::new(algorithm);
1712 attach_subagent_router(route_name, parent, subagents.as_ref(), targets)
1713 }
1714 AlgorithmSpec::Advisor {
1715 reviewer_system_prompt,
1716 redo_feedback_prefix,
1717 gate_trigger,
1718 gate_trigger_pattern,
1719 max_reviews,
1720 gate_stall_turns,
1721 gate_min_tool_results,
1722 advisor_max_tokens,
1723 advisor_temperature,
1724 transcript_max_chars,
1725 fail_open,
1726 ..
1727 } => {
1728 if *gate_trigger == AdvisorTriggerConfig::NoToolCall && gate_trigger_pattern.is_some() {
1731 return Err(AlgorithmConfigError::new(format!(
1732 "advisor route {route_name}: gate_trigger_pattern requires \
1733 gate_trigger = \"pattern\""
1734 )));
1735 }
1736 let mut config = AdvisorGateConfig::default();
1737 if let Some(prompt) = reviewer_system_prompt {
1738 config.reviewer_system_prompt = prompt.clone();
1739 }
1740 if let Some(prefix) = redo_feedback_prefix {
1741 config.redo_feedback_prefix = prefix.clone();
1742 }
1743 config.gate_trigger = match gate_trigger {
1744 AdvisorTriggerConfig::NoToolCall => GateTrigger::NoToolCall,
1745 AdvisorTriggerConfig::Pattern => {
1746 GateTrigger::Pattern(gate_trigger_pattern.clone().unwrap_or_default())
1747 }
1748 };
1749 config.max_reviews = *max_reviews;
1750 config.gate_stall_turns = *gate_stall_turns;
1751 config.gate_min_tool_results = *gate_min_tool_results;
1752 config.advisor_max_tokens = *advisor_max_tokens;
1753 config.advisor_temperature = *advisor_temperature;
1754 config.transcript_max_chars = *transcript_max_chars;
1755 config.fail_open = *fail_open;
1756 let algorithm = AdvisorGate::new(config).map_err(|error| {
1757 AlgorithmConfigError::with_source(
1758 format!("advisor route {route_name}: {error}"),
1759 error,
1760 )
1761 })?;
1762 Ok(Arc::new(algorithm))
1763 }
1764 AlgorithmSpec::Vgr { config } => build_vgr(route_name, config, targets),
1765 AlgorithmSpec::PrefillRouter {
1766 targets: names,
1767 checkpoint,
1768 device,
1769 cache_dir,
1770 max_length,
1771 batch_size,
1772 } => {
1773 #[cfg(feature = "prefill-router")]
1774 {
1775 let targets = names
1776 .iter()
1777 .map(|name| resolve_target_model_id(route_name, name, targets))
1778 .collect::<AlgorithmResult<Vec<_>>>()?;
1779 let mut config = prefill_router::PrefillRouterConfig::new(targets, checkpoint);
1780 config.device.clone_from(device);
1781 config.cache_dir.clone_from(cache_dir);
1782 if let Some(max_length) = max_length {
1783 config.max_length = *max_length;
1784 }
1785 if let Some(batch_size) = batch_size {
1786 config.batch_size = *batch_size;
1787 }
1788 let algorithm = config.build().map_err(|error| {
1789 AlgorithmConfigError::with_source(
1790 format!("prefill_router route {route_name}: {error}"),
1791 error,
1792 )
1793 })?;
1794 Ok(Arc::new(algorithm))
1795 }
1796
1797 #[cfg(not(feature = "prefill-router"))]
1798 {
1799 let _ = (
1800 names, checkpoint, device, cache_dir, max_length, batch_size, targets,
1801 );
1802 Err(AlgorithmConfigError::new(format!(
1803 "prefill_router route {route_name} requires the `prefill-router` Cargo feature"
1804 )))
1805 }
1806 }
1807 }
1808}
1809
1810fn build_vgr(
1811 route_name: &str,
1812 config: &VgrRouteConfig,
1813 targets: &BTreeMap<String, ModelId>,
1814) -> AlgorithmResult<Arc<dyn Algorithm>> {
1815 let efficient = resolve_target_model_id(route_name, &config.efficient_target, targets)?;
1816 let capable = resolve_target_model_id(route_name, &config.capable_target, targets)?;
1817 let optional = |name: &Option<String>| {
1818 name.as_deref()
1819 .map(|name| resolve_target_model_id(route_name, name, targets))
1820 .transpose()
1821 };
1822 let mut runtime = VgrConfig::new(efficient, capable);
1823 runtime.targets.judge = optional(&config.judge_target)?;
1824 runtime.targets.capable_judge = optional(&config.capable_judge_target)?;
1825 runtime.mode = match config.mode {
1826 VgrModeConfig::Off => libsy::ServingMode::Off,
1827 VgrModeConfig::Evaluate => libsy::ServingMode::Evaluate,
1828 VgrModeConfig::Shadow => libsy::ServingMode::Shadow,
1829 VgrModeConfig::Active => libsy::ServingMode::Active {
1830 approval: config.active_approval.clone().unwrap_or_default(),
1831 },
1832 };
1833 runtime.deadline = vgr_duration(route_name, "deadline_seconds", config.deadline_seconds)?;
1834 runtime.task_typing = config.task_typing;
1835 runtime.confirmed_recovery_min_clean_tail = config.confirmed_recovery_min_clean_tail;
1836 runtime.agentic_handoff = config.agentic_handoff;
1837 runtime.compact_handoff = config.compact_handoff;
1838 runtime.latch_escalation = config.latch_escalation;
1839 runtime.efficient_turn_budget = config
1840 .efficient_turn_budget_seconds
1841 .map(|seconds| vgr_duration(route_name, "efficient_turn_budget_seconds", seconds))
1842 .transpose()?;
1843 runtime.breaker = BreakerConfig {
1844 threshold: config.breaker_threshold,
1845 cooldown: vgr_duration(
1846 route_name,
1847 "breaker_cooldown_seconds",
1848 config.breaker_cooldown_seconds,
1849 )?,
1850 };
1851 Vgr::new(runtime)
1852 .map(|algorithm| Arc::new(algorithm) as Arc<dyn Algorithm>)
1853 .map_err(|error| {
1854 AlgorithmConfigError::with_source(format!("vgr route {route_name}: {error}"), error)
1855 })
1856}
1857
1858fn vgr_duration(route: &str, field: &str, seconds: f64) -> AlgorithmResult<std::time::Duration> {
1859 let message = || format!("vgr route {route}: {field} must be a positive number");
1860 let duration = std::time::Duration::try_from_secs_f64(seconds)
1861 .map_err(|error| AlgorithmConfigError::with_source(message(), error))?;
1862 if duration.is_zero() {
1863 return Err(AlgorithmConfigError::new(message()));
1864 }
1865 Ok(duration)
1866}
1867
1868const fn default_max_reviews() -> u32 {
1869 1
1870}
1871
1872const fn default_advisor_max_tokens() -> u64 {
1873 2048
1874}
1875
1876const fn default_transcript_max_chars() -> usize {
1877 200_000
1878}
1879
1880const fn default_fail_open() -> bool {
1881 true
1882}
1883
1884fn classifier_contract(prompt: Option<&str>) -> ClassifierContractConfig {
1885 prompt.map_or_else(ClassifierContractConfig::default, |prompt| {
1886 ClassifierContractConfig::default().with_prompt(prompt)
1887 })
1888}
1889
1890fn default_classifier_max_output_tokens() -> u64 {
1891 LlmCapabilityConfig::default().max_output_tokens
1892}
1893
1894fn warn_identical_tiers(route_name: &str, capable_target: &str, efficient_target: &str) {
1898 if capable_target == efficient_target {
1899 tracing::warn!(
1900 route = route_name,
1901 target = capable_target,
1902 "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"
1903 );
1904 }
1905}
1906
1907fn warn_single_target_classifier(route_name: &str, models: &CategoryModelConfig) {
1908 if models.routing_names().len() < 2 {
1909 tracing::warn!(
1910 route = route_name,
1911 "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."
1912 );
1913 }
1914}
1915
1916const fn default_vgr_deadline() -> f64 {
1917 30.0
1918}
1919
1920const fn default_true() -> bool {
1921 true
1922}
1923
1924const fn default_vgr_breaker_threshold() -> u32 {
1925 5
1926}
1927
1928const fn default_vgr_breaker_cooldown() -> f64 {
1929 30.0
1930}
1931
1932fn resolve_target_model_id(
1933 route_name: &str,
1934 name: &str,
1935 targets: &BTreeMap<String, ModelId>,
1936) -> AlgorithmResult<ModelId> {
1937 targets.get(name).cloned().ok_or_else(|| {
1938 AlgorithmConfigError::new(format!(
1939 "route {route_name} references unknown target {name}"
1940 ))
1941 })
1942}
1943
1944fn resolve_runtime_models(
1945 route_name: &str,
1946 names: HashMap<Category, Vec<String>>,
1947 targets: &BTreeMap<String, ModelId>,
1948) -> AlgorithmResult<HashMap<Category, Vec<ModelId>>> {
1949 names
1950 .into_iter()
1951 .map(|(category, names)| {
1952 let models = names
1953 .into_iter()
1954 .map(|name| resolve_target_model_id(route_name, &name, targets))
1955 .collect::<AlgorithmResult<Vec<_>>>()?;
1956 Ok((category, models))
1957 })
1958 .collect()
1959}