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