1use std::collections::{BTreeMap, BTreeSet, HashMap};
7use std::error::Error;
8use std::fmt::{Display, Formatter};
9use std::path::PathBuf;
10use std::sync::Arc;
11
12use libsy::{
13 AdvisorGate, AdvisorGateConfig, Algorithm, BreakerConfig, CapabilityJudgeConfig,
14 ClassifierContractConfig, ClassifierResponseFormat, ClassifyTrigger, CompositeJudgeConfig,
15 CompositeRouter, CompositeRouterConfig, CustomClassifierConfig, CustomClassifierPolicy,
16 DEFAULT_UNMATCHED_STEPS, DecisionJudgeConfig, EscalationJudgeConfig, GateTrigger,
17 HandoffNoteConfig, LlmCapabilityConfig, LlmClassifierConfig, LlmFallback, LlmTaskClassifier,
18 Noop, Passthrough, PickerMode, PlanExecute, PlanExecuteConfig, Random, RuntimeModels,
19 StageRouter, StageRouterConfig, SubagentRouter, SubagentRouterConfig, TaskClassifierConfig,
20 ToolSemantics, 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 unmatched_steps: Option<u8>,
307 pub fail_open: Option<bool>,
310 pub classify_trigger: ClassifyTrigger,
312 pub message_hash_fallback: bool,
315 pub recent_turn_window: Option<usize>,
318 pub prompt: Option<String>,
320 pub response_format_type: ClassifierResponseFormat,
323 #[serde(default = "default_classifier_max_output_tokens")]
325 pub max_output_tokens: u64,
326 pub escalation: Option<EscalationJudgeConfig>,
329 pub decision: Option<DecisionJudgeRouteConfig>,
331 pub models: Option<CategoryModelConfig>,
333 pub default_target: Option<String>,
335 pub response_schema: Option<String>,
337 pub policy: Option<ClassifierPolicyConfig>,
339}
340
341#[derive(Clone, Debug, Deserialize)]
344#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
345pub enum SubagentRouteConfig {
346 Passthrough {
348 target: String,
350 },
351 LlmClassifier(Box<LlmClassifierRouteConfig>),
353}
354
355impl SubagentRouteConfig {
356 fn routing_target_names(&self) -> Vec<&str> {
357 match self {
358 Self::Passthrough { target } => vec![target],
359 Self::LlmClassifier(classifier) => classifier
360 .models
361 .as_ref()
362 .map(CategoryModelConfig::routing_names)
363 .unwrap_or_default(),
364 }
365 }
366
367 fn judge_target_names(&self) -> Vec<&str> {
368 match self {
369 Self::Passthrough { .. } => Vec::new(),
370 Self::LlmClassifier(classifier) => classifier
371 .models
372 .as_ref()
373 .map(|models| models.get(&Category::Judge))
374 .unwrap_or_default()
375 .iter()
376 .map(String::as_str)
377 .collect(),
378 }
379 }
380}
381
382#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
383#[serde(rename_all = "snake_case")]
384pub enum PlanExecuteTrigger {
385 #[default]
386 Edit,
387 Judge,
388}
389
390#[derive(Clone, Debug, Deserialize, IntoStaticStr)]
393#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
394#[strum(serialize_all = "snake_case")]
395pub enum AlgorithmSpec {
396 Noop {},
398 Random {
400 targets: Vec<String>,
402 weights: Option<Vec<f64>>,
404 seed: Option<u64>,
406 },
407 Passthrough {
409 target: String,
411 #[serde(default)]
413 subagents: Option<SubagentRouteConfig>,
414 },
415 PlanExecute {
417 #[serde(default)]
418 execution_trigger: PlanExecuteTrigger,
419 judge: Option<StageClassifierConfig>,
420 capable_target: String,
422 efficient_target: String,
424 #[serde(default)]
426 tool_semantics: ToolSemantics,
427 #[serde(default)]
429 planning_prompt: Option<String>,
430 #[serde(default)]
432 handoff_prompt: Option<String>,
433 #[serde(default)]
435 planner_reasoning_as_text: bool,
436 },
437 LlmClassifier {
439 #[serde(flatten)]
441 config: LlmClassifierRouteConfig,
442 },
443 StageRouter {
445 capable_target: String,
446 efficient_target: String,
447 #[serde(default)]
448 capable_first_threshold: f64,
449 #[serde(flatten)]
450 settings: StageSettingsConfig,
451 picker: PickerMode,
453 #[serde(default)]
455 classifier: Option<StageClassifierConfig>,
456 #[serde(default)]
458 subagents: Option<SubagentRouteConfig>,
459 },
460 Auto {
465 capable_target: String,
467 efficient_target: String,
469 },
470 Composite {
472 classifier: StageClassifierConfig,
474 stage: StageTierConfig,
476 #[serde(default)]
478 efficient: Option<Box<StageProfileConfig>>,
479 #[serde(default)]
481 capable: Option<Box<StageProfileConfig>>,
482 #[serde(default)]
484 subagents: Option<SubagentRouteConfig>,
485 },
486 Advisor {
489 executor_target: String,
491 advisor_target: String,
493 #[serde(default)]
495 reviewer_system_prompt: Option<String>,
496 #[serde(default)]
498 redo_feedback_prefix: Option<String>,
499 #[serde(default)]
501 gate_trigger: AdvisorTriggerConfig,
502 #[serde(default)]
504 gate_trigger_pattern: Option<String>,
505 #[serde(default = "default_max_reviews")]
507 max_reviews: u32,
508 #[serde(default)]
511 gate_stall_turns: u32,
512 #[serde(default)]
514 gate_min_tool_results: u32,
515 #[serde(default = "default_advisor_max_tokens")]
517 advisor_max_tokens: u64,
518 #[serde(default)]
520 advisor_temperature: Option<f64>,
521 #[serde(default = "default_transcript_max_chars")]
524 transcript_max_chars: usize,
525 #[serde(default = "default_fail_open")]
527 fail_open: bool,
528 },
529 Vgr {
530 #[serde(flatten)]
531 config: VgrRouteConfig,
532 },
533 PrefillRouter {
535 targets: Vec<String>,
537 checkpoint: PathBuf,
539 device: Option<String>,
542 cache_dir: Option<PathBuf>,
544 max_length: Option<usize>,
546 batch_size: Option<usize>,
548 },
549}
550
551#[derive(Clone, Debug, Deserialize)]
552#[serde(deny_unknown_fields)]
553pub struct VgrRouteConfig {
554 pub efficient_target: String,
555 pub capable_target: String,
556 #[serde(default)]
558 pub judge_target: Option<String>,
559 #[serde(default)]
560 pub capable_judge_target: Option<String>,
561 #[serde(default)]
562 pub mode: VgrModeConfig,
563 #[serde(default)]
564 pub active_approval: Option<String>,
565 #[serde(default = "default_vgr_deadline")]
566 pub deadline_seconds: f64,
567 #[serde(default = "default_true")]
569 pub bounded_efficient: bool,
570 #[serde(default = "default_true")]
571 pub task_typing: bool,
572 #[serde(default = "default_vgr_breaker_threshold")]
573 pub breaker_threshold: u32,
574 #[serde(default = "default_vgr_breaker_cooldown")]
575 pub breaker_cooldown_seconds: f64,
576 #[serde(default)]
578 pub confirmed_recovery_min_clean_tail: Option<u32>,
579 #[serde(default)]
581 pub agentic_handoff: bool,
582 #[serde(default)]
584 pub compact_handoff: bool,
585 #[serde(default)]
587 pub efficient_turn_budget_seconds: Option<f64>,
588 #[serde(default)]
590 pub latch_escalation: bool,
591}
592
593#[derive(Clone, Debug, Default, Deserialize)]
594#[serde(rename_all = "snake_case")]
595pub enum VgrModeConfig {
596 #[default]
597 Off,
598 Evaluate,
599 Shadow,
600 Active,
601}
602
603#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
605#[serde(rename_all = "snake_case")]
606pub enum AdvisorTriggerConfig {
607 #[default]
609 NoToolCall,
610 Pattern,
612}
613
614#[derive(Clone, Debug, Deserialize)]
616#[serde(deny_unknown_fields)]
617pub struct StageClassifierConfig {
618 pub target: String,
620 #[serde(default)]
622 pub mode: Option<ClassifierMode>,
623 #[serde(default)]
624 pub response_schema: Option<String>,
625 #[serde(default)]
626 pub policy: Option<ClassifierPolicyConfig>,
627 #[serde(default)]
628 pub decision: Option<DecisionJudgeRouteConfig>,
629 #[serde(default)]
630 pub fail_open: Option<bool>,
631 pub base_threshold: Option<f64>,
633 #[serde(default)]
636 pub threshold_step: Option<f64>,
637 #[serde(default)]
641 pub classify_trigger: ClassifyTrigger,
642 #[serde(default)]
645 pub message_hash_fallback: bool,
646 #[serde(default)]
649 pub recent_turn_window: Option<usize>,
650 #[serde(default)]
652 pub prompt: Option<String>,
653 #[serde(default)]
656 pub response_format_type: ClassifierResponseFormat,
657 #[serde(default = "default_classifier_max_output_tokens")]
659 pub max_output_tokens: u64,
660}
661
662#[derive(Clone, Debug, Deserialize)]
664#[serde(deny_unknown_fields)]
665pub struct StageTierConfig {
666 pub capable_target: String,
667 pub efficient_target: String,
668 #[serde(default)]
669 pub capable_first_threshold: f64,
670 #[serde(flatten)]
671 pub settings: StageSettingsConfig,
672}
673
674#[derive(Clone, Debug, Default, Deserialize)]
676#[serde(default, deny_unknown_fields)]
677pub struct StageSettingsConfig {
678 #[serde(alias = "efficient_first_threshold")]
679 pub confidence_threshold: Option<f64>,
680 pub recent_turn_window: Option<usize>,
681 pub tool_semantics: Option<ToolSemantics>,
682 pub capable_hold_turns: Option<i64>,
683 pub efficient_hold_turns: Option<i64>,
684 pub handoff_notes: Option<HandoffNoteConfig>,
685 pub capable_system_prompt: Option<String>,
686 pub efficient_system_prompt: Option<String>,
687}
688
689#[derive(Clone, Debug, Default, Deserialize)]
690#[serde(deny_unknown_fields)]
691pub struct StageProfileConfig {
692 pub classifier: Option<StageClassifierConfig>,
693 pub picker: Option<PickerMode>,
694 #[serde(flatten)]
695 pub settings: StageSettingsConfig,
696}
697
698impl StageSettingsConfig {
699 fn stage_config(&self, shared: &Self, mode: PickerMode, threshold: f64) -> StageRouterConfig {
700 let mut config =
701 StageRouterConfig::new(mode, self.confidence_threshold.unwrap_or(threshold));
702 config.recent_window = self.recent_turn_window.or(shared.recent_turn_window);
703 config.tool_semantics = self
704 .tool_semantics
705 .as_ref()
706 .or(shared.tool_semantics.as_ref())
707 .cloned()
708 .unwrap_or_default();
709 config.capable_hold_turns = self
710 .capable_hold_turns
711 .or(shared.capable_hold_turns)
712 .unwrap_or(2);
713 config.efficient_hold_turns = self.efficient_hold_turns.or(shared.efficient_hold_turns);
714 config.handoff_notes = self
715 .handoff_notes
716 .as_ref()
717 .or(shared.handoff_notes.as_ref())
718 .cloned();
719 config.capable_system_prompt = self
720 .capable_system_prompt
721 .as_ref()
722 .or(shared.capable_system_prompt.as_ref())
723 .cloned();
724 config.efficient_system_prompt = self
725 .efficient_system_prompt
726 .as_ref()
727 .or(shared.efficient_system_prompt.as_ref())
728 .cloned();
729 config
730 }
731}
732
733impl StageTierConfig {
734 fn warn_identical_tiers(&self, route_name: &str) {
736 warn_identical_tiers(route_name, &self.capable_target, &self.efficient_target);
737 }
738}
739
740impl StageClassifierConfig {
741 fn task_classifier_config(
742 &self,
743 route_name: &str,
744 capable_target: &str,
745 efficient_target: &str,
746 targets: &BTreeMap<String, ModelId>,
747 ) -> AlgorithmResult<TaskClassifierConfig> {
748 if self
749 .mode
750 .is_some_and(|mode| !matches!(mode, ClassifierMode::Capability))
751 || self.response_schema.is_some()
752 || self.policy.is_some()
753 {
754 return Err(AlgorithmConfigError::new(
755 "custom classifier settings require composite with mode = custom",
756 ));
757 }
758 let config = LlmClassifierRouteConfig {
759 classifier_target: self.target.clone(),
760 strong_target: Some(capable_target.to_string()),
761 weak_target: Some(efficient_target.to_string()),
762 base_threshold: self.base_threshold,
763 threshold_step: self.threshold_step,
764 fail_open: self.fail_open,
765 decision: self.decision.clone(),
766 classify_trigger: self.classify_trigger,
767 message_hash_fallback: self.message_hash_fallback,
768 recent_turn_window: self.recent_turn_window,
769 prompt: self.prompt.clone(),
770 response_format_type: self.response_format_type,
771 max_output_tokens: self.max_output_tokens,
772 ..Default::default()
773 };
774 match config.validated_classifier_mode(route_name)? {
775 LlmClassifierModeConfig::Capability(config) => {
776 config.into_task_config(route_name, targets)
777 }
778 _ => Err(AlgorithmConfigError::new("expected a capability judge")),
779 }
780 }
781
782 fn stage_fallback(
783 &self,
784 route_name: &str,
785 capable: &str,
786 efficient: &str,
787 targets: &BTreeMap<String, ModelId>,
788 ) -> AlgorithmResult<LlmFallback> {
789 Ok(LlmFallback {
790 target: Some(resolve_target_model_id(route_name, &self.target, targets)?),
791 config: self.task_classifier_config(route_name, capable, efficient, targets)?,
792 })
793 }
794
795 fn custom_classifier_config(
796 &self,
797 route_name: &str,
798 ) -> AlgorithmResult<CustomClassifierConfig> {
799 if self.fail_open == Some(false) {
800 return Err(AlgorithmConfigError::new(
801 "composite custom classifiers always fail open; remove fail_open = false",
802 ));
803 }
804 if self.decision.is_some()
805 || self.base_threshold.is_some()
806 || self.threshold_step.is_some()
807 || self.response_format_type != ClassifierResponseFormat::JsonSchema
808 {
809 return Err(AlgorithmConfigError::new(
810 "composite custom classifier cannot use capability thresholds, decision settings, or response_format_type",
811 ));
812 }
813 let schema =
814 required_classifier_field(route_name, "response_schema", &self.response_schema)?;
815 let schema = serde_json::from_str(&schema).map_err(|error| {
816 AlgorithmConfigError::with_source(
817 format!("composite route {route_name}: response_schema is invalid JSON"),
818 error,
819 )
820 })?;
821 let mut config = CustomClassifierConfig::new(
822 required_classifier_field(route_name, "prompt", &self.prompt)?,
823 schema,
824 required_classifier_field(route_name, "policy", &self.policy)?.into_libsy(),
825 );
826 config.classify_trigger = self.classify_trigger;
827 config.message_hash_fallback = self.message_hash_fallback;
828 config.recent_turn_window = self.recent_turn_window;
829 config.max_output_tokens = self.max_output_tokens;
830 Ok(config)
831 }
832}
833
834impl AlgorithmSpec {
835 pub(crate) fn classifier_targets(
836 &self,
837 ) -> impl Iterator<Item = (&str, Option<&DecisionJudgeRouteConfig>)> {
838 let (primary, profiles) = match self {
839 Self::LlmClassifier { config, .. }
840 if !matches!(config.classifier_mode(), ClassifierMode::Custom) =>
841 {
842 (
843 Some((config.classifier_target.as_str(), config.decision.as_ref())),
844 [None, None],
845 )
846 }
847 Self::Composite {
848 classifier,
849 efficient,
850 capable,
851 ..
852 } => (
853 Some((classifier.target.as_str(), classifier.decision.as_ref())),
854 [efficient.as_deref(), capable.as_deref()],
855 ),
856 Self::PlanExecute {
857 judge: classifier, ..
858 }
859 | Self::StageRouter { classifier, .. } => (
860 classifier
861 .as_ref()
862 .map(|c| (c.target.as_str(), c.decision.as_ref())),
863 [None, None],
864 ),
865 _ => (None, [None, None]),
866 };
867 primary.into_iter().chain(
868 profiles
869 .into_iter()
870 .flatten()
871 .filter_map(|profile| profile.classifier.as_ref())
872 .map(|c| (c.target.as_str(), c.decision.as_ref())),
873 )
874 }
875
876 pub fn routing_target_names(&self) -> Vec<&str> {
878 match self {
879 Self::Noop { .. } => Vec::new(),
880 Self::Random { targets, .. } => targets.iter().map(String::as_str).collect(),
881 Self::Passthrough {
882 target, subagents, ..
883 } => {
884 let mut names = vec![target.as_str()];
885 if let Some(subagents) = subagents {
886 names.extend(subagents.routing_target_names());
887 }
888 names
889 }
890 Self::PlanExecute {
891 capable_target,
892 efficient_target,
893 ..
894 } => vec![capable_target.as_str(), efficient_target.as_str()],
895 Self::LlmClassifier { config, .. } => match config.classifier_mode() {
896 ClassifierMode::Capability => config
897 .weak_target
898 .iter()
899 .chain(&config.strong_target)
900 .map(String::as_str)
901 .collect(),
902 ClassifierMode::Escalation => config
903 .strong_target
904 .iter()
905 .chain(&config.weak_target)
906 .map(String::as_str)
907 .collect(),
908 ClassifierMode::Custom => config
909 .models
910 .as_ref()
911 .map(CategoryModelConfig::routing_names)
912 .unwrap_or_default(),
913 },
914 Self::StageRouter {
915 capable_target,
916 efficient_target,
917 subagents,
918 ..
919 } => {
920 let mut names = vec![capable_target.as_str(), efficient_target.as_str()];
921 if let Some(subagents) = subagents {
922 names.extend(subagents.routing_target_names());
923 }
924 names
925 }
926 Self::Auto {
927 capable_target,
928 efficient_target,
929 } => vec![capable_target.as_str(), efficient_target.as_str()],
930 Self::Composite {
931 stage, subagents, ..
932 } => {
933 let mut names = vec![
934 stage.capable_target.as_str(),
935 stage.efficient_target.as_str(),
936 ];
937 if let Some(subagents) = subagents {
938 names.extend(subagents.routing_target_names());
939 }
940 names
941 }
942 Self::Advisor {
945 executor_target, ..
946 } => vec![executor_target],
947 Self::Vgr { config } => {
948 vec![
949 config.efficient_target.as_str(),
950 config.capable_target.as_str(),
951 ]
952 }
953 Self::PrefillRouter { targets, .. } => targets.iter().map(String::as_str).collect(),
954 }
955 }
956
957 pub fn callable_target_names(&self) -> Vec<&str> {
962 let mut names = self.routing_target_names();
963 names.extend(self.judge_target_names());
964 names
965 }
966
967 pub fn judge_target_names(&self) -> Vec<&str> {
970 let mut names = Vec::new();
971 match self {
972 Self::LlmClassifier { config, .. } => {
975 if matches!(config.classifier_mode(), ClassifierMode::Custom) {
976 names.extend(
977 config
978 .models
979 .as_ref()
980 .map(|models| models.get(&Category::Judge))
981 .unwrap_or_default()
982 .iter()
983 .map(String::as_str),
984 );
985 } else {
986 names.push(config.classifier_target.as_str());
987 }
988 }
989 Self::PlanExecute { .. } | Self::StageRouter { .. } | Self::Composite { .. } => {
990 names.extend(self.classifier_targets().map(|(target, _)| target));
991 }
992 Self::Advisor { advisor_target, .. } => names.push(advisor_target),
993 Self::Vgr { config } => {
994 names.extend(config.judge_target.as_deref());
995 names.extend(config.capable_judge_target.as_deref());
996 }
997 _ => {}
998 }
999 if let Self::Passthrough {
1001 subagents: Some(subagents),
1002 ..
1003 }
1004 | Self::StageRouter {
1005 subagents: Some(subagents),
1006 ..
1007 }
1008 | Self::Composite {
1009 subagents: Some(subagents),
1010 ..
1011 } = self
1012 {
1013 names.extend(subagents.judge_target_names());
1014 }
1015 names
1016 }
1017
1018 fn runtime_model_names(&self, route_name: &str) -> AlgorithmResult<RuntimeModelNames> {
1020 let parent = match self {
1021 Self::Noop { .. } => HashMap::new(),
1022 Self::Random { targets, .. } | Self::PrefillRouter { targets, .. } => {
1023 category_models([(Category::Any, targets.clone())])
1024 }
1025 Self::Passthrough { target, .. } => {
1026 category_models([(Category::Any, vec![target.clone()])])
1027 }
1028 Self::LlmClassifier { config } => {
1029 classifier_runtime_model_names(config.validated_classifier_mode(route_name)?)
1030 }
1031 Self::PlanExecute {
1032 capable_target,
1033 efficient_target,
1034 judge: classifier,
1035 ..
1036 }
1037 | Self::StageRouter {
1038 capable_target,
1039 efficient_target,
1040 classifier,
1041 ..
1042 } => {
1043 let mut models = category_models([
1044 (Category::Capable, vec![capable_target.clone()]),
1045 (Category::Efficient, vec![efficient_target.clone()]),
1046 (
1047 Category::Any,
1048 vec![capable_target.clone(), efficient_target.clone()],
1049 ),
1050 ]);
1051 if let Some(classifier) = classifier {
1052 models.insert(Category::Judge, vec![classifier.target.clone()]);
1053 }
1054 models
1055 }
1056 Self::Auto {
1057 capable_target,
1058 efficient_target,
1059 } => category_models([
1060 (Category::Capable, vec![capable_target.clone()]),
1061 (Category::Efficient, vec![efficient_target.clone()]),
1062 (
1063 Category::Any,
1064 vec![capable_target.clone(), efficient_target.clone()],
1065 ),
1066 ]),
1067 Self::Composite { stage, .. } => category_models([
1068 (
1069 Category::Judge,
1070 self.judge_target_names()
1071 .into_iter()
1072 .map(str::to_string)
1073 .collect(),
1074 ),
1075 (Category::Capable, vec![stage.capable_target.clone()]),
1076 (Category::Efficient, vec![stage.efficient_target.clone()]),
1077 (
1078 Category::Any,
1079 vec![stage.capable_target.clone(), stage.efficient_target.clone()],
1080 ),
1081 ]),
1082 Self::Advisor {
1083 executor_target,
1084 advisor_target,
1085 ..
1086 } => category_models([
1087 (Category::Efficient, vec![executor_target.clone()]),
1088 (Category::Any, vec![executor_target.clone()]),
1089 (Category::Judge, vec![advisor_target.clone()]),
1090 ]),
1091 Self::Vgr { config } => {
1092 let judge = config
1093 .judge_target
1094 .as_ref()
1095 .unwrap_or(&config.efficient_target);
1096 let judges = std::iter::once(judge)
1097 .chain(&config.capable_judge_target)
1098 .cloned()
1099 .collect();
1100 category_models([
1101 (Category::Efficient, vec![config.efficient_target.clone()]),
1102 (Category::Capable, vec![config.capable_target.clone()]),
1103 (
1104 Category::Any,
1105 vec![
1106 config.efficient_target.clone(),
1107 config.capable_target.clone(),
1108 ],
1109 ),
1110 (Category::Judge, judges),
1111 ])
1112 }
1113 };
1114
1115 let subagents = match self {
1116 Self::Passthrough { subagents, .. }
1117 | Self::StageRouter { subagents, .. }
1118 | Self::Composite { subagents, .. } => subagents.as_ref(),
1119 _ => None,
1120 };
1121 let subagent = subagents
1125 .map(|subagents| subagent_runtime_model_names(subagents, route_name))
1126 .transpose()?;
1127 Ok(RuntimeModelNames { parent, subagent })
1128 }
1129
1130 #[cfg(not(target_os = "wasi"))]
1132 pub(crate) fn routing_response_and_dependency(&self) -> Option<(&str, &str)> {
1133 match self {
1134 Self::LlmClassifier { config, .. }
1135 if matches!(config.classifier_mode(), ClassifierMode::Escalation) =>
1136 {
1137 Some((
1138 config.weak_target.as_deref()?,
1139 config.classifier_target.as_str(),
1140 ))
1141 }
1142 Self::Advisor {
1143 executor_target,
1144 advisor_target,
1145 ..
1146 } => Some((executor_target, advisor_target)),
1147 Self::Vgr { config } => Some((
1148 &config.efficient_target,
1149 config
1150 .judge_target
1151 .as_deref()
1152 .unwrap_or(&config.efficient_target),
1153 )),
1154 Self::Noop { .. }
1155 | Self::Random { .. }
1156 | Self::Passthrough { .. }
1157 | Self::PlanExecute { .. }
1158 | Self::LlmClassifier { .. }
1159 | Self::StageRouter { .. }
1160 | Self::Auto { .. }
1161 | Self::Composite { .. }
1162 | Self::PrefillRouter { .. } => None,
1163 }
1164 }
1165
1166 pub fn build(
1168 &self,
1169 route_name: &str,
1170 targets: &BTreeMap<String, ModelId>,
1171 ) -> AlgorithmResult<Arc<dyn Algorithm>> {
1172 build_algorithm(route_name, self, targets)
1173 }
1174
1175 pub fn build_with_runtime_models(
1177 &self,
1178 route_name: &str,
1179 targets: &BTreeMap<String, ModelId>,
1180 ) -> AlgorithmResult<(Arc<dyn Algorithm>, RuntimeModels)> {
1181 let algorithm = self.build(route_name, targets)?;
1182 let names = self.runtime_model_names(route_name)?;
1183 let mut models =
1184 RuntimeModels::new(resolve_runtime_models(route_name, names.parent, targets)?);
1185 if let Some(subagent) = names.subagent {
1186 models = models.with_subagent(resolve_runtime_models(route_name, subagent, targets)?);
1187 }
1188 Ok((algorithm, models))
1189 }
1190}
1191
1192struct RuntimeModelNames {
1194 parent: HashMap<Category, Vec<String>>,
1196 subagent: Option<HashMap<Category, Vec<String>>>,
1198}
1199
1200fn category_models(
1201 entries: impl IntoIterator<Item = (Category, Vec<String>)>,
1202) -> HashMap<Category, Vec<String>> {
1203 entries.into_iter().collect()
1204}
1205
1206fn custom_runtime_model_names(config: &CategoryModelConfig) -> HashMap<Category, Vec<String>> {
1207 config.groups().collect()
1208}
1209
1210fn classifier_runtime_model_names(
1211 config: LlmClassifierModeConfig,
1212) -> HashMap<Category, Vec<String>> {
1213 match config {
1214 LlmClassifierModeConfig::Capability(config) => category_models([
1215 (Category::Judge, vec![config.classifier_target]),
1216 (Category::Efficient, vec![config.weak_target.clone()]),
1217 (Category::Capable, vec![config.strong_target.clone()]),
1218 (
1219 Category::Any,
1220 vec![config.weak_target, config.strong_target],
1221 ),
1222 ]),
1223 LlmClassifierModeConfig::Escalation(config) => category_models([
1224 (Category::Judge, vec![config.classifier_target]),
1225 (Category::Efficient, vec![config.weak_target.clone()]),
1226 (Category::Capable, vec![config.strong_target.clone()]),
1227 (
1228 Category::Any,
1229 vec![config.strong_target, config.weak_target],
1230 ),
1231 ]),
1232 LlmClassifierModeConfig::Custom(config) => custom_runtime_model_names(&config.models),
1233 }
1234}
1235
1236fn subagent_runtime_model_names(
1237 config: &SubagentRouteConfig,
1238 route_name: &str,
1239) -> AlgorithmResult<HashMap<Category, Vec<String>>> {
1240 match config {
1241 SubagentRouteConfig::Passthrough { target } => {
1242 Ok(category_models([(Category::Any, vec![target.clone()])]))
1243 }
1244 SubagentRouteConfig::LlmClassifier(config) => {
1245 let LlmClassifierModeConfig::Custom(config) =
1246 config.validated_classifier_mode(route_name)?
1247 else {
1248 return Err(AlgorithmConfigError::new(format!(
1249 "route {route_name}: subagents llm_classifier only supports mode custom"
1250 )));
1251 };
1252 Ok(custom_runtime_model_names(&config.models))
1253 }
1254 }
1255}
1256impl LlmClassifierRouteConfig {
1257 fn classifier_mode(&self) -> ClassifierMode {
1258 let default = if self.escalation.is_some() {
1259 ClassifierMode::Escalation
1260 } else {
1261 ClassifierMode::Capability
1262 };
1263 self.mode.unwrap_or(default)
1264 }
1265
1266 fn validated_classifier_mode(
1267 &self,
1268 route_name: &str,
1269 ) -> AlgorithmResult<LlmClassifierModeConfig> {
1270 let Self {
1271 classifier_target,
1272 mode,
1273 strong_target,
1274 weak_target,
1275 base_threshold,
1276 threshold_step,
1277 unmatched_steps,
1278 fail_open,
1279 classify_trigger,
1280 message_hash_fallback,
1281 recent_turn_window,
1282 prompt,
1283 response_format_type,
1284 max_output_tokens,
1285 escalation,
1286 decision,
1287 models,
1288 default_target,
1289 response_schema,
1290 policy,
1291 } = self;
1292
1293 let selected_mode = match (mode, escalation.is_some()) {
1294 (Some(mode), _) => *mode,
1295 (None, true) => ClassifierMode::Escalation,
1296 (None, false) => ClassifierMode::Capability,
1297 };
1298
1299 if decision.is_some() && !matches!(selected_mode, ClassifierMode::Capability) {
1300 return Err(AlgorithmConfigError::new(format!(
1301 "llm_classifier route {route_name}: decision requires capability mode"
1302 )));
1303 }
1304
1305 if !matches!(selected_mode, ClassifierMode::Capability) && *fail_open == Some(true) {
1306 return Err(AlgorithmConfigError::new(format!(
1307 "llm_classifier route {route_name}: fail_open = true requires capability mode"
1308 )));
1309 }
1310
1311 match selected_mode {
1312 ClassifierMode::Capability => {
1313 if escalation.is_some() {
1314 return Err(classifier_field_error(
1315 route_name,
1316 "escalation",
1317 "capability",
1318 ));
1319 }
1320 reject_custom_fields(
1321 route_name,
1322 "capability",
1323 models,
1324 default_target,
1325 response_schema,
1326 policy,
1327 )?;
1328 let judge = if let Some(decision) = decision {
1329 if base_threshold.is_some()
1330 || threshold_step.is_some()
1331 || unmatched_steps.is_some()
1332 || prompt.is_some()
1333 || *response_format_type != ClassifierResponseFormat::JsonSchema
1334 || *max_output_tokens != default_classifier_max_output_tokens()
1335 {
1336 return Err(AlgorithmConfigError::new(format!(
1337 "llm_classifier route {route_name}: decision cannot use LLM judge settings; use decision.cutoff and decision.instructions"
1338 )));
1339 }
1340 CapabilityJudgeRouteConfig::Decision(decision.clone())
1341 } else {
1342 CapabilityJudgeRouteConfig::Llm(LlmCapabilityConfig {
1343 base_threshold: required_classifier_field(
1344 route_name,
1345 "base_threshold",
1346 base_threshold,
1347 )?,
1348 threshold_step: threshold_step.unwrap_or_default(),
1349 unmatched_steps: unmatched_steps.unwrap_or(DEFAULT_UNMATCHED_STEPS),
1350 contract: classifier_contract(prompt.as_deref())
1351 .with_response_format_type(*response_format_type),
1352 max_output_tokens: *max_output_tokens,
1353 })
1354 };
1355 Ok(LlmClassifierModeConfig::Capability(
1356 CapabilityClassifierRouteConfig {
1357 classifier_target: classifier_target.clone(),
1358 fail_open: fail_open.unwrap_or(true),
1359 strong_target: required_classifier_field(
1360 route_name,
1361 "strong_target",
1362 strong_target,
1363 )?,
1364 weak_target: required_classifier_field(
1365 route_name,
1366 "weak_target",
1367 weak_target,
1368 )?,
1369 judge,
1370 classify_trigger: *classify_trigger,
1371 message_hash_fallback: *message_hash_fallback,
1372 recent_turn_window: *recent_turn_window,
1373 },
1374 ))
1375 }
1376 ClassifierMode::Escalation => {
1377 reject_custom_fields(
1378 route_name,
1379 "escalation",
1380 models,
1381 default_target,
1382 response_schema,
1383 policy,
1384 )?;
1385 if *classify_trigger != ClassifyTrigger::EveryRequest {
1386 return Err(AlgorithmConfigError::new(format!(
1387 "llm_classifier route {route_name} mode escalation cannot use classify_trigger"
1388 )));
1389 }
1390 let has_capability_settings = base_threshold.is_some()
1391 || threshold_step.is_some()
1392 || fail_open.is_some()
1393 || *message_hash_fallback
1394 || recent_turn_window.is_some();
1395 if unmatched_steps.is_some() || (mode.is_some() && has_capability_settings) {
1399 return Err(AlgorithmConfigError::new(format!(
1400 "llm_classifier route {route_name} mode escalation cannot use capability routing settings"
1401 )));
1402 }
1403 if has_capability_settings {
1404 tracing::warn!(
1407 "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."
1408 );
1409 }
1410 Ok(LlmClassifierModeConfig::Escalation(
1411 EscalationClassifierRouteConfig {
1412 classifier_target: classifier_target.clone(),
1413 strong_target: required_classifier_field(
1414 route_name,
1415 "strong_target",
1416 strong_target,
1417 )?,
1418 weak_target: required_classifier_field(
1419 route_name,
1420 "weak_target",
1421 weak_target,
1422 )?,
1423 prompt: prompt.clone(),
1424 response_format_type: *response_format_type,
1425 max_output_tokens: *max_output_tokens,
1426 judge: required_classifier_field(route_name, "escalation", escalation)?,
1427 },
1428 ))
1429 }
1430 ClassifierMode::Custom => {
1431 if !classifier_target.is_empty()
1432 || strong_target.is_some()
1433 || weak_target.is_some()
1434 || base_threshold.is_some()
1435 || threshold_step.is_some()
1436 || unmatched_steps.is_some()
1437 || escalation.is_some()
1438 || *response_format_type != ClassifierResponseFormat::JsonSchema
1439 {
1440 return Err(AlgorithmConfigError::new(format!(
1441 "llm_classifier route {route_name} mode custom cannot use capability or escalation fields and response_format_type must be 'json_schema'"
1442 )));
1443 }
1444 let models = required_classifier_field(route_name, "models", models)?;
1445 let default_target: Category = required_classifier_field(
1446 route_name,
1447 "default_target",
1448 default_target,
1449 )?
1450 .parse()
1451 .map_err(|error| {
1452 AlgorithmConfigError::new(format!(
1453 "llm_classifier route {route_name} has invalid default_target: {error}"
1454 ))
1455 })?;
1456 if default_target == Category::Judge {
1457 return Err(AlgorithmConfigError::new(format!(
1458 "llm_classifier route {route_name} default_target cannot be judge"
1459 )));
1460 }
1461 models.validate(route_name, &default_target)?;
1462 Ok(LlmClassifierModeConfig::Custom(
1463 CustomClassifierRouteConfig {
1464 models,
1465 default_target,
1466 prompt: required_classifier_field(route_name, "prompt", prompt)?,
1467 response_schema: required_classifier_field(
1468 route_name,
1469 "response_schema",
1470 response_schema,
1471 )?,
1472 policy: required_classifier_field(route_name, "policy", policy)?,
1473 classify_trigger: *classify_trigger,
1474 message_hash_fallback: *message_hash_fallback,
1475 recent_turn_window: *recent_turn_window,
1476 max_output_tokens: *max_output_tokens,
1477 },
1478 ))
1479 }
1480 }
1481 }
1482}
1483
1484fn reject_custom_fields(
1485 route_name: &str,
1486 mode: &str,
1487 models: &Option<CategoryModelConfig>,
1488 default_target: &Option<String>,
1489 response_schema: &Option<String>,
1490 policy: &Option<ClassifierPolicyConfig>,
1491) -> AlgorithmResult<()> {
1492 if models.is_some() || default_target.is_some() || response_schema.is_some() || policy.is_some()
1493 {
1494 return Err(AlgorithmConfigError::new(format!(
1495 "llm_classifier route {route_name} mode {mode} cannot use custom classifier fields"
1496 )));
1497 }
1498 Ok(())
1499}
1500
1501fn classifier_field_error(route_name: &str, field: &str, mode: &str) -> AlgorithmConfigError {
1502 AlgorithmConfigError::new(format!(
1503 "llm_classifier route {route_name} mode {mode} cannot use {field}"
1504 ))
1505}
1506
1507fn required_classifier_field<T: Clone>(
1508 route_name: &str,
1509 field: &str,
1510 value: &Option<T>,
1511) -> AlgorithmResult<T> {
1512 value.clone().ok_or_else(|| {
1513 AlgorithmConfigError::new(format!(
1514 "llm_classifier route {route_name} requires {field}"
1515 ))
1516 })
1517}
1518
1519fn build_subagent_router_config(
1520 route_name: &str,
1521 config: &SubagentRouteConfig,
1522 targets: &BTreeMap<String, ModelId>,
1523) -> AlgorithmResult<SubagentRouterConfig> {
1524 match config {
1525 SubagentRouteConfig::Passthrough { target } => {
1526 resolve_target_model_id(route_name, target, targets)?;
1527 Ok(SubagentRouterConfig::fixed_target())
1528 }
1529 SubagentRouteConfig::LlmClassifier(config) => {
1530 let LlmClassifierModeConfig::Custom(config) =
1531 config.validated_classifier_mode(route_name)?
1532 else {
1533 return Err(AlgorithmConfigError::new(format!(
1534 "route {route_name}: subagents llm_classifier only supports mode custom"
1535 )));
1536 };
1537 for name in config.models.all_names() {
1538 resolve_target_model_id(route_name, name, targets)?;
1539 }
1540 if config.models.get(&config.default_target).is_empty() {
1541 return Err(AlgorithmConfigError::new(format!(
1542 "route {route_name}: subagents llm_classifier has no model for default category {}",
1543 config.default_target.as_str()
1544 )));
1545 }
1546 let response_schema =
1547 serde_json::from_str(&config.response_schema).map_err(|error| {
1548 AlgorithmConfigError::with_source(
1549 format!(
1550 "route {route_name}: subagents llm_classifier response_schema is invalid JSON: {error}"
1551 ),
1552 error,
1553 )
1554 })?;
1555 warn_single_target_classifier(route_name, &config.models);
1556 let mut classifier_config = CustomClassifierConfig::new(
1557 config.prompt,
1558 response_schema,
1559 config.policy.into_libsy(),
1560 );
1561 classifier_config.recent_turn_window = config.recent_turn_window;
1562 classifier_config.max_output_tokens = config.max_output_tokens;
1563 let classifier = Arc::new(
1564 LlmTaskClassifier::new(LlmClassifierConfig::Custom {
1565 default_target: config.default_target.clone(),
1566 config: classifier_config,
1567 })
1568 .map_err(|error| {
1569 AlgorithmConfigError::with_source(
1570 format!("route {route_name}: subagents llm_classifier: {error}"),
1571 error,
1572 )
1573 })?,
1574 );
1575 Ok(SubagentRouterConfig {
1576 classifier,
1577 default_target: config.default_target,
1578 classify_trigger: config.classify_trigger,
1579 message_hash_fallback: config.message_hash_fallback,
1580 })
1581 }
1582 }
1583}
1584
1585fn attach_subagent_router(
1586 route_name: &str,
1587 parent: Arc<dyn Algorithm>,
1588 config: Option<&SubagentRouteConfig>,
1589 targets: &BTreeMap<String, ModelId>,
1590) -> AlgorithmResult<Arc<dyn Algorithm>> {
1591 let Some(config) = config else {
1592 return Ok(parent);
1593 };
1594 let config = build_subagent_router_config(route_name, config, targets)?;
1595 let algorithm = SubagentRouter::new(parent, config).map_err(|error| {
1596 AlgorithmConfigError::with_source(
1597 format!("route {route_name}: subagent routing: {error}"),
1598 error,
1599 )
1600 })?;
1601 Ok(Arc::new(algorithm))
1602}
1603
1604fn build_algorithm(
1605 route_name: &str,
1606 config: &AlgorithmSpec,
1607 targets: &BTreeMap<String, ModelId>,
1608) -> AlgorithmResult<Arc<dyn Algorithm>> {
1609 match config {
1610 AlgorithmSpec::Noop { .. } => Ok(Arc::new(Noop {})),
1611 AlgorithmSpec::Random {
1612 targets: names,
1613 weights,
1614 seed,
1615 } => {
1616 if let Some(weights) = weights
1619 && weights.len() != names.len()
1620 {
1621 return Err(AlgorithmConfigError::new(format!(
1622 "random route {route_name}: expected {} weights, got {}",
1623 names.len(),
1624 weights.len()
1625 )));
1626 }
1627 let mut seen = BTreeSet::new();
1628 if let Some(duplicate) = names.iter().find(|name| !seen.insert(*name)) {
1629 return Err(AlgorithmConfigError::new(format!(
1630 "random route {route_name}: targets must be unique, {duplicate} is repeated"
1631 )));
1632 }
1633 let algorithm = Random::new(weights.clone(), *seed).map_err(|error| {
1634 AlgorithmConfigError::with_source(
1635 format!("random route {route_name}: {error}"),
1636 error,
1637 )
1638 })?;
1639 Ok(Arc::new(algorithm))
1640 }
1641 AlgorithmSpec::Passthrough { subagents, .. } => {
1642 let algorithm = Passthrough;
1643 let parent: Arc<dyn Algorithm> = Arc::new(algorithm);
1644 attach_subagent_router(route_name, parent, subagents.as_ref(), targets)
1645 }
1646 AlgorithmSpec::PlanExecute {
1647 execution_trigger,
1648 judge,
1649 capable_target,
1650 efficient_target,
1651 tool_semantics,
1652 planning_prompt,
1653 handoff_prompt,
1654 planner_reasoning_as_text,
1655 ..
1656 } => {
1657 if (*execution_trigger == PlanExecuteTrigger::Judge) != judge.is_some() {
1658 return Err(AlgorithmConfigError::new(
1659 "plan_execute requires a judge exactly when execution_trigger = judge",
1660 ));
1661 }
1662 let mut config = PlanExecuteConfig {
1663 judge: judge
1664 .as_ref()
1665 .map(|judge| {
1666 if judge.classify_trigger != ClassifyTrigger::EveryRequest
1667 || judge.message_hash_fallback
1668 {
1669 return Err(AlgorithmConfigError::new(
1670 "plan_execute judge runs every tool turn. Remove classify_trigger and message_hash_fallback",
1671 ));
1672 }
1673 judge.task_classifier_config(
1674 route_name,
1675 capable_target,
1676 efficient_target,
1677 targets,
1678 )
1679 })
1680 .transpose()?,
1681 ..PlanExecuteConfig::default()
1682 };
1683 config.tool_semantics.clone_from(tool_semantics);
1684 if let Some(prompt) = planning_prompt {
1685 config.planning_prompt = prompt.clone();
1686 }
1687 config.handoff_prompt.clone_from(handoff_prompt);
1688 config.planner_reasoning_as_text = *planner_reasoning_as_text;
1689 let algorithm = PlanExecute::new(config).map_err(|error| {
1690 AlgorithmConfigError::with_source(
1691 format!("plan_execute route {route_name}: {error}"),
1692 error,
1693 )
1694 })?;
1695 Ok(Arc::new(algorithm))
1696 }
1697 AlgorithmSpec::LlmClassifier {
1698 config: classifier_config,
1699 ..
1700 } => {
1701 let mode = classifier_config.validated_classifier_mode(route_name)?;
1702 let algorithm = match mode {
1703 LlmClassifierModeConfig::Capability(config) => {
1704 let classifier_config = config.into_task_config(route_name, targets)?;
1705 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1706 config: classifier_config,
1707 })
1708 }
1709 LlmClassifierModeConfig::Escalation(config) => {
1710 LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
1711 contract: classifier_contract(config.prompt.as_deref())
1712 .with_response_format_type(config.response_format_type),
1713 config: config.judge,
1714 max_output_tokens: config.max_output_tokens,
1715 })
1716 }
1717 LlmClassifierModeConfig::Custom(config) => {
1718 let response_schema = serde_json::from_str(&config.response_schema).map_err(
1719 |error| {
1720 AlgorithmConfigError::with_source(
1721 format!(
1722 "llm_classifier route {route_name}: response_schema is invalid JSON: {error}"
1723 ),
1724 error,
1725 )
1726 },
1727 )?;
1728 warn_single_target_classifier(route_name, &config.models);
1729 let mut classifier_config = CustomClassifierConfig::new(
1730 config.prompt,
1731 response_schema,
1732 config.policy.into_libsy(),
1733 );
1734 classifier_config.classify_trigger = config.classify_trigger;
1735 classifier_config.message_hash_fallback = config.message_hash_fallback;
1736 classifier_config.recent_turn_window = config.recent_turn_window;
1737 classifier_config.max_output_tokens = config.max_output_tokens;
1738 LlmTaskClassifier::new(LlmClassifierConfig::Custom {
1739 default_target: config.default_target,
1740 config: classifier_config,
1741 })
1742 }
1743 }
1744 .map_err(|error| {
1745 AlgorithmConfigError::with_source(
1746 format!("llm_classifier route {route_name}: {error}"),
1747 error,
1748 )
1749 })?;
1750 Ok(Arc::new(algorithm))
1751 }
1752 AlgorithmSpec::StageRouter {
1753 capable_target,
1754 efficient_target,
1755 capable_first_threshold,
1756 settings,
1757 picker,
1758 classifier,
1759 subagents,
1760 ..
1761 } => {
1762 if matches!(picker, PickerMode::CapableFirst) {
1763 tracing::warn!(
1764 "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."
1765 );
1766 }
1767 warn_identical_tiers(route_name, capable_target, efficient_target);
1768 let threshold = settings.confidence_threshold.ok_or_else(|| {
1769 AlgorithmConfigError::new(format!(
1770 "stage_router route {route_name}: confidence_threshold is required"
1771 ))
1772 })?;
1773 let mut config = settings.stage_config(settings, *picker, threshold);
1774 config.capable_first_threshold = *capable_first_threshold;
1775 config.llm_fallback = classifier
1778 .as_ref()
1779 .map(|classifier| {
1780 classifier.stage_fallback(route_name, capable_target, efficient_target, targets)
1781 })
1782 .transpose()?;
1783 let algorithm = StageRouter::new(config).map_err(|error| {
1784 AlgorithmConfigError::with_source(
1785 format!("stage_router route {route_name}: {error}"),
1786 error,
1787 )
1788 })?;
1789 let parent: Arc<dyn Algorithm> = Arc::new(algorithm);
1790 attach_subagent_router(route_name, parent, subagents.as_ref(), targets)
1791 }
1792 AlgorithmSpec::Auto {
1793 capable_target,
1794 efficient_target,
1795 } => {
1796 warn_identical_tiers(route_name, capable_target, efficient_target);
1797 let config = StageRouterConfig::new(PickerMode::EfficientFirst, 0.5);
1798 let algorithm = StageRouter::new(config).map_err(|error| {
1799 AlgorithmConfigError::with_source(
1800 format!("auto route {route_name}: {error}"),
1801 error,
1802 )
1803 })?;
1804 Ok(Arc::new(algorithm))
1805 }
1806 AlgorithmSpec::Composite {
1807 classifier,
1808 stage,
1809 efficient,
1810 capable,
1811 subagents,
1812 } => {
1813 stage.warn_identical_tiers(route_name);
1814 let judge = if matches!(classifier.mode, Some(ClassifierMode::Custom)) {
1815 CompositeJudgeConfig::Custom(classifier.custom_classifier_config(route_name)?)
1816 } else {
1817 CompositeJudgeConfig::Capability(classifier.task_classifier_config(
1818 route_name,
1819 &stage.capable_target,
1820 &stage.efficient_target,
1821 targets,
1822 )?)
1823 };
1824 let shared = &stage.settings;
1825 let algorithm = if efficient.is_some() || capable.is_some() {
1826 let defaults = StageProfileConfig::default();
1827 let profiles = [
1828 (
1829 efficient,
1830 PickerMode::EfficientFirst,
1831 shared.confidence_threshold.unwrap_or(0.5),
1832 ),
1833 (
1834 capable,
1835 PickerMode::CapableFirst,
1836 stage.capable_first_threshold,
1837 ),
1838 ]
1839 .map(|(profile, picker, threshold)| -> AlgorithmResult<_> {
1840 let profile = profile.as_deref().unwrap_or(&defaults);
1841 let mut config = profile.settings.stage_config(
1842 shared,
1843 profile.picker.unwrap_or(picker),
1844 threshold,
1845 );
1846 config.llm_fallback = profile
1847 .classifier
1848 .as_ref()
1849 .map(|classifier| {
1850 classifier.stage_fallback(
1851 route_name,
1852 &stage.capable_target,
1853 &stage.efficient_target,
1854 targets,
1855 )
1856 })
1857 .transpose()?;
1858 Ok(config)
1859 });
1860 let [profile_0, profile_1] = profiles;
1861 CompositeRouter::new_with_profiles(judge, profile_0?, profile_1?)
1862 } else {
1863 let mut config = shared.stage_config(shared, PickerMode::EfficientFirst, 0.5);
1864 config.capable_first_threshold = stage.capable_first_threshold;
1865 CompositeRouter::new(CompositeRouterConfig {
1866 judge,
1867 stage: config,
1868 })
1869 }
1870 .map_err(|error| {
1871 AlgorithmConfigError::with_source(
1872 format!("composite route {route_name}: {error}"),
1873 error,
1874 )
1875 })?;
1876 let parent: Arc<dyn Algorithm> = Arc::new(algorithm);
1877 attach_subagent_router(route_name, parent, subagents.as_ref(), targets)
1878 }
1879 AlgorithmSpec::Advisor {
1880 reviewer_system_prompt,
1881 redo_feedback_prefix,
1882 gate_trigger,
1883 gate_trigger_pattern,
1884 max_reviews,
1885 gate_stall_turns,
1886 gate_min_tool_results,
1887 advisor_max_tokens,
1888 advisor_temperature,
1889 transcript_max_chars,
1890 fail_open,
1891 ..
1892 } => {
1893 if *gate_trigger == AdvisorTriggerConfig::NoToolCall && gate_trigger_pattern.is_some() {
1896 return Err(AlgorithmConfigError::new(format!(
1897 "advisor route {route_name}: gate_trigger_pattern requires \
1898 gate_trigger = \"pattern\""
1899 )));
1900 }
1901 let mut config = AdvisorGateConfig::default();
1902 if let Some(prompt) = reviewer_system_prompt {
1903 config.reviewer_system_prompt = prompt.clone();
1904 }
1905 if let Some(prefix) = redo_feedback_prefix {
1906 config.redo_feedback_prefix = prefix.clone();
1907 }
1908 config.gate_trigger = match gate_trigger {
1909 AdvisorTriggerConfig::NoToolCall => GateTrigger::NoToolCall,
1910 AdvisorTriggerConfig::Pattern => {
1911 GateTrigger::Pattern(gate_trigger_pattern.clone().unwrap_or_default())
1912 }
1913 };
1914 config.max_reviews = *max_reviews;
1915 config.gate_stall_turns = *gate_stall_turns;
1916 config.gate_min_tool_results = *gate_min_tool_results;
1917 config.advisor_max_tokens = *advisor_max_tokens;
1918 config.advisor_temperature = *advisor_temperature;
1919 config.transcript_max_chars = *transcript_max_chars;
1920 config.fail_open = *fail_open;
1921 let algorithm = AdvisorGate::new(config).map_err(|error| {
1922 AlgorithmConfigError::with_source(
1923 format!("advisor route {route_name}: {error}"),
1924 error,
1925 )
1926 })?;
1927 Ok(Arc::new(algorithm))
1928 }
1929 AlgorithmSpec::Vgr { config } => build_vgr(route_name, config, targets),
1930 AlgorithmSpec::PrefillRouter {
1931 targets: names,
1932 checkpoint,
1933 device,
1934 cache_dir,
1935 max_length,
1936 batch_size,
1937 } => {
1938 #[cfg(feature = "prefill-router")]
1939 {
1940 let targets = names
1941 .iter()
1942 .map(|name| resolve_target_model_id(route_name, name, targets))
1943 .collect::<AlgorithmResult<Vec<_>>>()?;
1944 let mut config = prefill_router::PrefillRouterConfig::new(targets, checkpoint);
1945 config.device.clone_from(device);
1946 config.cache_dir.clone_from(cache_dir);
1947 if let Some(max_length) = max_length {
1948 config.max_length = *max_length;
1949 }
1950 if let Some(batch_size) = batch_size {
1951 config.batch_size = *batch_size;
1952 }
1953 let algorithm = config.build().map_err(|error| {
1954 AlgorithmConfigError::with_source(
1955 format!("prefill_router route {route_name}: {error}"),
1956 error,
1957 )
1958 })?;
1959 Ok(Arc::new(algorithm))
1960 }
1961
1962 #[cfg(not(feature = "prefill-router"))]
1963 {
1964 let _ = (
1965 names, checkpoint, device, cache_dir, max_length, batch_size, targets,
1966 );
1967 Err(AlgorithmConfigError::new(format!(
1968 "prefill_router route {route_name} requires the `prefill-router` Cargo feature"
1969 )))
1970 }
1971 }
1972 }
1973}
1974
1975fn build_vgr(
1976 route_name: &str,
1977 config: &VgrRouteConfig,
1978 targets: &BTreeMap<String, ModelId>,
1979) -> AlgorithmResult<Arc<dyn Algorithm>> {
1980 let efficient = resolve_target_model_id(route_name, &config.efficient_target, targets)?;
1981 let capable = resolve_target_model_id(route_name, &config.capable_target, targets)?;
1982 let optional = |name: &Option<String>| {
1983 name.as_deref()
1984 .map(|name| resolve_target_model_id(route_name, name, targets))
1985 .transpose()
1986 };
1987 let mut runtime = VgrConfig::new(efficient, capable);
1988 runtime.targets.judge = optional(&config.judge_target)?;
1989 runtime.targets.capable_judge = optional(&config.capable_judge_target)?;
1990 runtime.mode = match config.mode {
1991 VgrModeConfig::Off => libsy::ServingMode::Off,
1992 VgrModeConfig::Evaluate => libsy::ServingMode::Evaluate,
1993 VgrModeConfig::Shadow => libsy::ServingMode::Shadow,
1994 VgrModeConfig::Active => libsy::ServingMode::Active {
1995 approval: config.active_approval.clone().unwrap_or_default(),
1996 },
1997 };
1998 runtime.deadline = vgr_duration(route_name, "deadline_seconds", config.deadline_seconds)?;
1999 runtime.bounded_efficient = config.bounded_efficient;
2000 runtime.task_typing = config.task_typing;
2001 runtime.confirmed_recovery_min_clean_tail = config.confirmed_recovery_min_clean_tail;
2002 runtime.agentic_handoff = config.agentic_handoff;
2003 runtime.compact_handoff = config.compact_handoff;
2004 runtime.latch_escalation = config.latch_escalation;
2005 runtime.efficient_turn_budget = config
2006 .efficient_turn_budget_seconds
2007 .map(|seconds| vgr_duration(route_name, "efficient_turn_budget_seconds", seconds))
2008 .transpose()?;
2009 runtime.breaker = BreakerConfig {
2010 threshold: config.breaker_threshold,
2011 cooldown: vgr_duration(
2012 route_name,
2013 "breaker_cooldown_seconds",
2014 config.breaker_cooldown_seconds,
2015 )?,
2016 };
2017 Vgr::new(runtime)
2018 .map(|algorithm| Arc::new(algorithm) as Arc<dyn Algorithm>)
2019 .map_err(|error| {
2020 AlgorithmConfigError::with_source(format!("vgr route {route_name}: {error}"), error)
2021 })
2022}
2023
2024fn vgr_duration(route: &str, field: &str, seconds: f64) -> AlgorithmResult<std::time::Duration> {
2025 let message = || format!("vgr route {route}: {field} must be a positive number");
2026 let duration = std::time::Duration::try_from_secs_f64(seconds)
2027 .map_err(|error| AlgorithmConfigError::with_source(message(), error))?;
2028 if duration.is_zero() {
2029 return Err(AlgorithmConfigError::new(message()));
2030 }
2031 Ok(duration)
2032}
2033
2034const fn default_max_reviews() -> u32 {
2035 1
2036}
2037
2038const fn default_advisor_max_tokens() -> u64 {
2039 2048
2040}
2041
2042const fn default_transcript_max_chars() -> usize {
2043 200_000
2044}
2045
2046const fn default_fail_open() -> bool {
2047 true
2048}
2049
2050fn classifier_contract(prompt: Option<&str>) -> ClassifierContractConfig {
2051 prompt.map_or_else(ClassifierContractConfig::default, |prompt| {
2052 ClassifierContractConfig::default().with_prompt(prompt)
2053 })
2054}
2055
2056fn default_classifier_max_output_tokens() -> u64 {
2057 LlmCapabilityConfig::default().max_output_tokens
2058}
2059
2060fn warn_identical_tiers(route_name: &str, capable_target: &str, efficient_target: &str) {
2064 if capable_target == efficient_target {
2065 tracing::warn!(
2066 route = route_name,
2067 target = capable_target,
2068 "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"
2069 );
2070 }
2071}
2072
2073fn warn_single_target_classifier(route_name: &str, models: &CategoryModelConfig) {
2074 if models.routing_names().len() < 2 {
2075 tracing::warn!(
2076 route = route_name,
2077 "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."
2078 );
2079 }
2080}
2081
2082const fn default_vgr_deadline() -> f64 {
2083 30.0
2084}
2085
2086const fn default_true() -> bool {
2087 true
2088}
2089
2090const fn default_vgr_breaker_threshold() -> u32 {
2091 5
2092}
2093
2094const fn default_vgr_breaker_cooldown() -> f64 {
2095 30.0
2096}
2097
2098fn resolve_target_model_id(
2099 route_name: &str,
2100 name: &str,
2101 targets: &BTreeMap<String, ModelId>,
2102) -> AlgorithmResult<ModelId> {
2103 targets.get(name).cloned().ok_or_else(|| {
2104 AlgorithmConfigError::new(format!(
2105 "route {route_name} references unknown target {name}"
2106 ))
2107 })
2108}
2109
2110fn resolve_runtime_models(
2111 route_name: &str,
2112 names: HashMap<Category, Vec<String>>,
2113 targets: &BTreeMap<String, ModelId>,
2114) -> AlgorithmResult<HashMap<Category, Vec<ModelId>>> {
2115 names
2116 .into_iter()
2117 .map(|(category, names)| {
2118 let models = names
2119 .into_iter()
2120 .map(|name| resolve_target_model_id(route_name, &name, targets))
2121 .collect::<AlgorithmResult<Vec<_>>>()?;
2122 Ok((category, models))
2123 })
2124 .collect()
2125}