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