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")]
545 pub bounded_efficient: bool,
546 #[serde(default = "default_true")]
547 pub task_typing: bool,
548 #[serde(default = "default_vgr_breaker_threshold")]
549 pub breaker_threshold: u32,
550 #[serde(default = "default_vgr_breaker_cooldown")]
551 pub breaker_cooldown_seconds: f64,
552 #[serde(default)]
554 pub confirmed_recovery_min_clean_tail: Option<u32>,
555 #[serde(default)]
557 pub agentic_handoff: bool,
558 #[serde(default)]
560 pub compact_handoff: bool,
561 #[serde(default)]
563 pub efficient_turn_budget_seconds: Option<f64>,
564 #[serde(default)]
566 pub latch_escalation: bool,
567}
568
569#[derive(Clone, Debug, Default, Deserialize)]
570#[serde(rename_all = "snake_case")]
571pub enum VgrModeConfig {
572 #[default]
573 Off,
574 Evaluate,
575 Shadow,
576 Active,
577}
578
579#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
581#[serde(rename_all = "snake_case")]
582pub enum AdvisorTriggerConfig {
583 #[default]
585 NoToolCall,
586 Pattern,
588}
589
590#[derive(Clone, Debug, Deserialize)]
592#[serde(deny_unknown_fields)]
593pub struct StageClassifierConfig {
594 pub target: String,
596 #[serde(default)]
598 pub mode: Option<ClassifierMode>,
599 #[serde(default)]
600 pub response_schema: Option<String>,
601 #[serde(default)]
602 pub policy: Option<ClassifierPolicyConfig>,
603 #[serde(default)]
604 pub decision: Option<DecisionJudgeRouteConfig>,
605 #[serde(default)]
606 pub fail_open: Option<bool>,
607 pub base_threshold: Option<f64>,
609 #[serde(default)]
612 pub threshold_step: Option<f64>,
613 #[serde(default)]
617 pub classify_trigger: ClassifyTrigger,
618 #[serde(default)]
621 pub message_hash_fallback: bool,
622 #[serde(default)]
625 pub recent_turn_window: Option<usize>,
626 #[serde(default)]
628 pub prompt: Option<String>,
629 #[serde(default)]
632 pub response_format_type: ClassifierResponseFormat,
633 #[serde(default = "default_classifier_max_output_tokens")]
635 pub max_output_tokens: u64,
636}
637
638#[derive(Clone, Debug, Deserialize)]
643#[serde(deny_unknown_fields)]
644pub struct StageTierConfig {
645 pub capable_target: String,
647 pub efficient_target: String,
649 #[serde(default, alias = "efficient_first_threshold")]
651 pub confidence_threshold: Option<f64>,
652 #[serde(default)]
654 pub capable_first_threshold: f64,
655 #[serde(default)]
657 pub recent_turn_window: Option<usize>,
658 #[serde(default)]
660 pub tool_semantics: ToolSemantics,
661 #[serde(default)]
663 pub capable_hold_turns: Option<i64>,
664 #[serde(default)]
667 pub efficient_hold_turns: Option<i64>,
668 #[serde(default)]
670 pub handoff_notes: Option<HandoffNoteConfig>,
671}
672
673impl StageTierConfig {
674 fn warn_identical_tiers(&self, route_name: &str) {
676 warn_identical_tiers(route_name, &self.capable_target, &self.efficient_target);
677 }
678}
679
680impl StageClassifierConfig {
681 fn task_classifier_config(
682 &self,
683 route_name: &str,
684 tiers: &StageTierConfig,
685 targets: &BTreeMap<String, ModelId>,
686 ) -> AlgorithmResult<TaskClassifierConfig> {
687 if self
688 .mode
689 .is_some_and(|mode| !matches!(mode, ClassifierMode::Capability))
690 || self.response_schema.is_some()
691 || self.policy.is_some()
692 {
693 return Err(AlgorithmConfigError::new(
694 "custom classifier settings require composite with mode = custom",
695 ));
696 }
697 let config = LlmClassifierRouteConfig {
698 classifier_target: self.target.clone(),
699 strong_target: Some(tiers.capable_target.clone()),
700 weak_target: Some(tiers.efficient_target.clone()),
701 base_threshold: self.base_threshold,
702 threshold_step: self.threshold_step,
703 fail_open: self.fail_open,
704 decision: self.decision.clone(),
705 classify_trigger: self.classify_trigger,
706 message_hash_fallback: self.message_hash_fallback,
707 recent_turn_window: self.recent_turn_window,
708 prompt: self.prompt.clone(),
709 response_format_type: self.response_format_type,
710 max_output_tokens: self.max_output_tokens,
711 ..Default::default()
712 };
713 match config.validated_classifier_mode(route_name)? {
714 LlmClassifierModeConfig::Capability(config) => {
715 config.into_task_config(route_name, targets)
716 }
717 _ => Err(AlgorithmConfigError::new("expected a capability judge")),
718 }
719 }
720
721 fn custom_classifier_config(
722 &self,
723 route_name: &str,
724 ) -> AlgorithmResult<CustomClassifierConfig> {
725 if self.fail_open == Some(false) {
726 return Err(AlgorithmConfigError::new(
727 "composite custom classifiers always fail open; remove fail_open = false",
728 ));
729 }
730 if self.decision.is_some()
731 || self.base_threshold.is_some()
732 || self.threshold_step.is_some()
733 || self.response_format_type != ClassifierResponseFormat::JsonSchema
734 {
735 return Err(AlgorithmConfigError::new(
736 "composite custom classifier cannot use capability thresholds, decision settings, or response_format_type",
737 ));
738 }
739 let schema =
740 required_classifier_field(route_name, "response_schema", &self.response_schema)?;
741 let schema = serde_json::from_str(&schema).map_err(|error| {
742 AlgorithmConfigError::with_source(
743 format!("composite route {route_name}: response_schema is invalid JSON"),
744 error,
745 )
746 })?;
747 let mut config = CustomClassifierConfig::new(
748 required_classifier_field(route_name, "prompt", &self.prompt)?,
749 schema,
750 required_classifier_field(route_name, "policy", &self.policy)?.into_libsy(),
751 );
752 config.classify_trigger = self.classify_trigger;
753 config.message_hash_fallback = self.message_hash_fallback;
754 config.recent_turn_window = self.recent_turn_window;
755 config.max_output_tokens = self.max_output_tokens;
756 Ok(config)
757 }
758}
759
760impl AlgorithmSpec {
761 #[cfg(not(target_os = "wasi"))]
762 pub(crate) fn decision_judge(&self) -> Option<(&str, &DecisionJudgeRouteConfig)> {
763 match self {
764 Self::LlmClassifier { config, .. } => config
765 .decision
766 .as_ref()
767 .map(|decision| (config.classifier_target.as_str(), decision)),
768 Self::Composite { classifier, .. }
769 | Self::StageRouter {
770 classifier: Some(classifier),
771 ..
772 } => classifier
773 .decision
774 .as_ref()
775 .map(|decision| (classifier.target.as_str(), decision)),
776 _ => None,
777 }
778 }
779
780 pub fn routing_target_names(&self) -> Vec<&str> {
782 match self {
783 Self::Noop { .. } => Vec::new(),
784 Self::Random { targets, .. } => targets.iter().map(String::as_str).collect(),
785 Self::Passthrough {
786 target, subagents, ..
787 } => {
788 let mut names = vec![target.as_str()];
789 if let Some(subagents) = subagents {
790 names.extend(subagents.routing_target_names());
791 }
792 names
793 }
794 Self::PlanExecute {
795 capable_target,
796 efficient_target,
797 ..
798 } => vec![capable_target.as_str(), efficient_target.as_str()],
799 Self::LlmClassifier { config, .. } => match config.classifier_mode() {
800 ClassifierMode::Capability => config
801 .weak_target
802 .iter()
803 .chain(&config.strong_target)
804 .map(String::as_str)
805 .collect(),
806 ClassifierMode::Escalation => config
807 .strong_target
808 .iter()
809 .chain(&config.weak_target)
810 .map(String::as_str)
811 .collect(),
812 ClassifierMode::Custom => config
813 .models
814 .as_ref()
815 .map(CategoryModelConfig::routing_names)
816 .unwrap_or_default(),
817 },
818 Self::StageRouter {
819 tiers, subagents, ..
820 } => {
821 let mut names = vec![
822 tiers.capable_target.as_str(),
823 tiers.efficient_target.as_str(),
824 ];
825 if let Some(subagents) = subagents {
826 names.extend(subagents.routing_target_names());
827 }
828 names
829 }
830 Self::Auto {
831 capable_target,
832 efficient_target,
833 } => vec![capable_target.as_str(), efficient_target.as_str()],
834 Self::Composite {
835 stage, subagents, ..
836 } => {
837 let mut names = vec![
838 stage.capable_target.as_str(),
839 stage.efficient_target.as_str(),
840 ];
841 if let Some(subagents) = subagents {
842 names.extend(subagents.routing_target_names());
843 }
844 names
845 }
846 Self::Advisor {
849 executor_target, ..
850 } => vec![executor_target],
851 Self::Vgr { config } => {
852 vec![
853 config.efficient_target.as_str(),
854 config.capable_target.as_str(),
855 ]
856 }
857 Self::PrefillRouter { targets, .. } => targets.iter().map(String::as_str).collect(),
858 }
859 }
860
861 pub fn callable_target_names(&self) -> Vec<&str> {
866 let mut names = self.routing_target_names();
867 names.extend(self.judge_target_names());
868 names
869 }
870
871 pub fn judge_target_names(&self) -> Vec<&str> {
874 let mut names = Vec::new();
875 match self {
876 Self::LlmClassifier { config, .. } => {
879 if matches!(config.classifier_mode(), ClassifierMode::Custom) {
880 names.extend(
881 config
882 .models
883 .as_ref()
884 .map(|models| models.get(&Category::Judge))
885 .unwrap_or_default()
886 .iter()
887 .map(String::as_str),
888 );
889 } else {
890 names.push(config.classifier_target.as_str());
891 }
892 }
893 Self::StageRouter {
894 classifier: Some(classifier),
895 ..
896 } => names.push(&classifier.target),
897 Self::Composite { classifier, .. } => {
898 names.push(&classifier.target);
899 }
900 Self::Advisor { advisor_target, .. } => names.push(advisor_target),
901 Self::Vgr { config } => {
902 names.extend(config.judge_target.as_deref());
903 names.extend(config.capable_judge_target.as_deref());
904 }
905 _ => {}
906 }
907 if let Self::Passthrough {
909 subagents: Some(subagents),
910 ..
911 }
912 | Self::StageRouter {
913 subagents: Some(subagents),
914 ..
915 }
916 | Self::Composite {
917 subagents: Some(subagents),
918 ..
919 } = self
920 {
921 names.extend(subagents.judge_target_names());
922 }
923 names
924 }
925
926 fn runtime_model_names(&self, route_name: &str) -> AlgorithmResult<RuntimeModelNames> {
928 let parent = match self {
929 Self::Noop { .. } => HashMap::new(),
930 Self::Random { targets, .. } | Self::PrefillRouter { targets, .. } => {
931 category_models([(Category::Any, targets.clone())])
932 }
933 Self::Passthrough { target, .. } => {
934 category_models([(Category::Any, vec![target.clone()])])
935 }
936 Self::PlanExecute {
937 capable_target,
938 efficient_target,
939 ..
940 } => category_models([
941 (Category::Capable, vec![capable_target.clone()]),
942 (Category::Efficient, vec![efficient_target.clone()]),
943 (
944 Category::Any,
945 vec![capable_target.clone(), efficient_target.clone()],
946 ),
947 ]),
948 Self::LlmClassifier { config } => {
949 classifier_runtime_model_names(config.validated_classifier_mode(route_name)?)
950 }
951 Self::StageRouter {
952 tiers, classifier, ..
953 } => {
954 let mut models = category_models([
955 (Category::Capable, vec![tiers.capable_target.clone()]),
956 (Category::Efficient, vec![tiers.efficient_target.clone()]),
957 (
958 Category::Any,
959 vec![tiers.capable_target.clone(), tiers.efficient_target.clone()],
960 ),
961 ]);
962 if let Some(classifier) = classifier {
963 models.insert(Category::Judge, vec![classifier.target.clone()]);
964 }
965 models
966 }
967 Self::Auto {
968 capable_target,
969 efficient_target,
970 } => category_models([
971 (Category::Capable, vec![capable_target.clone()]),
972 (Category::Efficient, vec![efficient_target.clone()]),
973 (
974 Category::Any,
975 vec![capable_target.clone(), efficient_target.clone()],
976 ),
977 ]),
978 Self::Composite {
979 classifier, stage, ..
980 } => category_models([
981 (Category::Judge, vec![classifier.target.clone()]),
982 (Category::Capable, vec![stage.capable_target.clone()]),
983 (Category::Efficient, vec![stage.efficient_target.clone()]),
984 (
985 Category::Any,
986 vec![stage.capable_target.clone(), stage.efficient_target.clone()],
987 ),
988 ]),
989 Self::Advisor {
990 executor_target,
991 advisor_target,
992 ..
993 } => category_models([
994 (Category::Efficient, vec![executor_target.clone()]),
995 (Category::Any, vec![executor_target.clone()]),
996 (Category::Judge, vec![advisor_target.clone()]),
997 ]),
998 Self::Vgr { config } => {
999 let judge = config
1000 .judge_target
1001 .as_ref()
1002 .unwrap_or(&config.efficient_target);
1003 let judges = std::iter::once(judge)
1004 .chain(&config.capable_judge_target)
1005 .cloned()
1006 .collect();
1007 category_models([
1008 (Category::Efficient, vec![config.efficient_target.clone()]),
1009 (Category::Capable, vec![config.capable_target.clone()]),
1010 (
1011 Category::Any,
1012 vec![
1013 config.efficient_target.clone(),
1014 config.capable_target.clone(),
1015 ],
1016 ),
1017 (Category::Judge, judges),
1018 ])
1019 }
1020 };
1021
1022 let subagents = match self {
1023 Self::Passthrough { subagents, .. }
1024 | Self::StageRouter { subagents, .. }
1025 | Self::Composite { subagents, .. } => subagents.as_ref(),
1026 _ => None,
1027 };
1028 let subagent = subagents
1032 .map(|subagents| subagent_runtime_model_names(subagents, route_name))
1033 .transpose()?;
1034 Ok(RuntimeModelNames { parent, subagent })
1035 }
1036
1037 #[cfg(not(target_os = "wasi"))]
1039 pub(crate) fn routing_response_and_dependency(&self) -> Option<(&str, &str)> {
1040 match self {
1041 Self::LlmClassifier { config, .. }
1042 if matches!(config.classifier_mode(), ClassifierMode::Escalation) =>
1043 {
1044 Some((
1045 config.weak_target.as_deref()?,
1046 config.classifier_target.as_str(),
1047 ))
1048 }
1049 Self::Advisor {
1050 executor_target,
1051 advisor_target,
1052 ..
1053 } => Some((executor_target, advisor_target)),
1054 Self::Vgr { config } => Some((
1055 &config.efficient_target,
1056 config
1057 .judge_target
1058 .as_deref()
1059 .unwrap_or(&config.efficient_target),
1060 )),
1061 Self::Noop { .. }
1062 | Self::Random { .. }
1063 | Self::Passthrough { .. }
1064 | Self::PlanExecute { .. }
1065 | Self::LlmClassifier { .. }
1066 | Self::StageRouter { .. }
1067 | Self::Auto { .. }
1068 | Self::Composite { .. }
1069 | Self::PrefillRouter { .. } => None,
1070 }
1071 }
1072
1073 pub fn build(
1075 &self,
1076 route_name: &str,
1077 targets: &BTreeMap<String, ModelId>,
1078 ) -> AlgorithmResult<Arc<dyn Algorithm>> {
1079 build_algorithm(route_name, self, targets)
1080 }
1081
1082 pub fn build_with_runtime_models(
1084 &self,
1085 route_name: &str,
1086 targets: &BTreeMap<String, ModelId>,
1087 ) -> AlgorithmResult<(Arc<dyn Algorithm>, RuntimeModels)> {
1088 let algorithm = self.build(route_name, targets)?;
1089 let names = self.runtime_model_names(route_name)?;
1090 let mut models =
1091 RuntimeModels::new(resolve_runtime_models(route_name, names.parent, targets)?);
1092 if let Some(subagent) = names.subagent {
1093 models = models.with_subagent(resolve_runtime_models(route_name, subagent, targets)?);
1094 }
1095 Ok((algorithm, models))
1096 }
1097}
1098
1099struct RuntimeModelNames {
1101 parent: HashMap<Category, Vec<String>>,
1103 subagent: Option<HashMap<Category, Vec<String>>>,
1105}
1106
1107fn category_models(
1108 entries: impl IntoIterator<Item = (Category, Vec<String>)>,
1109) -> HashMap<Category, Vec<String>> {
1110 entries.into_iter().collect()
1111}
1112
1113fn custom_runtime_model_names(config: &CategoryModelConfig) -> HashMap<Category, Vec<String>> {
1114 config.groups().collect()
1115}
1116
1117fn classifier_runtime_model_names(
1118 config: LlmClassifierModeConfig,
1119) -> HashMap<Category, Vec<String>> {
1120 match config {
1121 LlmClassifierModeConfig::Capability(config) => category_models([
1122 (Category::Judge, vec![config.classifier_target]),
1123 (Category::Efficient, vec![config.weak_target.clone()]),
1124 (Category::Capable, vec![config.strong_target.clone()]),
1125 (
1126 Category::Any,
1127 vec![config.weak_target, config.strong_target],
1128 ),
1129 ]),
1130 LlmClassifierModeConfig::Escalation(config) => category_models([
1131 (Category::Judge, vec![config.classifier_target]),
1132 (Category::Efficient, vec![config.weak_target.clone()]),
1133 (Category::Capable, vec![config.strong_target.clone()]),
1134 (
1135 Category::Any,
1136 vec![config.strong_target, config.weak_target],
1137 ),
1138 ]),
1139 LlmClassifierModeConfig::Custom(config) => custom_runtime_model_names(&config.models),
1140 }
1141}
1142
1143fn subagent_runtime_model_names(
1144 config: &SubagentRouteConfig,
1145 route_name: &str,
1146) -> AlgorithmResult<HashMap<Category, Vec<String>>> {
1147 match config {
1148 SubagentRouteConfig::Passthrough { target } => {
1149 Ok(category_models([(Category::Any, vec![target.clone()])]))
1150 }
1151 SubagentRouteConfig::LlmClassifier(config) => {
1152 let LlmClassifierModeConfig::Custom(config) =
1153 config.validated_classifier_mode(route_name)?
1154 else {
1155 return Err(AlgorithmConfigError::new(format!(
1156 "route {route_name}: subagents llm_classifier only supports mode custom"
1157 )));
1158 };
1159 Ok(custom_runtime_model_names(&config.models))
1160 }
1161 }
1162}
1163impl LlmClassifierRouteConfig {
1164 fn classifier_mode(&self) -> ClassifierMode {
1165 let default = if self.escalation.is_some() {
1166 ClassifierMode::Escalation
1167 } else {
1168 ClassifierMode::Capability
1169 };
1170 self.mode.unwrap_or(default)
1171 }
1172
1173 fn validated_classifier_mode(
1174 &self,
1175 route_name: &str,
1176 ) -> AlgorithmResult<LlmClassifierModeConfig> {
1177 let Self {
1178 classifier_target,
1179 mode,
1180 strong_target,
1181 weak_target,
1182 base_threshold,
1183 threshold_step,
1184 fail_open,
1185 classify_trigger,
1186 message_hash_fallback,
1187 recent_turn_window,
1188 prompt,
1189 response_format_type,
1190 max_output_tokens,
1191 escalation,
1192 decision,
1193 models,
1194 default_target,
1195 response_schema,
1196 policy,
1197 } = self;
1198
1199 let selected_mode = match (mode, escalation.is_some()) {
1200 (Some(mode), _) => *mode,
1201 (None, true) => ClassifierMode::Escalation,
1202 (None, false) => ClassifierMode::Capability,
1203 };
1204
1205 if decision.is_some() && !matches!(selected_mode, ClassifierMode::Capability) {
1206 return Err(AlgorithmConfigError::new(format!(
1207 "llm_classifier route {route_name}: decision requires capability mode"
1208 )));
1209 }
1210
1211 if !matches!(selected_mode, ClassifierMode::Capability) && *fail_open == Some(true) {
1212 return Err(AlgorithmConfigError::new(format!(
1213 "llm_classifier route {route_name}: fail_open = true requires capability mode"
1214 )));
1215 }
1216
1217 match selected_mode {
1218 ClassifierMode::Capability => {
1219 if escalation.is_some() {
1220 return Err(classifier_field_error(
1221 route_name,
1222 "escalation",
1223 "capability",
1224 ));
1225 }
1226 reject_custom_fields(
1227 route_name,
1228 "capability",
1229 models,
1230 default_target,
1231 response_schema,
1232 policy,
1233 )?;
1234 let judge = if let Some(decision) = decision {
1235 if base_threshold.is_some()
1236 || threshold_step.is_some()
1237 || prompt.is_some()
1238 || *response_format_type != ClassifierResponseFormat::JsonSchema
1239 || *max_output_tokens != default_classifier_max_output_tokens()
1240 {
1241 return Err(AlgorithmConfigError::new(format!(
1242 "llm_classifier route {route_name}: decision cannot use LLM judge settings; use decision.cutoff and decision.instructions"
1243 )));
1244 }
1245 CapabilityJudgeRouteConfig::Decision(decision.clone())
1246 } else {
1247 CapabilityJudgeRouteConfig::Llm(LlmCapabilityConfig {
1248 base_threshold: required_classifier_field(
1249 route_name,
1250 "base_threshold",
1251 base_threshold,
1252 )?,
1253 threshold_step: threshold_step.unwrap_or_default(),
1254 contract: classifier_contract(prompt.as_deref())
1255 .with_response_format_type(*response_format_type),
1256 max_output_tokens: *max_output_tokens,
1257 })
1258 };
1259 Ok(LlmClassifierModeConfig::Capability(
1260 CapabilityClassifierRouteConfig {
1261 classifier_target: classifier_target.clone(),
1262 fail_open: fail_open.unwrap_or(true),
1263 strong_target: required_classifier_field(
1264 route_name,
1265 "strong_target",
1266 strong_target,
1267 )?,
1268 weak_target: required_classifier_field(
1269 route_name,
1270 "weak_target",
1271 weak_target,
1272 )?,
1273 judge,
1274 classify_trigger: *classify_trigger,
1275 message_hash_fallback: *message_hash_fallback,
1276 recent_turn_window: *recent_turn_window,
1277 },
1278 ))
1279 }
1280 ClassifierMode::Escalation => {
1281 reject_custom_fields(
1282 route_name,
1283 "escalation",
1284 models,
1285 default_target,
1286 response_schema,
1287 policy,
1288 )?;
1289 if *classify_trigger != ClassifyTrigger::EveryRequest {
1290 return Err(AlgorithmConfigError::new(format!(
1291 "llm_classifier route {route_name} mode escalation cannot use classify_trigger"
1292 )));
1293 }
1294 let has_capability_settings = base_threshold.is_some()
1295 || threshold_step.is_some()
1296 || fail_open.is_some()
1297 || *message_hash_fallback
1298 || recent_turn_window.is_some();
1299 if mode.is_some() && has_capability_settings {
1300 return Err(AlgorithmConfigError::new(format!(
1301 "llm_classifier route {route_name} mode escalation cannot use capability routing settings"
1302 )));
1303 }
1304 if has_capability_settings {
1305 tracing::warn!(
1308 "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."
1309 );
1310 }
1311 Ok(LlmClassifierModeConfig::Escalation(
1312 EscalationClassifierRouteConfig {
1313 classifier_target: classifier_target.clone(),
1314 strong_target: required_classifier_field(
1315 route_name,
1316 "strong_target",
1317 strong_target,
1318 )?,
1319 weak_target: required_classifier_field(
1320 route_name,
1321 "weak_target",
1322 weak_target,
1323 )?,
1324 prompt: prompt.clone(),
1325 response_format_type: *response_format_type,
1326 max_output_tokens: *max_output_tokens,
1327 judge: required_classifier_field(route_name, "escalation", escalation)?,
1328 },
1329 ))
1330 }
1331 ClassifierMode::Custom => {
1332 if !classifier_target.is_empty()
1333 || strong_target.is_some()
1334 || weak_target.is_some()
1335 || base_threshold.is_some()
1336 || threshold_step.is_some()
1337 || escalation.is_some()
1338 || *response_format_type != ClassifierResponseFormat::JsonSchema
1339 {
1340 return Err(AlgorithmConfigError::new(format!(
1341 "llm_classifier route {route_name} mode custom cannot use capability or escalation fields and response_format_type must be 'json_schema'"
1342 )));
1343 }
1344 let models = required_classifier_field(route_name, "models", models)?;
1345 let default_target: Category = required_classifier_field(
1346 route_name,
1347 "default_target",
1348 default_target,
1349 )?
1350 .parse()
1351 .map_err(|error| {
1352 AlgorithmConfigError::new(format!(
1353 "llm_classifier route {route_name} has invalid default_target: {error}"
1354 ))
1355 })?;
1356 if default_target == Category::Judge {
1357 return Err(AlgorithmConfigError::new(format!(
1358 "llm_classifier route {route_name} default_target cannot be judge"
1359 )));
1360 }
1361 models.validate(route_name, &default_target)?;
1362 Ok(LlmClassifierModeConfig::Custom(
1363 CustomClassifierRouteConfig {
1364 models,
1365 default_target,
1366 prompt: required_classifier_field(route_name, "prompt", prompt)?,
1367 response_schema: required_classifier_field(
1368 route_name,
1369 "response_schema",
1370 response_schema,
1371 )?,
1372 policy: required_classifier_field(route_name, "policy", policy)?,
1373 classify_trigger: *classify_trigger,
1374 message_hash_fallback: *message_hash_fallback,
1375 recent_turn_window: *recent_turn_window,
1376 max_output_tokens: *max_output_tokens,
1377 },
1378 ))
1379 }
1380 }
1381 }
1382}
1383
1384fn reject_custom_fields(
1385 route_name: &str,
1386 mode: &str,
1387 models: &Option<CategoryModelConfig>,
1388 default_target: &Option<String>,
1389 response_schema: &Option<String>,
1390 policy: &Option<ClassifierPolicyConfig>,
1391) -> AlgorithmResult<()> {
1392 if models.is_some() || default_target.is_some() || response_schema.is_some() || policy.is_some()
1393 {
1394 return Err(AlgorithmConfigError::new(format!(
1395 "llm_classifier route {route_name} mode {mode} cannot use custom classifier fields"
1396 )));
1397 }
1398 Ok(())
1399}
1400
1401fn classifier_field_error(route_name: &str, field: &str, mode: &str) -> AlgorithmConfigError {
1402 AlgorithmConfigError::new(format!(
1403 "llm_classifier route {route_name} mode {mode} cannot use {field}"
1404 ))
1405}
1406
1407fn required_classifier_field<T: Clone>(
1408 route_name: &str,
1409 field: &str,
1410 value: &Option<T>,
1411) -> AlgorithmResult<T> {
1412 value.clone().ok_or_else(|| {
1413 AlgorithmConfigError::new(format!(
1414 "llm_classifier route {route_name} requires {field}"
1415 ))
1416 })
1417}
1418
1419fn build_subagent_router_config(
1420 route_name: &str,
1421 config: &SubagentRouteConfig,
1422 targets: &BTreeMap<String, ModelId>,
1423) -> AlgorithmResult<SubagentRouterConfig> {
1424 match config {
1425 SubagentRouteConfig::Passthrough { target } => {
1426 resolve_target_model_id(route_name, target, targets)?;
1427 Ok(SubagentRouterConfig::fixed_target())
1428 }
1429 SubagentRouteConfig::LlmClassifier(config) => {
1430 let LlmClassifierModeConfig::Custom(config) =
1431 config.validated_classifier_mode(route_name)?
1432 else {
1433 return Err(AlgorithmConfigError::new(format!(
1434 "route {route_name}: subagents llm_classifier only supports mode custom"
1435 )));
1436 };
1437 for name in config.models.all_names() {
1438 resolve_target_model_id(route_name, name, targets)?;
1439 }
1440 if config.models.get(&config.default_target).is_empty() {
1441 return Err(AlgorithmConfigError::new(format!(
1442 "route {route_name}: subagents llm_classifier has no model for default category {}",
1443 config.default_target.as_str()
1444 )));
1445 }
1446 let response_schema =
1447 serde_json::from_str(&config.response_schema).map_err(|error| {
1448 AlgorithmConfigError::with_source(
1449 format!(
1450 "route {route_name}: subagents llm_classifier response_schema is invalid JSON: {error}"
1451 ),
1452 error,
1453 )
1454 })?;
1455 warn_single_target_classifier(route_name, &config.models);
1456 let mut classifier_config = CustomClassifierConfig::new(
1457 config.prompt,
1458 response_schema,
1459 config.policy.into_libsy(),
1460 );
1461 classifier_config.recent_turn_window = config.recent_turn_window;
1462 classifier_config.max_output_tokens = config.max_output_tokens;
1463 let classifier = Arc::new(
1464 LlmTaskClassifier::new(LlmClassifierConfig::Custom {
1465 default_target: config.default_target.clone(),
1466 config: classifier_config,
1467 })
1468 .map_err(|error| {
1469 AlgorithmConfigError::with_source(
1470 format!("route {route_name}: subagents llm_classifier: {error}"),
1471 error,
1472 )
1473 })?,
1474 );
1475 Ok(SubagentRouterConfig {
1476 classifier,
1477 default_target: config.default_target,
1478 classify_trigger: config.classify_trigger,
1479 message_hash_fallback: config.message_hash_fallback,
1480 })
1481 }
1482 }
1483}
1484
1485fn attach_subagent_router(
1486 route_name: &str,
1487 parent: Arc<dyn Algorithm>,
1488 config: Option<&SubagentRouteConfig>,
1489 targets: &BTreeMap<String, ModelId>,
1490) -> AlgorithmResult<Arc<dyn Algorithm>> {
1491 let Some(config) = config else {
1492 return Ok(parent);
1493 };
1494 let config = build_subagent_router_config(route_name, config, targets)?;
1495 let algorithm = SubagentRouter::new(parent, config).map_err(|error| {
1496 AlgorithmConfigError::with_source(
1497 format!("route {route_name}: subagent routing: {error}"),
1498 error,
1499 )
1500 })?;
1501 Ok(Arc::new(algorithm))
1502}
1503
1504fn build_algorithm(
1505 route_name: &str,
1506 config: &AlgorithmSpec,
1507 targets: &BTreeMap<String, ModelId>,
1508) -> AlgorithmResult<Arc<dyn Algorithm>> {
1509 match config {
1510 AlgorithmSpec::Noop { .. } => Ok(Arc::new(Noop {})),
1511 AlgorithmSpec::Random {
1512 targets: names,
1513 weights,
1514 seed,
1515 } => {
1516 if let Some(weights) = weights
1519 && weights.len() != names.len()
1520 {
1521 return Err(AlgorithmConfigError::new(format!(
1522 "random route {route_name}: expected {} weights, got {}",
1523 names.len(),
1524 weights.len()
1525 )));
1526 }
1527 let mut seen = BTreeSet::new();
1528 if let Some(duplicate) = names.iter().find(|name| !seen.insert(*name)) {
1529 return Err(AlgorithmConfigError::new(format!(
1530 "random route {route_name}: targets must be unique, {duplicate} is repeated"
1531 )));
1532 }
1533 let algorithm = Random::new(weights.clone(), *seed).map_err(|error| {
1534 AlgorithmConfigError::with_source(
1535 format!("random route {route_name}: {error}"),
1536 error,
1537 )
1538 })?;
1539 Ok(Arc::new(algorithm))
1540 }
1541 AlgorithmSpec::Passthrough { subagents, .. } => {
1542 let algorithm = Passthrough;
1543 let parent: Arc<dyn Algorithm> = Arc::new(algorithm);
1544 attach_subagent_router(route_name, parent, subagents.as_ref(), targets)
1545 }
1546 AlgorithmSpec::PlanExecute {
1547 tool_semantics,
1548 planning_prompt,
1549 handoff_prompt,
1550 planner_reasoning_as_text,
1551 ..
1552 } => {
1553 let mut config = PlanExecuteConfig::default();
1554 config.tool_semantics.clone_from(tool_semantics);
1555 if let Some(prompt) = planning_prompt {
1556 config.planning_prompt = prompt.clone();
1557 }
1558 config.handoff_prompt.clone_from(handoff_prompt);
1559 config.planner_reasoning_as_text = *planner_reasoning_as_text;
1560 let algorithm = PlanExecute::new(config).map_err(|error| {
1561 AlgorithmConfigError::with_source(
1562 format!("plan_execute route {route_name}: {error}"),
1563 error,
1564 )
1565 })?;
1566 Ok(Arc::new(algorithm))
1567 }
1568 AlgorithmSpec::LlmClassifier {
1569 config: classifier_config,
1570 ..
1571 } => {
1572 let mode = classifier_config.validated_classifier_mode(route_name)?;
1573 let algorithm = match mode {
1574 LlmClassifierModeConfig::Capability(config) => {
1575 let classifier_config = config.into_task_config(route_name, targets)?;
1576 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1577 config: classifier_config,
1578 })
1579 }
1580 LlmClassifierModeConfig::Escalation(config) => {
1581 LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
1582 contract: classifier_contract(config.prompt.as_deref())
1583 .with_response_format_type(config.response_format_type),
1584 config: config.judge,
1585 max_output_tokens: config.max_output_tokens,
1586 })
1587 }
1588 LlmClassifierModeConfig::Custom(config) => {
1589 let response_schema = serde_json::from_str(&config.response_schema).map_err(
1590 |error| {
1591 AlgorithmConfigError::with_source(
1592 format!(
1593 "llm_classifier route {route_name}: response_schema is invalid JSON: {error}"
1594 ),
1595 error,
1596 )
1597 },
1598 )?;
1599 warn_single_target_classifier(route_name, &config.models);
1600 let mut classifier_config = CustomClassifierConfig::new(
1601 config.prompt,
1602 response_schema,
1603 config.policy.into_libsy(),
1604 );
1605 classifier_config.classify_trigger = config.classify_trigger;
1606 classifier_config.message_hash_fallback = config.message_hash_fallback;
1607 classifier_config.recent_turn_window = config.recent_turn_window;
1608 classifier_config.max_output_tokens = config.max_output_tokens;
1609 LlmTaskClassifier::new(LlmClassifierConfig::Custom {
1610 default_target: config.default_target,
1611 config: classifier_config,
1612 })
1613 }
1614 }
1615 .map_err(|error| {
1616 AlgorithmConfigError::with_source(
1617 format!("llm_classifier route {route_name}: {error}"),
1618 error,
1619 )
1620 })?;
1621 Ok(Arc::new(algorithm))
1622 }
1623 AlgorithmSpec::StageRouter {
1624 tiers,
1625 picker,
1626 classifier,
1627 subagents,
1628 ..
1629 } => {
1630 let StageTierConfig {
1631 confidence_threshold,
1632 recent_turn_window,
1633 tool_semantics,
1634 capable_hold_turns,
1635 efficient_hold_turns,
1636 handoff_notes,
1637 ..
1638 } = tiers;
1639 if matches!(picker, PickerMode::CapableFirst) {
1640 tracing::warn!(
1641 "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."
1642 );
1643 }
1644 tiers.warn_identical_tiers(route_name);
1645 let threshold = confidence_threshold.ok_or_else(|| {
1646 AlgorithmConfigError::new(format!(
1647 "stage_router route {route_name}: confidence_threshold is required"
1648 ))
1649 })?;
1650 let mut config = StageRouterConfig::new(*picker, threshold);
1651 config.capable_first_threshold = tiers.capable_first_threshold;
1652 config.recent_window = *recent_turn_window;
1653 if let Some(turns) = capable_hold_turns {
1654 config.capable_hold_turns = *turns;
1655 }
1656 config.efficient_hold_turns = *efficient_hold_turns;
1657 config.tool_semantics = tool_semantics.clone();
1658 config.handoff_notes = handoff_notes.clone();
1659 config.llm_fallback = classifier
1662 .as_ref()
1663 .map(|classifier| {
1664 classifier
1665 .task_classifier_config(route_name, tiers, targets)
1666 .map(|config| LlmFallback { config })
1667 })
1668 .transpose()?;
1669 let algorithm = StageRouter::new(config).map_err(|error| {
1670 AlgorithmConfigError::with_source(
1671 format!("stage_router route {route_name}: {error}"),
1672 error,
1673 )
1674 })?;
1675 let parent: Arc<dyn Algorithm> = Arc::new(algorithm);
1676 attach_subagent_router(route_name, parent, subagents.as_ref(), targets)
1677 }
1678 AlgorithmSpec::Auto {
1679 capable_target,
1680 efficient_target,
1681 } => {
1682 warn_identical_tiers(route_name, capable_target, efficient_target);
1683 let config = StageRouterConfig::new(PickerMode::EfficientFirst, 0.5);
1684 let algorithm = StageRouter::new(config).map_err(|error| {
1685 AlgorithmConfigError::with_source(
1686 format!("auto route {route_name}: {error}"),
1687 error,
1688 )
1689 })?;
1690 Ok(Arc::new(algorithm))
1691 }
1692 AlgorithmSpec::Composite {
1693 classifier,
1694 stage,
1695 subagents,
1696 } => {
1697 stage.warn_identical_tiers(route_name);
1698 let mut stage_config = StageRouterConfig::new(
1699 PickerMode::EfficientFirst,
1700 stage.confidence_threshold.unwrap_or(0.5),
1701 );
1702 stage_config.capable_first_threshold = stage.capable_first_threshold;
1703 stage_config.recent_window = stage.recent_turn_window;
1704 if let Some(turns) = stage.capable_hold_turns {
1705 stage_config.capable_hold_turns = turns;
1706 }
1707 stage_config.efficient_hold_turns = stage.efficient_hold_turns;
1708 stage_config.tool_semantics = stage.tool_semantics.clone();
1709 stage_config.handoff_notes = stage.handoff_notes.clone();
1710 let judge = if matches!(classifier.mode, Some(ClassifierMode::Custom)) {
1711 CompositeJudgeConfig::Custom(classifier.custom_classifier_config(route_name)?)
1712 } else {
1713 CompositeJudgeConfig::Capability(
1714 classifier.task_classifier_config(route_name, stage, targets)?,
1715 )
1716 };
1717 let algorithm = CompositeRouter::new(CompositeRouterConfig {
1718 judge,
1719 stage: stage_config,
1720 })
1721 .map_err(|error| {
1722 AlgorithmConfigError::with_source(
1723 format!("composite route {route_name}: {error}"),
1724 error,
1725 )
1726 })?;
1727 let parent: Arc<dyn Algorithm> = Arc::new(algorithm);
1728 attach_subagent_router(route_name, parent, subagents.as_ref(), targets)
1729 }
1730 AlgorithmSpec::Advisor {
1731 reviewer_system_prompt,
1732 redo_feedback_prefix,
1733 gate_trigger,
1734 gate_trigger_pattern,
1735 max_reviews,
1736 gate_stall_turns,
1737 gate_min_tool_results,
1738 advisor_max_tokens,
1739 advisor_temperature,
1740 transcript_max_chars,
1741 fail_open,
1742 ..
1743 } => {
1744 if *gate_trigger == AdvisorTriggerConfig::NoToolCall && gate_trigger_pattern.is_some() {
1747 return Err(AlgorithmConfigError::new(format!(
1748 "advisor route {route_name}: gate_trigger_pattern requires \
1749 gate_trigger = \"pattern\""
1750 )));
1751 }
1752 let mut config = AdvisorGateConfig::default();
1753 if let Some(prompt) = reviewer_system_prompt {
1754 config.reviewer_system_prompt = prompt.clone();
1755 }
1756 if let Some(prefix) = redo_feedback_prefix {
1757 config.redo_feedback_prefix = prefix.clone();
1758 }
1759 config.gate_trigger = match gate_trigger {
1760 AdvisorTriggerConfig::NoToolCall => GateTrigger::NoToolCall,
1761 AdvisorTriggerConfig::Pattern => {
1762 GateTrigger::Pattern(gate_trigger_pattern.clone().unwrap_or_default())
1763 }
1764 };
1765 config.max_reviews = *max_reviews;
1766 config.gate_stall_turns = *gate_stall_turns;
1767 config.gate_min_tool_results = *gate_min_tool_results;
1768 config.advisor_max_tokens = *advisor_max_tokens;
1769 config.advisor_temperature = *advisor_temperature;
1770 config.transcript_max_chars = *transcript_max_chars;
1771 config.fail_open = *fail_open;
1772 let algorithm = AdvisorGate::new(config).map_err(|error| {
1773 AlgorithmConfigError::with_source(
1774 format!("advisor route {route_name}: {error}"),
1775 error,
1776 )
1777 })?;
1778 Ok(Arc::new(algorithm))
1779 }
1780 AlgorithmSpec::Vgr { config } => build_vgr(route_name, config, targets),
1781 AlgorithmSpec::PrefillRouter {
1782 targets: names,
1783 checkpoint,
1784 device,
1785 cache_dir,
1786 max_length,
1787 batch_size,
1788 } => {
1789 #[cfg(feature = "prefill-router")]
1790 {
1791 let targets = names
1792 .iter()
1793 .map(|name| resolve_target_model_id(route_name, name, targets))
1794 .collect::<AlgorithmResult<Vec<_>>>()?;
1795 let mut config = prefill_router::PrefillRouterConfig::new(targets, checkpoint);
1796 config.device.clone_from(device);
1797 config.cache_dir.clone_from(cache_dir);
1798 if let Some(max_length) = max_length {
1799 config.max_length = *max_length;
1800 }
1801 if let Some(batch_size) = batch_size {
1802 config.batch_size = *batch_size;
1803 }
1804 let algorithm = config.build().map_err(|error| {
1805 AlgorithmConfigError::with_source(
1806 format!("prefill_router route {route_name}: {error}"),
1807 error,
1808 )
1809 })?;
1810 Ok(Arc::new(algorithm))
1811 }
1812
1813 #[cfg(not(feature = "prefill-router"))]
1814 {
1815 let _ = (
1816 names, checkpoint, device, cache_dir, max_length, batch_size, targets,
1817 );
1818 Err(AlgorithmConfigError::new(format!(
1819 "prefill_router route {route_name} requires the `prefill-router` Cargo feature"
1820 )))
1821 }
1822 }
1823 }
1824}
1825
1826fn build_vgr(
1827 route_name: &str,
1828 config: &VgrRouteConfig,
1829 targets: &BTreeMap<String, ModelId>,
1830) -> AlgorithmResult<Arc<dyn Algorithm>> {
1831 let efficient = resolve_target_model_id(route_name, &config.efficient_target, targets)?;
1832 let capable = resolve_target_model_id(route_name, &config.capable_target, targets)?;
1833 let optional = |name: &Option<String>| {
1834 name.as_deref()
1835 .map(|name| resolve_target_model_id(route_name, name, targets))
1836 .transpose()
1837 };
1838 let mut runtime = VgrConfig::new(efficient, capable);
1839 runtime.targets.judge = optional(&config.judge_target)?;
1840 runtime.targets.capable_judge = optional(&config.capable_judge_target)?;
1841 runtime.mode = match config.mode {
1842 VgrModeConfig::Off => libsy::ServingMode::Off,
1843 VgrModeConfig::Evaluate => libsy::ServingMode::Evaluate,
1844 VgrModeConfig::Shadow => libsy::ServingMode::Shadow,
1845 VgrModeConfig::Active => libsy::ServingMode::Active {
1846 approval: config.active_approval.clone().unwrap_or_default(),
1847 },
1848 };
1849 runtime.deadline = vgr_duration(route_name, "deadline_seconds", config.deadline_seconds)?;
1850 runtime.bounded_efficient = config.bounded_efficient;
1851 runtime.task_typing = config.task_typing;
1852 runtime.confirmed_recovery_min_clean_tail = config.confirmed_recovery_min_clean_tail;
1853 runtime.agentic_handoff = config.agentic_handoff;
1854 runtime.compact_handoff = config.compact_handoff;
1855 runtime.latch_escalation = config.latch_escalation;
1856 runtime.efficient_turn_budget = config
1857 .efficient_turn_budget_seconds
1858 .map(|seconds| vgr_duration(route_name, "efficient_turn_budget_seconds", seconds))
1859 .transpose()?;
1860 runtime.breaker = BreakerConfig {
1861 threshold: config.breaker_threshold,
1862 cooldown: vgr_duration(
1863 route_name,
1864 "breaker_cooldown_seconds",
1865 config.breaker_cooldown_seconds,
1866 )?,
1867 };
1868 Vgr::new(runtime)
1869 .map(|algorithm| Arc::new(algorithm) as Arc<dyn Algorithm>)
1870 .map_err(|error| {
1871 AlgorithmConfigError::with_source(format!("vgr route {route_name}: {error}"), error)
1872 })
1873}
1874
1875fn vgr_duration(route: &str, field: &str, seconds: f64) -> AlgorithmResult<std::time::Duration> {
1876 let message = || format!("vgr route {route}: {field} must be a positive number");
1877 let duration = std::time::Duration::try_from_secs_f64(seconds)
1878 .map_err(|error| AlgorithmConfigError::with_source(message(), error))?;
1879 if duration.is_zero() {
1880 return Err(AlgorithmConfigError::new(message()));
1881 }
1882 Ok(duration)
1883}
1884
1885const fn default_max_reviews() -> u32 {
1886 1
1887}
1888
1889const fn default_advisor_max_tokens() -> u64 {
1890 2048
1891}
1892
1893const fn default_transcript_max_chars() -> usize {
1894 200_000
1895}
1896
1897const fn default_fail_open() -> bool {
1898 true
1899}
1900
1901fn classifier_contract(prompt: Option<&str>) -> ClassifierContractConfig {
1902 prompt.map_or_else(ClassifierContractConfig::default, |prompt| {
1903 ClassifierContractConfig::default().with_prompt(prompt)
1904 })
1905}
1906
1907fn default_classifier_max_output_tokens() -> u64 {
1908 LlmCapabilityConfig::default().max_output_tokens
1909}
1910
1911fn warn_identical_tiers(route_name: &str, capable_target: &str, efficient_target: &str) {
1915 if capable_target == efficient_target {
1916 tracing::warn!(
1917 route = route_name,
1918 target = capable_target,
1919 "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"
1920 );
1921 }
1922}
1923
1924fn warn_single_target_classifier(route_name: &str, models: &CategoryModelConfig) {
1925 if models.routing_names().len() < 2 {
1926 tracing::warn!(
1927 route = route_name,
1928 "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."
1929 );
1930 }
1931}
1932
1933const fn default_vgr_deadline() -> f64 {
1934 30.0
1935}
1936
1937const fn default_true() -> bool {
1938 true
1939}
1940
1941const fn default_vgr_breaker_threshold() -> u32 {
1942 5
1943}
1944
1945const fn default_vgr_breaker_cooldown() -> f64 {
1946 30.0
1947}
1948
1949fn resolve_target_model_id(
1950 route_name: &str,
1951 name: &str,
1952 targets: &BTreeMap<String, ModelId>,
1953) -> AlgorithmResult<ModelId> {
1954 targets.get(name).cloned().ok_or_else(|| {
1955 AlgorithmConfigError::new(format!(
1956 "route {route_name} references unknown target {name}"
1957 ))
1958 })
1959}
1960
1961fn resolve_runtime_models(
1962 route_name: &str,
1963 names: HashMap<Category, Vec<String>>,
1964 targets: &BTreeMap<String, ModelId>,
1965) -> AlgorithmResult<HashMap<Category, Vec<ModelId>>> {
1966 names
1967 .into_iter()
1968 .map(|(category, names)| {
1969 let models = names
1970 .into_iter()
1971 .map(|name| resolve_target_model_id(route_name, &name, targets))
1972 .collect::<AlgorithmResult<Vec<_>>>()?;
1973 Ok((category, models))
1974 })
1975 .collect()
1976}