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