Skip to main content

switchyard_runner/
algorithm.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Schema-neutral algorithm configuration and construction.
5
6use 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/// Error returned when an algorithm description cannot be constructed.
28#[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/// How a custom classifier turns the judge's JSON verdict into a target.
65#[derive(Clone, Debug, Deserialize)]
66#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
67pub enum ClassifierPolicyConfig {
68    /// Reads the target name straight out of the judge's verdict.
69    TargetSelector {
70        /// JSON Pointer to the name, such as `/decision/target`.
71        selector: String,
72    },
73}
74
75/// Which of the three `llm_classifier` behaviors a route uses.
76#[derive(Clone, Copy, Debug, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum ClassifierMode {
79    /// Judges the request first, then serves the strong or weak target.
80    Capability,
81    /// Serves the weak target first and judges the finished turn, moving to the
82    /// strong target once the session latches.
83    Escalation,
84    /// Judges against your own JSON schema and routes to any configured target.
85    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/// Runtime model groups for a custom classifier, keyed by group name.
189///
190/// `any` and `judge` are required; `capable` and `efficient` carry tier meaning
191/// when present. Any other key is a deployment-defined group the policy may
192/// select by name, which is what lets one route choose between more than two
193/// models. Ordered so error messages and derived target lists do not depend on
194/// hash iteration.
195#[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        // Every group name parses, so a typo would otherwise build fine and then
216        // abstain on every request. `any` is the fallback pool the router checks
217        // the selected target against, so a group outside it can never be served.
218        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    /// Every configured target name, judge included.
237    fn all_names(&self) -> Vec<&str> {
238        Self::deduped(self.0.values().flatten())
239    }
240
241    /// The completion targets: every group except the judge's own candidates.
242    ///
243    /// `any` leads because it is the deployment's stated fallback order, and
244    /// callers read this order to pick a route's representative target.
245    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/// Relative-advantage judgment using a decision model instead of an LLM prompt.
272#[derive(Clone, Debug, Deserialize)]
273#[serde(deny_unknown_fields)]
274pub struct DecisionJudgeRouteConfig {
275    /// Route capable only when its advantage score is strictly above this cutoff.
276    pub cutoff: f64,
277    /// Replaces the default relative-advantage instructions.
278    pub instructions: Option<Value>,
279    /// Anonymous evidence labels mapped to configured LLM target names.
280    pub candidates: BTreeMap<String, String>,
281    /// Candidate descriptions and reference outcomes, passed unchanged to the judge.
282    pub evidence: Value,
283}
284
285/// Settings for an `llm_classifier` route. Which fields are required depends on
286/// the [`ClassifierMode`]; using a field from the wrong mode is an error.
287#[derive(Clone, Debug, Default, Deserialize)]
288#[serde(default, deny_unknown_fields)]
289pub struct LlmClassifierRouteConfig {
290    /// Target the judge is called through. Never a routing destination itself.
291    pub classifier_target: String,
292    /// Mode to run. Defaults to escalation when `escalation` is set, otherwise capability.
293    pub mode: Option<ClassifierMode>,
294    /// Capability and escalation modes: the capable tier.
295    pub strong_target: Option<String>,
296    /// Capability and escalation modes: the efficient tier.
297    pub weak_target: Option<String>,
298    /// Capability mode: lowest solve probability that still routes to the weak
299    /// target, from 0 to 1.
300    pub base_threshold: Option<f64>,
301    /// Capability mode: how much to raise the threshold when the judge is
302    /// uncertain. Added once for an uncertain verdict and twice for unsupported.
303    pub threshold_step: Option<f64>,
304    /// Capability mode: threshold steps for an unmatched verdict (no capability rule
305    /// applies). 1 by default, the same as uncertain; 2 treats it like unsupported.
306    pub unmatched_steps: Option<u8>,
307    /// Capability mode: routes to the capable tier on judge client failures and deadlines.
308    /// Defaults to true when capability mode is selected.
309    pub fail_open: Option<bool>,
310    /// How often the judge runs: every request, once per user turn, or once per session.
311    pub classify_trigger: ClassifyTrigger,
312    /// Reuses the session's target by hashing the first user message when no
313    /// session ID is available. Needs `classify_trigger = "new_session"`.
314    pub message_hash_fallback: bool,
315    /// How many trailing turns the judge sees. Unset shows it the opening task
316    /// and the latest user follow-up only.
317    pub recent_turn_window: Option<usize>,
318    /// Replaces the packaged judge prompt. Required in custom mode.
319    pub prompt: Option<String>,
320    /// How the judge is asked for structured output. Use `json_object` when the
321    /// provider cannot do JSON Schema.
322    pub response_format_type: ClassifierResponseFormat,
323    /// Most completion tokens the judge verdict may use.
324    #[serde(default = "default_classifier_max_output_tokens")]
325    pub max_output_tokens: u64,
326    /// Escalation mode: how many escalate verdicts latch the session, and how
327    /// much of the transcript the judge sees.
328    pub escalation: Option<EscalationJudgeConfig>,
329    /// Capability mode: use a decision target as the judge, with relative-advantage scoring.
330    pub decision: Option<DecisionJudgeRouteConfig>,
331    /// Custom mode: runtime model groups.
332    pub models: Option<CategoryModelConfig>,
333    /// Custom mode: category used when the judge fails or its verdict cannot be routed.
334    pub default_target: Option<String>,
335    /// Custom mode: JSON Schema the verdict must match, written as a string.
336    pub response_schema: Option<String>,
337    /// Custom mode: how to read the chosen target out of the verdict.
338    pub policy: Option<ClassifierPolicyConfig>,
339}
340
341/// Routing policy applied only to delegated sub-agent work, nested inside a
342/// `passthrough` or `stage_router` route.
343#[derive(Clone, Debug, Deserialize)]
344#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
345pub enum SubagentRouteConfig {
346    /// Sends all sub-agent work to one target.
347    Passthrough {
348        /// Target that serves sub-agent requests.
349        target: String,
350    },
351    /// Judges each sub-agent request. Only [`ClassifierMode::Custom`] is supported here.
352    LlmClassifier(Box<LlmClassifierRouteConfig>),
353}
354
355impl SubagentRouteConfig {
356    fn routing_target_names(&self) -> Vec<&str> {
357        match self {
358            Self::Passthrough { target } => vec![target],
359            Self::LlmClassifier(classifier) => classifier
360                .models
361                .as_ref()
362                .map(CategoryModelConfig::routing_names)
363                .unwrap_or_default(),
364        }
365    }
366
367    fn judge_target_names(&self) -> Vec<&str> {
368        match self {
369            Self::Passthrough { .. } => Vec::new(),
370            Self::LlmClassifier(classifier) => classifier
371                .models
372                .as_ref()
373                .map(|models| models.get(&Category::Judge))
374                .unwrap_or_default()
375                .iter()
376                .map(String::as_str)
377                .collect(),
378        }
379    }
380}
381
382#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
383#[serde(rename_all = "snake_case")]
384pub enum PlanExecuteTrigger {
385    #[default]
386    Edit,
387    Judge,
388}
389
390/// A routing algorithm described by configured target names. Converting it to `&str` gives
391/// the route's `type`, such as `stage_router`.
392#[derive(Clone, Debug, Deserialize, IntoStaticStr)]
393#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
394#[strum(serialize_all = "snake_case")]
395pub enum AlgorithmSpec {
396    /// Replies `OK` without calling any model. Useful for smoke tests.
397    Noop {},
398    /// Splits traffic across several targets.
399    Random {
400        /// Target names to choose from.
401        targets: Vec<String>,
402        /// Relative weights in `targets` order. Equal weights when unset.
403        weights: Option<Vec<f64>>,
404        /// Makes the sequence of choices repeatable.
405        seed: Option<u64>,
406    },
407    /// Sends every request to one target.
408    Passthrough {
409        /// Target that serves the request.
410        target: String,
411        /// Separate policy for delegated sub-agent work.
412        #[serde(default)]
413        subagents: Option<SubagentRouteConfig>,
414    },
415    /// Plans on the capable target, then hands off after an edit or judge approval.
416    PlanExecute {
417        #[serde(default)]
418        execution_trigger: PlanExecuteTrigger,
419        judge: Option<StageClassifierConfig>,
420        /// Target used before handoff.
421        capable_target: String,
422        /// Target used after handoff.
423        efficient_target: String,
424        /// Additional tool names whose mutations trigger handoff.
425        #[serde(default)]
426        tool_semantics: ToolSemantics,
427        /// Replaces the built-in planning instruction.
428        #[serde(default)]
429        planning_prompt: Option<String>,
430        /// Appends an instruction to the handoff request.
431        #[serde(default)]
432        handoff_prompt: Option<String>,
433        /// Replays visible planner reasoning summaries as assistant text at handoff.
434        #[serde(default)]
435        planner_reasoning_as_text: bool,
436    },
437    /// Asks a judge model which target should serve the request.
438    LlmClassifier {
439        /// Judge and tier settings, written directly in the route table.
440        #[serde(flatten)]
441        config: LlmClassifierRouteConfig,
442    },
443    /// Picks a tier per turn by scoring signals from recent tool results.
444    StageRouter {
445        capable_target: String,
446        efficient_target: String,
447        #[serde(default)]
448        capable_first_threshold: f64,
449        #[serde(flatten)]
450        settings: StageSettingsConfig,
451        /// Tier to use when the signals are not confident.
452        picker: PickerMode,
453        /// Judge consulted for turns the tool signals cannot decide.
454        #[serde(default)]
455        classifier: Option<StageClassifierConfig>,
456        /// Separate policy for delegated sub-agent work.
457        #[serde(default)]
458        subagents: Option<SubagentRouteConfig>,
459    },
460    /// Picks a routing strategy automatically, preset with recommended knobs.
461    /// Currently a `stage_router` with `picker = "efficient_first"` and
462    /// `confidence_threshold = 0.5`; change `build_algorithm`'s `Auto` arm to
463    /// repoint it at a different algorithm or preset.
464    Auto {
465        /// The capable tier.
466        capable_target: String,
467        /// The efficient tier.
468        efficient_target: String,
469    },
470    /// A judge picks the tier at each user turn; a stage router runs the turns within it.
471    Composite {
472        /// Judge that picks the tier. Called through its own target.
473        classifier: StageClassifierConfig,
474        /// The stage router the judge hands off to.
475        stage: StageTierConfig,
476        /// Settings for the profile selected by an efficient verdict.
477        #[serde(default)]
478        efficient: Option<Box<StageProfileConfig>>,
479        /// Settings for the profile selected by a capable verdict.
480        #[serde(default)]
481        capable: Option<Box<StageProfileConfig>>,
482        /// Separate policy for delegated sub-agent work.
483        #[serde(default)]
484        subagents: Option<SubagentRouteConfig>,
485    },
486    /// Serves every turn from one target, and has a second model review some of
487    /// those turns before the caller sees them.
488    Advisor {
489        /// Serves every client-visible turn.
490        executor_target: String,
491        /// Reviews gated turns. Never a routing destination.
492        advisor_target: String,
493        /// Replaces the built-in APPROVE/REDO reviewer prompt.
494        #[serde(default)]
495        reviewer_system_prompt: Option<String>,
496        /// Replaces the built-in text put in front of a REDO plan.
497        #[serde(default)]
498        redo_feedback_prefix: Option<String>,
499        /// What fires a review.
500        #[serde(default)]
501        gate_trigger: AdvisorTriggerConfig,
502        /// Regular expression for the `pattern` trigger. Required by it, and unused otherwise.
503        #[serde(default)]
504        gate_trigger_pattern: Option<String>,
505        /// How many reviews one session may spend.
506        #[serde(default = "default_max_reviews")]
507        max_reviews: u32,
508        /// Reviews a turn after this many assistant turns, as a mid-task
509        /// checkpoint. Zero turns the checkpoint off.
510        #[serde(default)]
511        gate_stall_turns: u32,
512        /// Tool results a turn needs before it can be reviewed. Skips early chatty turns.
513        #[serde(default)]
514        gate_min_tool_results: u32,
515        /// Most output tokens one review may use.
516        #[serde(default = "default_advisor_max_tokens")]
517        advisor_max_tokens: u64,
518        /// Sampling temperature for reviews. Left off the request when unset.
519        #[serde(default)]
520        advisor_temperature: Option<f64>,
521        /// Size cap on the transcript sent to the advisor. Longer transcripts
522        /// are trimmed from the middle.
523        #[serde(default = "default_transcript_max_chars")]
524        transcript_max_chars: usize,
525        /// Lets the turn through when the advisor fails, instead of erroring.
526        #[serde(default = "default_fail_open")]
527        fail_open: bool,
528    },
529    Vgr {
530        #[serde(flatten)]
531        config: VgrRouteConfig,
532    },
533    /// Routes using a checkpoint-backed prefill classifier.
534    PrefillRouter {
535        /// Target names in checkpoint output order.
536        targets: Vec<String>,
537        /// Tensor-only router checkpoint path.
538        checkpoint: PathBuf,
539        /// PyTorch device used for encoder inference, such as `cpu`, `cuda`, or
540        /// `cuda:0`. Auto-detected when omitted.
541        device: Option<String>,
542        /// Directory where Hugging Face caches the downloaded encoder and tokenizer.
543        cache_dir: Option<PathBuf>,
544        /// Maximum tokenized encoder input length; longer prompts are truncated.
545        max_length: Option<usize>,
546        /// Maximum prompts per encoder forward pass.
547        batch_size: Option<usize>,
548    },
549}
550
551#[derive(Clone, Debug, Deserialize)]
552#[serde(deny_unknown_fields)]
553pub struct VgrRouteConfig {
554    pub efficient_target: String,
555    pub capable_target: String,
556    /// Defaults to `efficient_target`.
557    #[serde(default)]
558    pub judge_target: Option<String>,
559    #[serde(default)]
560    pub capable_judge_target: Option<String>,
561    #[serde(default)]
562    pub mode: VgrModeConfig,
563    #[serde(default)]
564    pub active_approval: Option<String>,
565    #[serde(default = "default_vgr_deadline")]
566    pub deadline_seconds: f64,
567    /// Enforces VGR decision and efficient turn budgets. Defaults to true.
568    #[serde(default = "default_true")]
569    pub bounded_efficient: bool,
570    #[serde(default = "default_true")]
571    pub task_typing: bool,
572    #[serde(default = "default_vgr_breaker_threshold")]
573    pub breaker_threshold: u32,
574    #[serde(default = "default_vgr_breaker_cooldown")]
575    pub breaker_cooldown_seconds: f64,
576    /// Clean trailing tool results that let a long run recover on the capable judge.
577    #[serde(default)]
578    pub confirmed_recovery_min_clean_tail: Option<u32>,
579    /// Tells the capable tier it inherits unverified tool-using work.
580    #[serde(default)]
581    pub agentic_handoff: bool,
582    /// Condenses history from the efficient tier at handoff. Requires `agentic_handoff`.
583    #[serde(default)]
584    pub compact_handoff: bool,
585    /// Wall-clock budget for the efficient tier within one user turn.
586    #[serde(default)]
587    pub efficient_turn_budget_seconds: Option<f64>,
588    /// Holds escalations across later user turns.
589    #[serde(default)]
590    pub latch_escalation: bool,
591}
592
593#[derive(Clone, Debug, Default, Deserialize)]
594#[serde(rename_all = "snake_case")]
595pub enum VgrModeConfig {
596    #[default]
597    Off,
598    Evaluate,
599    Shadow,
600    Active,
601}
602
603/// What fires an advisor route's review.
604#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
605#[serde(rename_all = "snake_case")]
606pub enum AdvisorTriggerConfig {
607    /// The executor's first turn without tool calls.
608    #[default]
609    NoToolCall,
610    /// The first turn whose text matches `gate_trigger_pattern`.
611    Pattern,
612}
613
614/// The judge a `stage_router` route falls through to, and how it routes.
615#[derive(Clone, Debug, Deserialize)]
616#[serde(deny_unknown_fields)]
617pub struct StageClassifierConfig {
618    /// Target the judge is called through. Not a routing destination.
619    pub target: String,
620    /// Composite accepts custom-schema judges as well as capability judges.
621    #[serde(default)]
622    pub mode: Option<ClassifierMode>,
623    #[serde(default)]
624    pub response_schema: Option<String>,
625    #[serde(default)]
626    pub policy: Option<ClassifierPolicyConfig>,
627    #[serde(default)]
628    pub decision: Option<DecisionJudgeRouteConfig>,
629    #[serde(default)]
630    pub fail_open: Option<bool>,
631    /// Lowest solve probability that still routes to the efficient tier, from 0 to 1.
632    pub base_threshold: Option<f64>,
633    /// How much to raise the threshold when the judge is uncertain. Added once
634    /// for an uncertain verdict and twice for unsupported.
635    #[serde(default)]
636    pub threshold_step: Option<f64>,
637    /// How often the judge runs. Composite honors it. A standalone stage route
638    /// rejects any value other than `every_request`, because its fallback judge
639    /// runs on every ambiguous turn and retains no verdict.
640    #[serde(default)]
641    pub classify_trigger: ClassifyTrigger,
642    /// Reuses the session's target by hashing the first user message when no
643    /// session ID is available.
644    #[serde(default)]
645    pub message_hash_fallback: bool,
646    /// How many trailing turns the judge sees. Unset shows it the opening task
647    /// and the latest user follow-up only.
648    #[serde(default)]
649    pub recent_turn_window: Option<usize>,
650    /// Replaces the packaged judge prompt.
651    #[serde(default)]
652    pub prompt: Option<String>,
653    /// How the judge is asked for structured output. Use `json_object` when the
654    /// provider cannot do JSON Schema.
655    #[serde(default)]
656    pub response_format_type: ClassifierResponseFormat,
657    /// Most completion tokens the judge verdict may use.
658    #[serde(default = "default_classifier_max_output_tokens")]
659    pub max_output_tokens: u64,
660}
661
662/// The shared model pair and defaults for composite stage profiles.
663#[derive(Clone, Debug, Deserialize)]
664#[serde(deny_unknown_fields)]
665pub struct StageTierConfig {
666    pub capable_target: String,
667    pub efficient_target: String,
668    #[serde(default)]
669    pub capable_first_threshold: f64,
670    #[serde(flatten)]
671    pub settings: StageSettingsConfig,
672}
673
674/// Settings shared by standalone stage routes and composite profiles.
675#[derive(Clone, Debug, Default, Deserialize)]
676#[serde(default, deny_unknown_fields)]
677pub struct StageSettingsConfig {
678    #[serde(alias = "efficient_first_threshold")]
679    pub confidence_threshold: Option<f64>,
680    pub recent_turn_window: Option<usize>,
681    pub tool_semantics: Option<ToolSemantics>,
682    pub capable_hold_turns: Option<i64>,
683    pub efficient_hold_turns: Option<i64>,
684    pub handoff_notes: Option<HandoffNoteConfig>,
685    pub capable_system_prompt: Option<String>,
686    pub efficient_system_prompt: Option<String>,
687}
688
689#[derive(Clone, Debug, Default, Deserialize)]
690#[serde(deny_unknown_fields)]
691pub struct StageProfileConfig {
692    pub classifier: Option<StageClassifierConfig>,
693    pub picker: Option<PickerMode>,
694    #[serde(flatten)]
695    pub settings: StageSettingsConfig,
696}
697
698impl StageSettingsConfig {
699    fn stage_config(&self, shared: &Self, mode: PickerMode, threshold: f64) -> StageRouterConfig {
700        let mut config =
701            StageRouterConfig::new(mode, self.confidence_threshold.unwrap_or(threshold));
702        config.recent_window = self.recent_turn_window.or(shared.recent_turn_window);
703        config.tool_semantics = self
704            .tool_semantics
705            .as_ref()
706            .or(shared.tool_semantics.as_ref())
707            .cloned()
708            .unwrap_or_default();
709        config.capable_hold_turns = self
710            .capable_hold_turns
711            .or(shared.capable_hold_turns)
712            .unwrap_or(2);
713        config.efficient_hold_turns = self.efficient_hold_turns.or(shared.efficient_hold_turns);
714        config.handoff_notes = self
715            .handoff_notes
716            .as_ref()
717            .or(shared.handoff_notes.as_ref())
718            .cloned();
719        config.capable_system_prompt = self
720            .capable_system_prompt
721            .as_ref()
722            .or(shared.capable_system_prompt.as_ref())
723            .cloned();
724        config.efficient_system_prompt = self
725            .efficient_system_prompt
726            .as_ref()
727            .or(shared.efficient_system_prompt.as_ref())
728            .cloned();
729        config
730    }
731}
732
733impl StageTierConfig {
734    /// Warns when both tiers name one target; see [`warn_identical_tiers`].
735    fn warn_identical_tiers(&self, route_name: &str) {
736        warn_identical_tiers(route_name, &self.capable_target, &self.efficient_target);
737    }
738}
739
740impl StageClassifierConfig {
741    fn task_classifier_config(
742        &self,
743        route_name: &str,
744        capable_target: &str,
745        efficient_target: &str,
746        targets: &BTreeMap<String, ModelId>,
747    ) -> AlgorithmResult<TaskClassifierConfig> {
748        if self
749            .mode
750            .is_some_and(|mode| !matches!(mode, ClassifierMode::Capability))
751            || self.response_schema.is_some()
752            || self.policy.is_some()
753        {
754            return Err(AlgorithmConfigError::new(
755                "custom classifier settings require composite with mode = custom",
756            ));
757        }
758        let config = LlmClassifierRouteConfig {
759            classifier_target: self.target.clone(),
760            strong_target: Some(capable_target.to_string()),
761            weak_target: Some(efficient_target.to_string()),
762            base_threshold: self.base_threshold,
763            threshold_step: self.threshold_step,
764            fail_open: self.fail_open,
765            decision: self.decision.clone(),
766            classify_trigger: self.classify_trigger,
767            message_hash_fallback: self.message_hash_fallback,
768            recent_turn_window: self.recent_turn_window,
769            prompt: self.prompt.clone(),
770            response_format_type: self.response_format_type,
771            max_output_tokens: self.max_output_tokens,
772            ..Default::default()
773        };
774        match config.validated_classifier_mode(route_name)? {
775            LlmClassifierModeConfig::Capability(config) => {
776                config.into_task_config(route_name, targets)
777            }
778            _ => Err(AlgorithmConfigError::new("expected a capability judge")),
779        }
780    }
781
782    fn stage_fallback(
783        &self,
784        route_name: &str,
785        capable: &str,
786        efficient: &str,
787        targets: &BTreeMap<String, ModelId>,
788    ) -> AlgorithmResult<LlmFallback> {
789        Ok(LlmFallback {
790            target: Some(resolve_target_model_id(route_name, &self.target, targets)?),
791            config: self.task_classifier_config(route_name, capable, efficient, targets)?,
792        })
793    }
794
795    fn custom_classifier_config(
796        &self,
797        route_name: &str,
798    ) -> AlgorithmResult<CustomClassifierConfig> {
799        if self.fail_open == Some(false) {
800            return Err(AlgorithmConfigError::new(
801                "composite custom classifiers always fail open; remove fail_open = false",
802            ));
803        }
804        if self.decision.is_some()
805            || self.base_threshold.is_some()
806            || self.threshold_step.is_some()
807            || self.response_format_type != ClassifierResponseFormat::JsonSchema
808        {
809            return Err(AlgorithmConfigError::new(
810                "composite custom classifier cannot use capability thresholds, decision settings, or response_format_type",
811            ));
812        }
813        let schema =
814            required_classifier_field(route_name, "response_schema", &self.response_schema)?;
815        let schema = serde_json::from_str(&schema).map_err(|error| {
816            AlgorithmConfigError::with_source(
817                format!("composite route {route_name}: response_schema is invalid JSON"),
818                error,
819            )
820        })?;
821        let mut config = CustomClassifierConfig::new(
822            required_classifier_field(route_name, "prompt", &self.prompt)?,
823            schema,
824            required_classifier_field(route_name, "policy", &self.policy)?.into_libsy(),
825        );
826        config.classify_trigger = self.classify_trigger;
827        config.message_hash_fallback = self.message_hash_fallback;
828        config.recent_turn_window = self.recent_turn_window;
829        config.max_output_tokens = self.max_output_tokens;
830        Ok(config)
831    }
832}
833
834impl AlgorithmSpec {
835    pub(crate) fn classifier_targets(
836        &self,
837    ) -> impl Iterator<Item = (&str, Option<&DecisionJudgeRouteConfig>)> {
838        let (primary, profiles) = match self {
839            Self::LlmClassifier { config, .. }
840                if !matches!(config.classifier_mode(), ClassifierMode::Custom) =>
841            {
842                (
843                    Some((config.classifier_target.as_str(), config.decision.as_ref())),
844                    [None, None],
845                )
846            }
847            Self::Composite {
848                classifier,
849                efficient,
850                capable,
851                ..
852            } => (
853                Some((classifier.target.as_str(), classifier.decision.as_ref())),
854                [efficient.as_deref(), capable.as_deref()],
855            ),
856            Self::PlanExecute {
857                judge: classifier, ..
858            }
859            | Self::StageRouter { classifier, .. } => (
860                classifier
861                    .as_ref()
862                    .map(|c| (c.target.as_str(), c.decision.as_ref())),
863                [None, None],
864            ),
865            _ => (None, [None, None]),
866        };
867        primary.into_iter().chain(
868            profiles
869                .into_iter()
870                .flatten()
871                .filter_map(|profile| profile.classifier.as_ref())
872                .map(|c| (c.target.as_str(), c.decision.as_ref())),
873        )
874    }
875
876    /// Completion targets in algorithm order; judge-only targets are excluded.
877    pub fn routing_target_names(&self) -> Vec<&str> {
878        match self {
879            Self::Noop { .. } => Vec::new(),
880            Self::Random { targets, .. } => targets.iter().map(String::as_str).collect(),
881            Self::Passthrough {
882                target, subagents, ..
883            } => {
884                let mut names = vec![target.as_str()];
885                if let Some(subagents) = subagents {
886                    names.extend(subagents.routing_target_names());
887                }
888                names
889            }
890            Self::PlanExecute {
891                capable_target,
892                efficient_target,
893                ..
894            } => vec![capable_target.as_str(), efficient_target.as_str()],
895            Self::LlmClassifier { config, .. } => match config.classifier_mode() {
896                ClassifierMode::Capability => config
897                    .weak_target
898                    .iter()
899                    .chain(&config.strong_target)
900                    .map(String::as_str)
901                    .collect(),
902                ClassifierMode::Escalation => config
903                    .strong_target
904                    .iter()
905                    .chain(&config.weak_target)
906                    .map(String::as_str)
907                    .collect(),
908                ClassifierMode::Custom => config
909                    .models
910                    .as_ref()
911                    .map(CategoryModelConfig::routing_names)
912                    .unwrap_or_default(),
913            },
914            Self::StageRouter {
915                capable_target,
916                efficient_target,
917                subagents,
918                ..
919            } => {
920                let mut names = vec![capable_target.as_str(), efficient_target.as_str()];
921                if let Some(subagents) = subagents {
922                    names.extend(subagents.routing_target_names());
923                }
924                names
925            }
926            Self::Auto {
927                capable_target,
928                efficient_target,
929            } => vec![capable_target.as_str(), efficient_target.as_str()],
930            Self::Composite {
931                stage, subagents, ..
932            } => {
933                let mut names = vec![
934                    stage.capable_target.as_str(),
935                    stage.efficient_target.as_str(),
936                ];
937                if let Some(subagents) = subagents {
938                    names.extend(subagents.routing_target_names());
939                }
940                names
941            }
942            // The advisor is judge-only: reviews go through its own client,
943            // so it is not a completion (or count_tokens) destination.
944            Self::Advisor {
945                executor_target, ..
946            } => vec![executor_target],
947            Self::Vgr { config } => {
948                vec![
949                    config.efficient_target.as_str(),
950                    config.capable_target.as_str(),
951                ]
952            }
953            Self::PrefillRouter { targets, .. } => targets.iter().map(String::as_str).collect(),
954        }
955    }
956
957    /// Every target the algorithm may call, including judge-only targets.
958    ///
959    /// [`routing_target_names`](Self::routing_target_names) covers completion destinations;
960    /// a classifier also calls its judge, and that call needs a client too.
961    pub fn callable_target_names(&self) -> Vec<&str> {
962        let mut names = self.routing_target_names();
963        names.extend(self.judge_target_names());
964        names
965    }
966
967    /// Targets the algorithm calls only to decide, such as a judge or an advisor. A target
968    /// that also answers requests appears in [`routing_target_names`](Self::routing_target_names) too.
969    pub fn judge_target_names(&self) -> Vec<&str> {
970        let mut names = Vec::new();
971        match self {
972            // Custom mode names its judge in `models.judge`; the other two modes
973            // use the top-level `classifier_target`.
974            Self::LlmClassifier { config, .. } => {
975                if matches!(config.classifier_mode(), ClassifierMode::Custom) {
976                    names.extend(
977                        config
978                            .models
979                            .as_ref()
980                            .map(|models| models.get(&Category::Judge))
981                            .unwrap_or_default()
982                            .iter()
983                            .map(String::as_str),
984                    );
985                } else {
986                    names.push(config.classifier_target.as_str());
987                }
988            }
989            Self::PlanExecute { .. } | Self::StageRouter { .. } | Self::Composite { .. } => {
990                names.extend(self.classifier_targets().map(|(target, _)| target));
991            }
992            Self::Advisor { advisor_target, .. } => names.push(advisor_target),
993            Self::Vgr { config } => {
994                names.extend(config.judge_target.as_deref());
995                names.extend(config.capable_judge_target.as_deref());
996            }
997            _ => {}
998        }
999        // A sub-agent classifier calls its own judge, which is never a completion target.
1000        if let Self::Passthrough {
1001            subagents: Some(subagents),
1002            ..
1003        }
1004        | Self::StageRouter {
1005            subagents: Some(subagents),
1006            ..
1007        }
1008        | Self::Composite {
1009            subagents: Some(subagents),
1010            ..
1011        } = self
1012        {
1013            names.extend(subagents.judge_target_names());
1014        }
1015        names
1016    }
1017
1018    /// Target names grouped as the runtime [`Driver`](libsy::Driver) expects them.
1019    fn runtime_model_names(&self, route_name: &str) -> AlgorithmResult<RuntimeModelNames> {
1020        let parent = match self {
1021            Self::Noop { .. } => HashMap::new(),
1022            Self::Random { targets, .. } | Self::PrefillRouter { targets, .. } => {
1023                category_models([(Category::Any, targets.clone())])
1024            }
1025            Self::Passthrough { target, .. } => {
1026                category_models([(Category::Any, vec![target.clone()])])
1027            }
1028            Self::LlmClassifier { config } => {
1029                classifier_runtime_model_names(config.validated_classifier_mode(route_name)?)
1030            }
1031            Self::PlanExecute {
1032                capable_target,
1033                efficient_target,
1034                judge: classifier,
1035                ..
1036            }
1037            | Self::StageRouter {
1038                capable_target,
1039                efficient_target,
1040                classifier,
1041                ..
1042            } => {
1043                let mut models = category_models([
1044                    (Category::Capable, vec![capable_target.clone()]),
1045                    (Category::Efficient, vec![efficient_target.clone()]),
1046                    (
1047                        Category::Any,
1048                        vec![capable_target.clone(), efficient_target.clone()],
1049                    ),
1050                ]);
1051                if let Some(classifier) = classifier {
1052                    models.insert(Category::Judge, vec![classifier.target.clone()]);
1053                }
1054                models
1055            }
1056            Self::Auto {
1057                capable_target,
1058                efficient_target,
1059            } => category_models([
1060                (Category::Capable, vec![capable_target.clone()]),
1061                (Category::Efficient, vec![efficient_target.clone()]),
1062                (
1063                    Category::Any,
1064                    vec![capable_target.clone(), efficient_target.clone()],
1065                ),
1066            ]),
1067            Self::Composite { stage, .. } => category_models([
1068                (
1069                    Category::Judge,
1070                    self.judge_target_names()
1071                        .into_iter()
1072                        .map(str::to_string)
1073                        .collect(),
1074                ),
1075                (Category::Capable, vec![stage.capable_target.clone()]),
1076                (Category::Efficient, vec![stage.efficient_target.clone()]),
1077                (
1078                    Category::Any,
1079                    vec![stage.capable_target.clone(), stage.efficient_target.clone()],
1080                ),
1081            ]),
1082            Self::Advisor {
1083                executor_target,
1084                advisor_target,
1085                ..
1086            } => category_models([
1087                (Category::Efficient, vec![executor_target.clone()]),
1088                (Category::Any, vec![executor_target.clone()]),
1089                (Category::Judge, vec![advisor_target.clone()]),
1090            ]),
1091            Self::Vgr { config } => {
1092                let judge = config
1093                    .judge_target
1094                    .as_ref()
1095                    .unwrap_or(&config.efficient_target);
1096                let judges = std::iter::once(judge)
1097                    .chain(&config.capable_judge_target)
1098                    .cloned()
1099                    .collect();
1100                category_models([
1101                    (Category::Efficient, vec![config.efficient_target.clone()]),
1102                    (Category::Capable, vec![config.capable_target.clone()]),
1103                    (
1104                        Category::Any,
1105                        vec![
1106                            config.efficient_target.clone(),
1107                            config.capable_target.clone(),
1108                        ],
1109                    ),
1110                    (Category::Judge, judges),
1111                ])
1112            }
1113        };
1114
1115        let subagents = match self {
1116            Self::Passthrough { subagents, .. }
1117            | Self::StageRouter { subagents, .. }
1118            | Self::Composite { subagents, .. } => subagents.as_ref(),
1119            _ => None,
1120        };
1121        // Sub-agent groups stay separate from the parent's. Merging them would let a
1122        // sub-agent's `capable` resolve to the parent's, and would put models only the
1123        // sub-agents were given into the parent's `Any` fallback list.
1124        let subagent = subagents
1125            .map(|subagents| subagent_runtime_model_names(subagents, route_name))
1126            .transpose()?;
1127        Ok(RuntimeModelNames { parent, subagent })
1128    }
1129
1130    /// Response target and routing-only dependency for routers that answer while routing.
1131    #[cfg(not(target_os = "wasi"))]
1132    pub(crate) fn routing_response_and_dependency(&self) -> Option<(&str, &str)> {
1133        match self {
1134            Self::LlmClassifier { config, .. }
1135                if matches!(config.classifier_mode(), ClassifierMode::Escalation) =>
1136            {
1137                Some((
1138                    config.weak_target.as_deref()?,
1139                    config.classifier_target.as_str(),
1140                ))
1141            }
1142            Self::Advisor {
1143                executor_target,
1144                advisor_target,
1145                ..
1146            } => Some((executor_target, advisor_target)),
1147            Self::Vgr { config } => Some((
1148                &config.efficient_target,
1149                config
1150                    .judge_target
1151                    .as_deref()
1152                    .unwrap_or(&config.efficient_target),
1153            )),
1154            Self::Noop { .. }
1155            | Self::Random { .. }
1156            | Self::Passthrough { .. }
1157            | Self::PlanExecute { .. }
1158            | Self::LlmClassifier { .. }
1159            | Self::StageRouter { .. }
1160            | Self::Auto { .. }
1161            | Self::Composite { .. }
1162            | Self::PrefillRouter { .. } => None,
1163        }
1164    }
1165
1166    /// Builds this algorithm after resolving configured target names.
1167    pub fn build(
1168        &self,
1169        route_name: &str,
1170        targets: &BTreeMap<String, ModelId>,
1171    ) -> AlgorithmResult<Arc<dyn Algorithm>> {
1172        build_algorithm(route_name, self, targets)
1173    }
1174
1175    /// Resolves target names to host model IDs and builds the algorithm and runtime groups.
1176    pub fn build_with_runtime_models(
1177        &self,
1178        route_name: &str,
1179        targets: &BTreeMap<String, ModelId>,
1180    ) -> AlgorithmResult<(Arc<dyn Algorithm>, RuntimeModels)> {
1181        let algorithm = self.build(route_name, targets)?;
1182        let names = self.runtime_model_names(route_name)?;
1183        let mut models =
1184            RuntimeModels::new(resolve_runtime_models(route_name, names.parent, targets)?);
1185        if let Some(subagent) = names.subagent {
1186            models = models.with_subagent(resolve_runtime_models(route_name, subagent, targets)?);
1187        }
1188        Ok((algorithm, models))
1189    }
1190}
1191
1192/// One route's target names, grouped by category and by routing scope.
1193struct RuntimeModelNames {
1194    /// Groups the algorithm itself routes over.
1195    parent: HashMap<Category, Vec<String>>,
1196    /// Groups delegated sub-agent work routes over, when the route has a `subagents` table.
1197    subagent: Option<HashMap<Category, Vec<String>>>,
1198}
1199
1200fn category_models(
1201    entries: impl IntoIterator<Item = (Category, Vec<String>)>,
1202) -> HashMap<Category, Vec<String>> {
1203    entries.into_iter().collect()
1204}
1205
1206fn custom_runtime_model_names(config: &CategoryModelConfig) -> HashMap<Category, Vec<String>> {
1207    config.groups().collect()
1208}
1209
1210fn classifier_runtime_model_names(
1211    config: LlmClassifierModeConfig,
1212) -> HashMap<Category, Vec<String>> {
1213    match config {
1214        LlmClassifierModeConfig::Capability(config) => category_models([
1215            (Category::Judge, vec![config.classifier_target]),
1216            (Category::Efficient, vec![config.weak_target.clone()]),
1217            (Category::Capable, vec![config.strong_target.clone()]),
1218            (
1219                Category::Any,
1220                vec![config.weak_target, config.strong_target],
1221            ),
1222        ]),
1223        LlmClassifierModeConfig::Escalation(config) => category_models([
1224            (Category::Judge, vec![config.classifier_target]),
1225            (Category::Efficient, vec![config.weak_target.clone()]),
1226            (Category::Capable, vec![config.strong_target.clone()]),
1227            (
1228                Category::Any,
1229                vec![config.strong_target, config.weak_target],
1230            ),
1231        ]),
1232        LlmClassifierModeConfig::Custom(config) => custom_runtime_model_names(&config.models),
1233    }
1234}
1235
1236fn subagent_runtime_model_names(
1237    config: &SubagentRouteConfig,
1238    route_name: &str,
1239) -> AlgorithmResult<HashMap<Category, Vec<String>>> {
1240    match config {
1241        SubagentRouteConfig::Passthrough { target } => {
1242            Ok(category_models([(Category::Any, vec![target.clone()])]))
1243        }
1244        SubagentRouteConfig::LlmClassifier(config) => {
1245            let LlmClassifierModeConfig::Custom(config) =
1246                config.validated_classifier_mode(route_name)?
1247            else {
1248                return Err(AlgorithmConfigError::new(format!(
1249                    "route {route_name}: subagents llm_classifier only supports mode custom"
1250                )));
1251            };
1252            Ok(custom_runtime_model_names(&config.models))
1253        }
1254    }
1255}
1256impl LlmClassifierRouteConfig {
1257    fn classifier_mode(&self) -> ClassifierMode {
1258        let default = if self.escalation.is_some() {
1259            ClassifierMode::Escalation
1260        } else {
1261            ClassifierMode::Capability
1262        };
1263        self.mode.unwrap_or(default)
1264    }
1265
1266    fn validated_classifier_mode(
1267        &self,
1268        route_name: &str,
1269    ) -> AlgorithmResult<LlmClassifierModeConfig> {
1270        let Self {
1271            classifier_target,
1272            mode,
1273            strong_target,
1274            weak_target,
1275            base_threshold,
1276            threshold_step,
1277            unmatched_steps,
1278            fail_open,
1279            classify_trigger,
1280            message_hash_fallback,
1281            recent_turn_window,
1282            prompt,
1283            response_format_type,
1284            max_output_tokens,
1285            escalation,
1286            decision,
1287            models,
1288            default_target,
1289            response_schema,
1290            policy,
1291        } = self;
1292
1293        let selected_mode = match (mode, escalation.is_some()) {
1294            (Some(mode), _) => *mode,
1295            (None, true) => ClassifierMode::Escalation,
1296            (None, false) => ClassifierMode::Capability,
1297        };
1298
1299        if decision.is_some() && !matches!(selected_mode, ClassifierMode::Capability) {
1300            return Err(AlgorithmConfigError::new(format!(
1301                "llm_classifier route {route_name}: decision requires capability mode"
1302            )));
1303        }
1304
1305        if !matches!(selected_mode, ClassifierMode::Capability) && *fail_open == Some(true) {
1306            return Err(AlgorithmConfigError::new(format!(
1307                "llm_classifier route {route_name}: fail_open = true requires capability mode"
1308            )));
1309        }
1310
1311        match selected_mode {
1312            ClassifierMode::Capability => {
1313                if escalation.is_some() {
1314                    return Err(classifier_field_error(
1315                        route_name,
1316                        "escalation",
1317                        "capability",
1318                    ));
1319                }
1320                reject_custom_fields(
1321                    route_name,
1322                    "capability",
1323                    models,
1324                    default_target,
1325                    response_schema,
1326                    policy,
1327                )?;
1328                let judge = if let Some(decision) = decision {
1329                    if base_threshold.is_some()
1330                        || threshold_step.is_some()
1331                        || unmatched_steps.is_some()
1332                        || prompt.is_some()
1333                        || *response_format_type != ClassifierResponseFormat::JsonSchema
1334                        || *max_output_tokens != default_classifier_max_output_tokens()
1335                    {
1336                        return Err(AlgorithmConfigError::new(format!(
1337                            "llm_classifier route {route_name}: decision cannot use LLM judge settings; use decision.cutoff and decision.instructions"
1338                        )));
1339                    }
1340                    CapabilityJudgeRouteConfig::Decision(decision.clone())
1341                } else {
1342                    CapabilityJudgeRouteConfig::Llm(LlmCapabilityConfig {
1343                        base_threshold: required_classifier_field(
1344                            route_name,
1345                            "base_threshold",
1346                            base_threshold,
1347                        )?,
1348                        threshold_step: threshold_step.unwrap_or_default(),
1349                        unmatched_steps: unmatched_steps.unwrap_or(DEFAULT_UNMATCHED_STEPS),
1350                        contract: classifier_contract(prompt.as_deref())
1351                            .with_response_format_type(*response_format_type),
1352                        max_output_tokens: *max_output_tokens,
1353                    })
1354                };
1355                Ok(LlmClassifierModeConfig::Capability(
1356                    CapabilityClassifierRouteConfig {
1357                        classifier_target: classifier_target.clone(),
1358                        fail_open: fail_open.unwrap_or(true),
1359                        strong_target: required_classifier_field(
1360                            route_name,
1361                            "strong_target",
1362                            strong_target,
1363                        )?,
1364                        weak_target: required_classifier_field(
1365                            route_name,
1366                            "weak_target",
1367                            weak_target,
1368                        )?,
1369                        judge,
1370                        classify_trigger: *classify_trigger,
1371                        message_hash_fallback: *message_hash_fallback,
1372                        recent_turn_window: *recent_turn_window,
1373                    },
1374                ))
1375            }
1376            ClassifierMode::Escalation => {
1377                reject_custom_fields(
1378                    route_name,
1379                    "escalation",
1380                    models,
1381                    default_target,
1382                    response_schema,
1383                    policy,
1384                )?;
1385                if *classify_trigger != ClassifyTrigger::EveryRequest {
1386                    return Err(AlgorithmConfigError::new(format!(
1387                        "llm_classifier route {route_name} mode escalation cannot use classify_trigger"
1388                    )));
1389                }
1390                let has_capability_settings = base_threshold.is_some()
1391                    || threshold_step.is_some()
1392                    || fail_open.is_some()
1393                    || *message_hash_fallback
1394                    || recent_turn_window.is_some();
1395                // `unmatched_steps` is new, so no existing escalation configuration carries it:
1396                // reject it even when the mode is implied by `escalation`. The older capability
1397                // keys stay tolerated in that implicit form for compatibility.
1398                if unmatched_steps.is_some() || (mode.is_some() && has_capability_settings) {
1399                    return Err(AlgorithmConfigError::new(format!(
1400                        "llm_classifier route {route_name} mode escalation cannot use capability routing settings"
1401                    )));
1402                }
1403                if has_capability_settings {
1404                    // Existing files that select escalation through the `escalation` table alone
1405                    // keep loading, but their capability keys do nothing in this mode.
1406                    tracing::warn!(
1407                        "llm_classifier route {route_name} runs in escalation mode because it has an escalation table; base_threshold, threshold_step, fail_open, recent_turn_window and message_hash_fallback are ignored in this mode. Set mode = \"escalation\" and remove them."
1408                    );
1409                }
1410                Ok(LlmClassifierModeConfig::Escalation(
1411                    EscalationClassifierRouteConfig {
1412                        classifier_target: classifier_target.clone(),
1413                        strong_target: required_classifier_field(
1414                            route_name,
1415                            "strong_target",
1416                            strong_target,
1417                        )?,
1418                        weak_target: required_classifier_field(
1419                            route_name,
1420                            "weak_target",
1421                            weak_target,
1422                        )?,
1423                        prompt: prompt.clone(),
1424                        response_format_type: *response_format_type,
1425                        max_output_tokens: *max_output_tokens,
1426                        judge: required_classifier_field(route_name, "escalation", escalation)?,
1427                    },
1428                ))
1429            }
1430            ClassifierMode::Custom => {
1431                if !classifier_target.is_empty()
1432                    || strong_target.is_some()
1433                    || weak_target.is_some()
1434                    || base_threshold.is_some()
1435                    || threshold_step.is_some()
1436                    || unmatched_steps.is_some()
1437                    || escalation.is_some()
1438                    || *response_format_type != ClassifierResponseFormat::JsonSchema
1439                {
1440                    return Err(AlgorithmConfigError::new(format!(
1441                        "llm_classifier route {route_name} mode custom cannot use capability or escalation fields and response_format_type must be 'json_schema'"
1442                    )));
1443                }
1444                let models = required_classifier_field(route_name, "models", models)?;
1445                let default_target: Category = required_classifier_field(
1446                    route_name,
1447                    "default_target",
1448                    default_target,
1449                )?
1450                .parse()
1451                .map_err(|error| {
1452                    AlgorithmConfigError::new(format!(
1453                        "llm_classifier route {route_name} has invalid default_target: {error}"
1454                    ))
1455                })?;
1456                if default_target == Category::Judge {
1457                    return Err(AlgorithmConfigError::new(format!(
1458                        "llm_classifier route {route_name} default_target cannot be judge"
1459                    )));
1460                }
1461                models.validate(route_name, &default_target)?;
1462                Ok(LlmClassifierModeConfig::Custom(
1463                    CustomClassifierRouteConfig {
1464                        models,
1465                        default_target,
1466                        prompt: required_classifier_field(route_name, "prompt", prompt)?,
1467                        response_schema: required_classifier_field(
1468                            route_name,
1469                            "response_schema",
1470                            response_schema,
1471                        )?,
1472                        policy: required_classifier_field(route_name, "policy", policy)?,
1473                        classify_trigger: *classify_trigger,
1474                        message_hash_fallback: *message_hash_fallback,
1475                        recent_turn_window: *recent_turn_window,
1476                        max_output_tokens: *max_output_tokens,
1477                    },
1478                ))
1479            }
1480        }
1481    }
1482}
1483
1484fn reject_custom_fields(
1485    route_name: &str,
1486    mode: &str,
1487    models: &Option<CategoryModelConfig>,
1488    default_target: &Option<String>,
1489    response_schema: &Option<String>,
1490    policy: &Option<ClassifierPolicyConfig>,
1491) -> AlgorithmResult<()> {
1492    if models.is_some() || default_target.is_some() || response_schema.is_some() || policy.is_some()
1493    {
1494        return Err(AlgorithmConfigError::new(format!(
1495            "llm_classifier route {route_name} mode {mode} cannot use custom classifier fields"
1496        )));
1497    }
1498    Ok(())
1499}
1500
1501fn classifier_field_error(route_name: &str, field: &str, mode: &str) -> AlgorithmConfigError {
1502    AlgorithmConfigError::new(format!(
1503        "llm_classifier route {route_name} mode {mode} cannot use {field}"
1504    ))
1505}
1506
1507fn required_classifier_field<T: Clone>(
1508    route_name: &str,
1509    field: &str,
1510    value: &Option<T>,
1511) -> AlgorithmResult<T> {
1512    value.clone().ok_or_else(|| {
1513        AlgorithmConfigError::new(format!(
1514            "llm_classifier route {route_name} requires {field}"
1515        ))
1516    })
1517}
1518
1519fn build_subagent_router_config(
1520    route_name: &str,
1521    config: &SubagentRouteConfig,
1522    targets: &BTreeMap<String, ModelId>,
1523) -> AlgorithmResult<SubagentRouterConfig> {
1524    match config {
1525        SubagentRouteConfig::Passthrough { target } => {
1526            resolve_target_model_id(route_name, target, targets)?;
1527            Ok(SubagentRouterConfig::fixed_target())
1528        }
1529        SubagentRouteConfig::LlmClassifier(config) => {
1530            let LlmClassifierModeConfig::Custom(config) =
1531                config.validated_classifier_mode(route_name)?
1532            else {
1533                return Err(AlgorithmConfigError::new(format!(
1534                    "route {route_name}: subagents llm_classifier only supports mode custom"
1535                )));
1536            };
1537            for name in config.models.all_names() {
1538                resolve_target_model_id(route_name, name, targets)?;
1539            }
1540            if config.models.get(&config.default_target).is_empty() {
1541                return Err(AlgorithmConfigError::new(format!(
1542                    "route {route_name}: subagents llm_classifier has no model for default category {}",
1543                    config.default_target.as_str()
1544                )));
1545            }
1546            let response_schema =
1547                serde_json::from_str(&config.response_schema).map_err(|error| {
1548                    AlgorithmConfigError::with_source(
1549                        format!(
1550                            "route {route_name}: subagents llm_classifier response_schema is invalid JSON: {error}"
1551                        ),
1552                        error,
1553                    )
1554                })?;
1555            warn_single_target_classifier(route_name, &config.models);
1556            let mut classifier_config = CustomClassifierConfig::new(
1557                config.prompt,
1558                response_schema,
1559                config.policy.into_libsy(),
1560            );
1561            classifier_config.recent_turn_window = config.recent_turn_window;
1562            classifier_config.max_output_tokens = config.max_output_tokens;
1563            let classifier = Arc::new(
1564                LlmTaskClassifier::new(LlmClassifierConfig::Custom {
1565                    default_target: config.default_target.clone(),
1566                    config: classifier_config,
1567                })
1568                .map_err(|error| {
1569                    AlgorithmConfigError::with_source(
1570                        format!("route {route_name}: subagents llm_classifier: {error}"),
1571                        error,
1572                    )
1573                })?,
1574            );
1575            Ok(SubagentRouterConfig {
1576                classifier,
1577                default_target: config.default_target,
1578                classify_trigger: config.classify_trigger,
1579                message_hash_fallback: config.message_hash_fallback,
1580            })
1581        }
1582    }
1583}
1584
1585fn attach_subagent_router(
1586    route_name: &str,
1587    parent: Arc<dyn Algorithm>,
1588    config: Option<&SubagentRouteConfig>,
1589    targets: &BTreeMap<String, ModelId>,
1590) -> AlgorithmResult<Arc<dyn Algorithm>> {
1591    let Some(config) = config else {
1592        return Ok(parent);
1593    };
1594    let config = build_subagent_router_config(route_name, config, targets)?;
1595    let algorithm = SubagentRouter::new(parent, config).map_err(|error| {
1596        AlgorithmConfigError::with_source(
1597            format!("route {route_name}: subagent routing: {error}"),
1598            error,
1599        )
1600    })?;
1601    Ok(Arc::new(algorithm))
1602}
1603
1604fn build_algorithm(
1605    route_name: &str,
1606    config: &AlgorithmSpec,
1607    targets: &BTreeMap<String, ModelId>,
1608) -> AlgorithmResult<Arc<dyn Algorithm>> {
1609    match config {
1610        AlgorithmSpec::Noop { .. } => Ok(Arc::new(Noop {})),
1611        AlgorithmSpec::Random {
1612            targets: names,
1613            weights,
1614            seed,
1615        } => {
1616            // The algorithm only sees its targets at request time, so a bad pairing
1617            // would otherwise fail every request instead of failing to start.
1618            if let Some(weights) = weights
1619                && weights.len() != names.len()
1620            {
1621                return Err(AlgorithmConfigError::new(format!(
1622                    "random route {route_name}: expected {} weights, got {}",
1623                    names.len(),
1624                    weights.len()
1625                )));
1626            }
1627            let mut seen = BTreeSet::new();
1628            if let Some(duplicate) = names.iter().find(|name| !seen.insert(*name)) {
1629                return Err(AlgorithmConfigError::new(format!(
1630                    "random route {route_name}: targets must be unique, {duplicate} is repeated"
1631                )));
1632            }
1633            let algorithm = Random::new(weights.clone(), *seed).map_err(|error| {
1634                AlgorithmConfigError::with_source(
1635                    format!("random route {route_name}: {error}"),
1636                    error,
1637                )
1638            })?;
1639            Ok(Arc::new(algorithm))
1640        }
1641        AlgorithmSpec::Passthrough { subagents, .. } => {
1642            let algorithm = Passthrough;
1643            let parent: Arc<dyn Algorithm> = Arc::new(algorithm);
1644            attach_subagent_router(route_name, parent, subagents.as_ref(), targets)
1645        }
1646        AlgorithmSpec::PlanExecute {
1647            execution_trigger,
1648            judge,
1649            capable_target,
1650            efficient_target,
1651            tool_semantics,
1652            planning_prompt,
1653            handoff_prompt,
1654            planner_reasoning_as_text,
1655            ..
1656        } => {
1657            if (*execution_trigger == PlanExecuteTrigger::Judge) != judge.is_some() {
1658                return Err(AlgorithmConfigError::new(
1659                    "plan_execute requires a judge exactly when execution_trigger = judge",
1660                ));
1661            }
1662            let mut config = PlanExecuteConfig {
1663                judge: judge
1664                    .as_ref()
1665                    .map(|judge| {
1666                        if judge.classify_trigger != ClassifyTrigger::EveryRequest
1667                            || judge.message_hash_fallback
1668                        {
1669                            return Err(AlgorithmConfigError::new(
1670                                "plan_execute judge runs every tool turn. Remove classify_trigger and message_hash_fallback",
1671                            ));
1672                        }
1673                        judge.task_classifier_config(
1674                            route_name,
1675                            capable_target,
1676                            efficient_target,
1677                            targets,
1678                        )
1679                    })
1680                    .transpose()?,
1681                ..PlanExecuteConfig::default()
1682            };
1683            config.tool_semantics.clone_from(tool_semantics);
1684            if let Some(prompt) = planning_prompt {
1685                config.planning_prompt = prompt.clone();
1686            }
1687            config.handoff_prompt.clone_from(handoff_prompt);
1688            config.planner_reasoning_as_text = *planner_reasoning_as_text;
1689            let algorithm = PlanExecute::new(config).map_err(|error| {
1690                AlgorithmConfigError::with_source(
1691                    format!("plan_execute route {route_name}: {error}"),
1692                    error,
1693                )
1694            })?;
1695            Ok(Arc::new(algorithm))
1696        }
1697        AlgorithmSpec::LlmClassifier {
1698            config: classifier_config,
1699            ..
1700        } => {
1701            let mode = classifier_config.validated_classifier_mode(route_name)?;
1702            let algorithm = match mode {
1703                LlmClassifierModeConfig::Capability(config) => {
1704                    let classifier_config = config.into_task_config(route_name, targets)?;
1705                    LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1706                        config: classifier_config,
1707                    })
1708                }
1709                LlmClassifierModeConfig::Escalation(config) => {
1710                    LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
1711                        contract: classifier_contract(config.prompt.as_deref())
1712                            .with_response_format_type(config.response_format_type),
1713                        config: config.judge,
1714                        max_output_tokens: config.max_output_tokens,
1715                    })
1716                }
1717                LlmClassifierModeConfig::Custom(config) => {
1718                    let response_schema = serde_json::from_str(&config.response_schema).map_err(
1719                        |error| {
1720                            AlgorithmConfigError::with_source(
1721                                format!(
1722                                    "llm_classifier route {route_name}: response_schema is invalid JSON: {error}"
1723                                ),
1724                                error,
1725                            )
1726                        },
1727                    )?;
1728                    warn_single_target_classifier(route_name, &config.models);
1729                    let mut classifier_config = CustomClassifierConfig::new(
1730                        config.prompt,
1731                        response_schema,
1732                        config.policy.into_libsy(),
1733                    );
1734                    classifier_config.classify_trigger = config.classify_trigger;
1735                    classifier_config.message_hash_fallback = config.message_hash_fallback;
1736                    classifier_config.recent_turn_window = config.recent_turn_window;
1737                    classifier_config.max_output_tokens = config.max_output_tokens;
1738                    LlmTaskClassifier::new(LlmClassifierConfig::Custom {
1739                        default_target: config.default_target,
1740                        config: classifier_config,
1741                    })
1742                }
1743            }
1744            .map_err(|error| {
1745                AlgorithmConfigError::with_source(
1746                    format!("llm_classifier route {route_name}: {error}"),
1747                    error,
1748                )
1749            })?;
1750            Ok(Arc::new(algorithm))
1751        }
1752        AlgorithmSpec::StageRouter {
1753            capable_target,
1754            efficient_target,
1755            capable_first_threshold,
1756            settings,
1757            picker,
1758            classifier,
1759            subagents,
1760            ..
1761        } => {
1762            if matches!(picker, PickerMode::CapableFirst) {
1763                tracing::warn!(
1764                    "stage_router route {route_name} uses picker \"capable_first\", which is experimental: published thresholds and routing results all come from \"efficient_first\", so there is no calibrated confidence_threshold for it and no measured accuracy or cost. Use \"efficient_first\" unless you are running your own calibration."
1765                );
1766            }
1767            warn_identical_tiers(route_name, capable_target, efficient_target);
1768            let threshold = settings.confidence_threshold.ok_or_else(|| {
1769                AlgorithmConfigError::new(format!(
1770                    "stage_router route {route_name}: confidence_threshold is required"
1771                ))
1772            })?;
1773            let mut config = settings.stage_config(settings, *picker, threshold);
1774            config.capable_first_threshold = *capable_first_threshold;
1775            // The judge is called through its own target, so it is not a routing
1776            // destination and stays out of the tier pair.
1777            config.llm_fallback = classifier
1778                .as_ref()
1779                .map(|classifier| {
1780                    classifier.stage_fallback(route_name, capable_target, efficient_target, targets)
1781                })
1782                .transpose()?;
1783            let algorithm = StageRouter::new(config).map_err(|error| {
1784                AlgorithmConfigError::with_source(
1785                    format!("stage_router route {route_name}: {error}"),
1786                    error,
1787                )
1788            })?;
1789            let parent: Arc<dyn Algorithm> = Arc::new(algorithm);
1790            attach_subagent_router(route_name, parent, subagents.as_ref(), targets)
1791        }
1792        AlgorithmSpec::Auto {
1793            capable_target,
1794            efficient_target,
1795        } => {
1796            warn_identical_tiers(route_name, capable_target, efficient_target);
1797            let config = StageRouterConfig::new(PickerMode::EfficientFirst, 0.5);
1798            let algorithm = StageRouter::new(config).map_err(|error| {
1799                AlgorithmConfigError::with_source(
1800                    format!("auto route {route_name}: {error}"),
1801                    error,
1802                )
1803            })?;
1804            Ok(Arc::new(algorithm))
1805        }
1806        AlgorithmSpec::Composite {
1807            classifier,
1808            stage,
1809            efficient,
1810            capable,
1811            subagents,
1812        } => {
1813            stage.warn_identical_tiers(route_name);
1814            let judge = if matches!(classifier.mode, Some(ClassifierMode::Custom)) {
1815                CompositeJudgeConfig::Custom(classifier.custom_classifier_config(route_name)?)
1816            } else {
1817                CompositeJudgeConfig::Capability(classifier.task_classifier_config(
1818                    route_name,
1819                    &stage.capable_target,
1820                    &stage.efficient_target,
1821                    targets,
1822                )?)
1823            };
1824            let shared = &stage.settings;
1825            let algorithm = if efficient.is_some() || capable.is_some() {
1826                let defaults = StageProfileConfig::default();
1827                let profiles = [
1828                    (
1829                        efficient,
1830                        PickerMode::EfficientFirst,
1831                        shared.confidence_threshold.unwrap_or(0.5),
1832                    ),
1833                    (
1834                        capable,
1835                        PickerMode::CapableFirst,
1836                        stage.capable_first_threshold,
1837                    ),
1838                ]
1839                .map(|(profile, picker, threshold)| -> AlgorithmResult<_> {
1840                    let profile = profile.as_deref().unwrap_or(&defaults);
1841                    let mut config = profile.settings.stage_config(
1842                        shared,
1843                        profile.picker.unwrap_or(picker),
1844                        threshold,
1845                    );
1846                    config.llm_fallback = profile
1847                        .classifier
1848                        .as_ref()
1849                        .map(|classifier| {
1850                            classifier.stage_fallback(
1851                                route_name,
1852                                &stage.capable_target,
1853                                &stage.efficient_target,
1854                                targets,
1855                            )
1856                        })
1857                        .transpose()?;
1858                    Ok(config)
1859                });
1860                let [profile_0, profile_1] = profiles;
1861                CompositeRouter::new_with_profiles(judge, profile_0?, profile_1?)
1862            } else {
1863                let mut config = shared.stage_config(shared, PickerMode::EfficientFirst, 0.5);
1864                config.capable_first_threshold = stage.capable_first_threshold;
1865                CompositeRouter::new(CompositeRouterConfig {
1866                    judge,
1867                    stage: config,
1868                })
1869            }
1870            .map_err(|error| {
1871                AlgorithmConfigError::with_source(
1872                    format!("composite route {route_name}: {error}"),
1873                    error,
1874                )
1875            })?;
1876            let parent: Arc<dyn Algorithm> = Arc::new(algorithm);
1877            attach_subagent_router(route_name, parent, subagents.as_ref(), targets)
1878        }
1879        AlgorithmSpec::Advisor {
1880            reviewer_system_prompt,
1881            redo_feedback_prefix,
1882            gate_trigger,
1883            gate_trigger_pattern,
1884            max_reviews,
1885            gate_stall_turns,
1886            gate_min_tool_results,
1887            advisor_max_tokens,
1888            advisor_temperature,
1889            transcript_max_chars,
1890            fail_open,
1891            ..
1892        } => {
1893            // A pattern set under the default trigger would be silently
1894            // ignored; reject the misconfiguration instead.
1895            if *gate_trigger == AdvisorTriggerConfig::NoToolCall && gate_trigger_pattern.is_some() {
1896                return Err(AlgorithmConfigError::new(format!(
1897                    "advisor route {route_name}: gate_trigger_pattern requires \
1898                     gate_trigger = \"pattern\""
1899                )));
1900            }
1901            let mut config = AdvisorGateConfig::default();
1902            if let Some(prompt) = reviewer_system_prompt {
1903                config.reviewer_system_prompt = prompt.clone();
1904            }
1905            if let Some(prefix) = redo_feedback_prefix {
1906                config.redo_feedback_prefix = prefix.clone();
1907            }
1908            config.gate_trigger = match gate_trigger {
1909                AdvisorTriggerConfig::NoToolCall => GateTrigger::NoToolCall,
1910                AdvisorTriggerConfig::Pattern => {
1911                    GateTrigger::Pattern(gate_trigger_pattern.clone().unwrap_or_default())
1912                }
1913            };
1914            config.max_reviews = *max_reviews;
1915            config.gate_stall_turns = *gate_stall_turns;
1916            config.gate_min_tool_results = *gate_min_tool_results;
1917            config.advisor_max_tokens = *advisor_max_tokens;
1918            config.advisor_temperature = *advisor_temperature;
1919            config.transcript_max_chars = *transcript_max_chars;
1920            config.fail_open = *fail_open;
1921            let algorithm = AdvisorGate::new(config).map_err(|error| {
1922                AlgorithmConfigError::with_source(
1923                    format!("advisor route {route_name}: {error}"),
1924                    error,
1925                )
1926            })?;
1927            Ok(Arc::new(algorithm))
1928        }
1929        AlgorithmSpec::Vgr { config } => build_vgr(route_name, config, targets),
1930        AlgorithmSpec::PrefillRouter {
1931            targets: names,
1932            checkpoint,
1933            device,
1934            cache_dir,
1935            max_length,
1936            batch_size,
1937        } => {
1938            #[cfg(feature = "prefill-router")]
1939            {
1940                let targets = names
1941                    .iter()
1942                    .map(|name| resolve_target_model_id(route_name, name, targets))
1943                    .collect::<AlgorithmResult<Vec<_>>>()?;
1944                let mut config = prefill_router::PrefillRouterConfig::new(targets, checkpoint);
1945                config.device.clone_from(device);
1946                config.cache_dir.clone_from(cache_dir);
1947                if let Some(max_length) = max_length {
1948                    config.max_length = *max_length;
1949                }
1950                if let Some(batch_size) = batch_size {
1951                    config.batch_size = *batch_size;
1952                }
1953                let algorithm = config.build().map_err(|error| {
1954                    AlgorithmConfigError::with_source(
1955                        format!("prefill_router route {route_name}: {error}"),
1956                        error,
1957                    )
1958                })?;
1959                Ok(Arc::new(algorithm))
1960            }
1961
1962            #[cfg(not(feature = "prefill-router"))]
1963            {
1964                let _ = (
1965                    names, checkpoint, device, cache_dir, max_length, batch_size, targets,
1966                );
1967                Err(AlgorithmConfigError::new(format!(
1968                    "prefill_router route {route_name} requires the `prefill-router` Cargo feature"
1969                )))
1970            }
1971        }
1972    }
1973}
1974
1975fn build_vgr(
1976    route_name: &str,
1977    config: &VgrRouteConfig,
1978    targets: &BTreeMap<String, ModelId>,
1979) -> AlgorithmResult<Arc<dyn Algorithm>> {
1980    let efficient = resolve_target_model_id(route_name, &config.efficient_target, targets)?;
1981    let capable = resolve_target_model_id(route_name, &config.capable_target, targets)?;
1982    let optional = |name: &Option<String>| {
1983        name.as_deref()
1984            .map(|name| resolve_target_model_id(route_name, name, targets))
1985            .transpose()
1986    };
1987    let mut runtime = VgrConfig::new(efficient, capable);
1988    runtime.targets.judge = optional(&config.judge_target)?;
1989    runtime.targets.capable_judge = optional(&config.capable_judge_target)?;
1990    runtime.mode = match config.mode {
1991        VgrModeConfig::Off => libsy::ServingMode::Off,
1992        VgrModeConfig::Evaluate => libsy::ServingMode::Evaluate,
1993        VgrModeConfig::Shadow => libsy::ServingMode::Shadow,
1994        VgrModeConfig::Active => libsy::ServingMode::Active {
1995            approval: config.active_approval.clone().unwrap_or_default(),
1996        },
1997    };
1998    runtime.deadline = vgr_duration(route_name, "deadline_seconds", config.deadline_seconds)?;
1999    runtime.bounded_efficient = config.bounded_efficient;
2000    runtime.task_typing = config.task_typing;
2001    runtime.confirmed_recovery_min_clean_tail = config.confirmed_recovery_min_clean_tail;
2002    runtime.agentic_handoff = config.agentic_handoff;
2003    runtime.compact_handoff = config.compact_handoff;
2004    runtime.latch_escalation = config.latch_escalation;
2005    runtime.efficient_turn_budget = config
2006        .efficient_turn_budget_seconds
2007        .map(|seconds| vgr_duration(route_name, "efficient_turn_budget_seconds", seconds))
2008        .transpose()?;
2009    runtime.breaker = BreakerConfig {
2010        threshold: config.breaker_threshold,
2011        cooldown: vgr_duration(
2012            route_name,
2013            "breaker_cooldown_seconds",
2014            config.breaker_cooldown_seconds,
2015        )?,
2016    };
2017    Vgr::new(runtime)
2018        .map(|algorithm| Arc::new(algorithm) as Arc<dyn Algorithm>)
2019        .map_err(|error| {
2020            AlgorithmConfigError::with_source(format!("vgr route {route_name}: {error}"), error)
2021        })
2022}
2023
2024fn vgr_duration(route: &str, field: &str, seconds: f64) -> AlgorithmResult<std::time::Duration> {
2025    let message = || format!("vgr route {route}: {field} must be a positive number");
2026    let duration = std::time::Duration::try_from_secs_f64(seconds)
2027        .map_err(|error| AlgorithmConfigError::with_source(message(), error))?;
2028    if duration.is_zero() {
2029        return Err(AlgorithmConfigError::new(message()));
2030    }
2031    Ok(duration)
2032}
2033
2034const fn default_max_reviews() -> u32 {
2035    1
2036}
2037
2038const fn default_advisor_max_tokens() -> u64 {
2039    2048
2040}
2041
2042const fn default_transcript_max_chars() -> usize {
2043    200_000
2044}
2045
2046const fn default_fail_open() -> bool {
2047    true
2048}
2049
2050fn classifier_contract(prompt: Option<&str>) -> ClassifierContractConfig {
2051    prompt.map_or_else(ClassifierContractConfig::default, |prompt| {
2052        ClassifierContractConfig::default().with_prompt(prompt)
2053    })
2054}
2055
2056fn default_classifier_max_output_tokens() -> u64 {
2057    LlmCapabilityConfig::default().max_output_tokens
2058}
2059
2060/// Both tiers on one target leave the route nothing to choose between; every decision
2061/// lands on the same model. The file still loads, since a single-model deployment can
2062/// be a deliberate overhead measurement.
2063fn warn_identical_tiers(route_name: &str, capable_target: &str, efficient_target: &str) {
2064    if capable_target == efficient_target {
2065        tracing::warn!(
2066            route = route_name,
2067            target = capable_target,
2068            "capable_target and efficient_target name the same target, so every request goes to one model; use passthrough for a single model, or point the tiers at two targets"
2069        );
2070    }
2071}
2072
2073fn warn_single_target_classifier(route_name: &str, models: &CategoryModelConfig) {
2074    if models.routing_names().len() < 2 {
2075        tracing::warn!(
2076            route = route_name,
2077            "custom classifier has only one routing target; judge calls add cost without a routing choice. Use passthrough or add another completion target in routes.<name>, or in routes.<name>.subagents for a subagent classifier."
2078        );
2079    }
2080}
2081
2082const fn default_vgr_deadline() -> f64 {
2083    30.0
2084}
2085
2086const fn default_true() -> bool {
2087    true
2088}
2089
2090const fn default_vgr_breaker_threshold() -> u32 {
2091    5
2092}
2093
2094const fn default_vgr_breaker_cooldown() -> f64 {
2095    30.0
2096}
2097
2098fn resolve_target_model_id(
2099    route_name: &str,
2100    name: &str,
2101    targets: &BTreeMap<String, ModelId>,
2102) -> AlgorithmResult<ModelId> {
2103    targets.get(name).cloned().ok_or_else(|| {
2104        AlgorithmConfigError::new(format!(
2105            "route {route_name} references unknown target {name}"
2106        ))
2107    })
2108}
2109
2110fn resolve_runtime_models(
2111    route_name: &str,
2112    names: HashMap<Category, Vec<String>>,
2113    targets: &BTreeMap<String, ModelId>,
2114) -> AlgorithmResult<HashMap<Category, Vec<ModelId>>> {
2115    names
2116        .into_iter()
2117        .map(|(category, names)| {
2118            let models = names
2119                .into_iter()
2120                .map(|name| resolve_target_model_id(route_name, &name, targets))
2121                .collect::<AlgorithmResult<Vec<_>>>()?;
2122            Ok((category, models))
2123        })
2124        .collect()
2125}