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