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