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    #[serde(default = "default_true")]
544    pub task_typing: bool,
545    #[serde(default = "default_vgr_breaker_threshold")]
546    pub breaker_threshold: u32,
547    #[serde(default = "default_vgr_breaker_cooldown")]
548    pub breaker_cooldown_seconds: f64,
549    /// Clean trailing tool results that let a long run recover on the capable judge.
550    #[serde(default)]
551    pub confirmed_recovery_min_clean_tail: Option<u32>,
552    /// Tells the capable tier it inherits unverified tool-using work.
553    #[serde(default)]
554    pub agentic_handoff: bool,
555    /// Condenses history from the efficient tier at handoff. Requires `agentic_handoff`.
556    #[serde(default)]
557    pub compact_handoff: bool,
558    /// Wall-clock budget for the efficient tier within one user turn.
559    #[serde(default)]
560    pub efficient_turn_budget_seconds: Option<f64>,
561    /// Holds escalations across later user turns.
562    #[serde(default)]
563    pub latch_escalation: bool,
564}
565
566#[derive(Clone, Debug, Default, Deserialize)]
567#[serde(rename_all = "snake_case")]
568pub enum VgrModeConfig {
569    #[default]
570    Off,
571    Evaluate,
572    Shadow,
573    Active,
574}
575
576/// What fires an advisor route's review.
577#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
578#[serde(rename_all = "snake_case")]
579pub enum AdvisorTriggerConfig {
580    /// The executor's first turn without tool calls.
581    #[default]
582    NoToolCall,
583    /// The first turn whose text matches `gate_trigger_pattern`.
584    Pattern,
585}
586
587/// The judge a `stage_router` route falls through to, and how it routes.
588#[derive(Clone, Debug, Deserialize)]
589#[serde(deny_unknown_fields)]
590pub struct StageClassifierConfig {
591    /// Target the judge is called through. Not a routing destination.
592    pub target: String,
593    /// Composite accepts custom-schema judges as well as capability judges.
594    #[serde(default)]
595    pub mode: Option<ClassifierMode>,
596    #[serde(default)]
597    pub response_schema: Option<String>,
598    #[serde(default)]
599    pub policy: Option<ClassifierPolicyConfig>,
600    #[serde(default)]
601    pub decision: Option<DecisionJudgeRouteConfig>,
602    #[serde(default)]
603    pub fail_open: Option<bool>,
604    /// Lowest solve probability that still routes to the efficient tier, from 0 to 1.
605    pub base_threshold: Option<f64>,
606    /// How much to raise the threshold when the judge is uncertain. Added once
607    /// for an uncertain verdict and twice for unsupported.
608    #[serde(default)]
609    pub threshold_step: Option<f64>,
610    /// How often the judge runs. Composite honors it. A standalone stage route
611    /// rejects any value other than `every_request`, because its fallback judge
612    /// runs on every ambiguous turn and retains no verdict.
613    #[serde(default)]
614    pub classify_trigger: ClassifyTrigger,
615    /// Reuses the session's target by hashing the first user message when no
616    /// session ID is available.
617    #[serde(default)]
618    pub message_hash_fallback: bool,
619    /// How many trailing turns the judge sees. Unset shows it the opening task
620    /// and the latest user follow-up only.
621    #[serde(default)]
622    pub recent_turn_window: Option<usize>,
623    /// Replaces the packaged judge prompt.
624    #[serde(default)]
625    pub prompt: Option<String>,
626    /// How the judge is asked for structured output. Use `json_object` when the
627    /// provider cannot do JSON Schema.
628    #[serde(default)]
629    pub response_format_type: ClassifierResponseFormat,
630    /// Most completion tokens the judge verdict may use.
631    #[serde(default = "default_classifier_max_output_tokens")]
632    pub max_output_tokens: u64,
633}
634
635/// The tier pair and scoring settings shared by every stage-router-backed route.
636///
637/// `deny_unknown_fields` here is what rejects a typo on a flattened `stage_router`
638/// route, since the enum's own `deny_unknown_fields` does not apply through a flatten.
639#[derive(Clone, Debug, Deserialize)]
640#[serde(deny_unknown_fields)]
641pub struct StageTierConfig {
642    /// The capable tier.
643    pub capable_target: String,
644    /// The efficient tier.
645    pub efficient_target: String,
646    /// How much agreement a decisive pick needs, from 0 to 1.
647    #[serde(default, alias = "efficient_first_threshold")]
648    pub confidence_threshold: Option<f64>,
649    /// Threshold after an outer classifier selects capable-first. Defaults to `0.0`.
650    #[serde(default)]
651    pub capable_first_threshold: f64,
652    /// How many trailing tool results the signals are scored over.
653    #[serde(default)]
654    pub recent_turn_window: Option<usize>,
655    /// Exact tool-name semantics added to the built-in stage vocabulary.
656    #[serde(default)]
657    pub tool_semantics: ToolSemantics,
658    /// Requests to keep on the capable tier after an escalation.
659    #[serde(default)]
660    pub capable_hold_turns: Option<i64>,
661    /// Requests to keep on efficient after a switch. Defaults to `-1` for
662    /// composite's capable-first policy, otherwise `0`. `-1` pins it.
663    #[serde(default)]
664    pub efficient_hold_turns: Option<i64>,
665    /// Notes handed to a tier when the router switches to it.
666    #[serde(default)]
667    pub handoff_notes: Option<HandoffNoteConfig>,
668}
669
670impl StageTierConfig {
671    /// Warns when both tiers name one target; see [`warn_identical_tiers`].
672    fn warn_identical_tiers(&self, route_name: &str) {
673        warn_identical_tiers(route_name, &self.capable_target, &self.efficient_target);
674    }
675}
676
677impl StageClassifierConfig {
678    fn task_classifier_config(
679        &self,
680        route_name: &str,
681        tiers: &StageTierConfig,
682        targets: &BTreeMap<String, ModelId>,
683    ) -> AlgorithmResult<TaskClassifierConfig> {
684        if self
685            .mode
686            .is_some_and(|mode| !matches!(mode, ClassifierMode::Capability))
687            || self.response_schema.is_some()
688            || self.policy.is_some()
689        {
690            return Err(AlgorithmConfigError::new(
691                "custom classifier settings require composite with mode = custom",
692            ));
693        }
694        let config = LlmClassifierRouteConfig {
695            classifier_target: self.target.clone(),
696            strong_target: Some(tiers.capable_target.clone()),
697            weak_target: Some(tiers.efficient_target.clone()),
698            base_threshold: self.base_threshold,
699            threshold_step: self.threshold_step,
700            fail_open: self.fail_open,
701            decision: self.decision.clone(),
702            classify_trigger: self.classify_trigger,
703            message_hash_fallback: self.message_hash_fallback,
704            recent_turn_window: self.recent_turn_window,
705            prompt: self.prompt.clone(),
706            response_format_type: self.response_format_type,
707            max_output_tokens: self.max_output_tokens,
708            ..Default::default()
709        };
710        match config.validated_classifier_mode(route_name)? {
711            LlmClassifierModeConfig::Capability(config) => {
712                config.into_task_config(route_name, targets)
713            }
714            _ => Err(AlgorithmConfigError::new("expected a capability judge")),
715        }
716    }
717
718    fn custom_classifier_config(
719        &self,
720        route_name: &str,
721    ) -> AlgorithmResult<CustomClassifierConfig> {
722        if self.fail_open == Some(false) {
723            return Err(AlgorithmConfigError::new(
724                "composite custom classifiers always fail open; remove fail_open = false",
725            ));
726        }
727        if self.decision.is_some()
728            || self.base_threshold.is_some()
729            || self.threshold_step.is_some()
730            || self.response_format_type != ClassifierResponseFormat::JsonSchema
731        {
732            return Err(AlgorithmConfigError::new(
733                "composite custom classifier cannot use capability thresholds, decision settings, or response_format_type",
734            ));
735        }
736        let schema =
737            required_classifier_field(route_name, "response_schema", &self.response_schema)?;
738        let schema = serde_json::from_str(&schema).map_err(|error| {
739            AlgorithmConfigError::with_source(
740                format!("composite route {route_name}: response_schema is invalid JSON"),
741                error,
742            )
743        })?;
744        let mut config = CustomClassifierConfig::new(
745            required_classifier_field(route_name, "prompt", &self.prompt)?,
746            schema,
747            required_classifier_field(route_name, "policy", &self.policy)?.into_libsy(),
748        );
749        config.classify_trigger = self.classify_trigger;
750        config.message_hash_fallback = self.message_hash_fallback;
751        config.recent_turn_window = self.recent_turn_window;
752        config.max_output_tokens = self.max_output_tokens;
753        Ok(config)
754    }
755}
756
757impl AlgorithmSpec {
758    pub(crate) fn decision_judge(&self) -> Option<(&str, &DecisionJudgeRouteConfig)> {
759        match self {
760            Self::LlmClassifier { config, .. } => config
761                .decision
762                .as_ref()
763                .map(|decision| (config.classifier_target.as_str(), decision)),
764            Self::Composite { classifier, .. }
765            | Self::StageRouter {
766                classifier: Some(classifier),
767                ..
768            } => classifier
769                .decision
770                .as_ref()
771                .map(|decision| (classifier.target.as_str(), decision)),
772            _ => None,
773        }
774    }
775
776    /// Completion targets in algorithm order; judge-only targets are excluded.
777    pub fn routing_target_names(&self) -> Vec<&str> {
778        match self {
779            Self::Noop { .. } => Vec::new(),
780            Self::Random { targets, .. } => targets.iter().map(String::as_str).collect(),
781            Self::Passthrough {
782                target, subagents, ..
783            } => {
784                let mut names = vec![target.as_str()];
785                if let Some(subagents) = subagents {
786                    names.extend(subagents.routing_target_names());
787                }
788                names
789            }
790            Self::PlanExecute {
791                capable_target,
792                efficient_target,
793                ..
794            } => vec![capable_target.as_str(), efficient_target.as_str()],
795            Self::LlmClassifier { config, .. } => match config.classifier_mode() {
796                ClassifierMode::Capability => config
797                    .weak_target
798                    .iter()
799                    .chain(&config.strong_target)
800                    .map(String::as_str)
801                    .collect(),
802                ClassifierMode::Escalation => config
803                    .strong_target
804                    .iter()
805                    .chain(&config.weak_target)
806                    .map(String::as_str)
807                    .collect(),
808                ClassifierMode::Custom => config
809                    .models
810                    .as_ref()
811                    .map(CategoryModelConfig::routing_names)
812                    .unwrap_or_default(),
813            },
814            Self::StageRouter {
815                tiers, subagents, ..
816            } => {
817                let mut names = vec![
818                    tiers.capable_target.as_str(),
819                    tiers.efficient_target.as_str(),
820                ];
821                if let Some(subagents) = subagents {
822                    names.extend(subagents.routing_target_names());
823                }
824                names
825            }
826            Self::Auto {
827                capable_target,
828                efficient_target,
829            } => vec![capable_target.as_str(), efficient_target.as_str()],
830            Self::Composite {
831                stage, subagents, ..
832            } => {
833                let mut names = vec![
834                    stage.capable_target.as_str(),
835                    stage.efficient_target.as_str(),
836                ];
837                if let Some(subagents) = subagents {
838                    names.extend(subagents.routing_target_names());
839                }
840                names
841            }
842            // The advisor is judge-only: reviews go through its own client,
843            // so it is not a completion (or count_tokens) destination.
844            Self::Advisor {
845                executor_target, ..
846            } => vec![executor_target],
847            Self::Vgr { config } => {
848                vec![
849                    config.efficient_target.as_str(),
850                    config.capable_target.as_str(),
851                ]
852            }
853            Self::PrefillRouter { targets, .. } => targets.iter().map(String::as_str).collect(),
854        }
855    }
856
857    /// Every target the algorithm may call, including judge-only targets.
858    ///
859    /// [`routing_target_names`](Self::routing_target_names) covers completion destinations;
860    /// a classifier also calls its judge, and that call needs a client too.
861    pub fn callable_target_names(&self) -> Vec<&str> {
862        let mut names = self.routing_target_names();
863        names.extend(self.judge_target_names());
864        names
865    }
866
867    /// Targets the algorithm calls only to decide, such as a judge or an advisor. A target
868    /// that also answers requests appears in [`routing_target_names`](Self::routing_target_names) too.
869    pub fn judge_target_names(&self) -> Vec<&str> {
870        let mut names = Vec::new();
871        match self {
872            // Custom mode names its judge in `models.judge`; the other two modes
873            // use the top-level `classifier_target`.
874            Self::LlmClassifier { config, .. } => {
875                if matches!(config.classifier_mode(), ClassifierMode::Custom) {
876                    names.extend(
877                        config
878                            .models
879                            .as_ref()
880                            .map(|models| models.get(&Category::Judge))
881                            .unwrap_or_default()
882                            .iter()
883                            .map(String::as_str),
884                    );
885                } else {
886                    names.push(config.classifier_target.as_str());
887                }
888            }
889            Self::StageRouter {
890                classifier: Some(classifier),
891                ..
892            } => names.push(&classifier.target),
893            Self::Composite { classifier, .. } => {
894                names.push(&classifier.target);
895            }
896            Self::Advisor { advisor_target, .. } => names.push(advisor_target),
897            Self::Vgr { config } => {
898                names.extend(config.judge_target.as_deref());
899                names.extend(config.capable_judge_target.as_deref());
900            }
901            _ => {}
902        }
903        // A sub-agent classifier calls its own judge, which is never a completion target.
904        if let Self::Passthrough {
905            subagents: Some(subagents),
906            ..
907        }
908        | Self::StageRouter {
909            subagents: Some(subagents),
910            ..
911        }
912        | Self::Composite {
913            subagents: Some(subagents),
914            ..
915        } = self
916        {
917            names.extend(subagents.judge_target_names());
918        }
919        names
920    }
921
922    /// Target names grouped as the runtime [`Driver`](libsy::Driver) expects them.
923    fn runtime_model_names(&self, route_name: &str) -> AlgorithmResult<RuntimeModelNames> {
924        let parent = match self {
925            Self::Noop { .. } => HashMap::new(),
926            Self::Random { targets, .. } | Self::PrefillRouter { targets, .. } => {
927                category_models([(Category::Any, targets.clone())])
928            }
929            Self::Passthrough { target, .. } => {
930                category_models([(Category::Any, vec![target.clone()])])
931            }
932            Self::PlanExecute {
933                capable_target,
934                efficient_target,
935                ..
936            } => category_models([
937                (Category::Capable, vec![capable_target.clone()]),
938                (Category::Efficient, vec![efficient_target.clone()]),
939                (
940                    Category::Any,
941                    vec![capable_target.clone(), efficient_target.clone()],
942                ),
943            ]),
944            Self::LlmClassifier { config } => {
945                classifier_runtime_model_names(config.validated_classifier_mode(route_name)?)
946            }
947            Self::StageRouter {
948                tiers, classifier, ..
949            } => {
950                let mut models = category_models([
951                    (Category::Capable, vec![tiers.capable_target.clone()]),
952                    (Category::Efficient, vec![tiers.efficient_target.clone()]),
953                    (
954                        Category::Any,
955                        vec![tiers.capable_target.clone(), tiers.efficient_target.clone()],
956                    ),
957                ]);
958                if let Some(classifier) = classifier {
959                    models.insert(Category::Judge, vec![classifier.target.clone()]);
960                }
961                models
962            }
963            Self::Auto {
964                capable_target,
965                efficient_target,
966            } => category_models([
967                (Category::Capable, vec![capable_target.clone()]),
968                (Category::Efficient, vec![efficient_target.clone()]),
969                (
970                    Category::Any,
971                    vec![capable_target.clone(), efficient_target.clone()],
972                ),
973            ]),
974            Self::Composite {
975                classifier, stage, ..
976            } => category_models([
977                (Category::Judge, vec![classifier.target.clone()]),
978                (Category::Capable, vec![stage.capable_target.clone()]),
979                (Category::Efficient, vec![stage.efficient_target.clone()]),
980                (
981                    Category::Any,
982                    vec![stage.capable_target.clone(), stage.efficient_target.clone()],
983                ),
984            ]),
985            Self::Advisor {
986                executor_target,
987                advisor_target,
988                ..
989            } => category_models([
990                (Category::Efficient, vec![executor_target.clone()]),
991                (Category::Any, vec![executor_target.clone()]),
992                (Category::Judge, vec![advisor_target.clone()]),
993            ]),
994            Self::Vgr { config } => {
995                let judge = config
996                    .judge_target
997                    .as_ref()
998                    .unwrap_or(&config.efficient_target);
999                let judges = std::iter::once(judge)
1000                    .chain(&config.capable_judge_target)
1001                    .cloned()
1002                    .collect();
1003                category_models([
1004                    (Category::Efficient, vec![config.efficient_target.clone()]),
1005                    (Category::Capable, vec![config.capable_target.clone()]),
1006                    (
1007                        Category::Any,
1008                        vec![
1009                            config.efficient_target.clone(),
1010                            config.capable_target.clone(),
1011                        ],
1012                    ),
1013                    (Category::Judge, judges),
1014                ])
1015            }
1016        };
1017
1018        let subagents = match self {
1019            Self::Passthrough { subagents, .. }
1020            | Self::StageRouter { subagents, .. }
1021            | Self::Composite { subagents, .. } => subagents.as_ref(),
1022            _ => None,
1023        };
1024        // Sub-agent groups stay separate from the parent's. Merging them would let a
1025        // sub-agent's `capable` resolve to the parent's, and would put models only the
1026        // sub-agents were given into the parent's `Any` fallback list.
1027        let subagent = subagents
1028            .map(|subagents| subagent_runtime_model_names(subagents, route_name))
1029            .transpose()?;
1030        Ok(RuntimeModelNames { parent, subagent })
1031    }
1032
1033    /// Response target and routing-only dependency for routers that answer while routing.
1034    pub(crate) fn routing_response_and_dependency(&self) -> Option<(&str, &str)> {
1035        match self {
1036            Self::LlmClassifier { config, .. }
1037                if matches!(config.classifier_mode(), ClassifierMode::Escalation) =>
1038            {
1039                Some((
1040                    config.weak_target.as_deref()?,
1041                    config.classifier_target.as_str(),
1042                ))
1043            }
1044            Self::Advisor {
1045                executor_target,
1046                advisor_target,
1047                ..
1048            } => Some((executor_target, advisor_target)),
1049            Self::Vgr { config } => Some((
1050                &config.efficient_target,
1051                config
1052                    .judge_target
1053                    .as_deref()
1054                    .unwrap_or(&config.efficient_target),
1055            )),
1056            Self::Noop { .. }
1057            | Self::Random { .. }
1058            | Self::Passthrough { .. }
1059            | Self::PlanExecute { .. }
1060            | Self::LlmClassifier { .. }
1061            | Self::StageRouter { .. }
1062            | Self::Auto { .. }
1063            | Self::Composite { .. }
1064            | Self::PrefillRouter { .. } => None,
1065        }
1066    }
1067
1068    /// Builds this algorithm after resolving configured target names.
1069    pub fn build(
1070        &self,
1071        route_name: &str,
1072        targets: &BTreeMap<String, ModelId>,
1073    ) -> AlgorithmResult<Arc<dyn Algorithm>> {
1074        build_algorithm(route_name, self, targets)
1075    }
1076
1077    /// Resolves target names to host model IDs and builds the algorithm and runtime groups.
1078    pub fn build_with_runtime_models(
1079        &self,
1080        route_name: &str,
1081        targets: &BTreeMap<String, ModelId>,
1082    ) -> AlgorithmResult<(Arc<dyn Algorithm>, RuntimeModels)> {
1083        let algorithm = self.build(route_name, targets)?;
1084        let names = self.runtime_model_names(route_name)?;
1085        let mut models =
1086            RuntimeModels::new(resolve_runtime_models(route_name, names.parent, targets)?);
1087        if let Some(subagent) = names.subagent {
1088            models = models.with_subagent(resolve_runtime_models(route_name, subagent, targets)?);
1089        }
1090        Ok((algorithm, models))
1091    }
1092}
1093
1094/// One route's target names, grouped by category and by routing scope.
1095struct RuntimeModelNames {
1096    /// Groups the algorithm itself routes over.
1097    parent: HashMap<Category, Vec<String>>,
1098    /// Groups delegated sub-agent work routes over, when the route has a `subagents` table.
1099    subagent: Option<HashMap<Category, Vec<String>>>,
1100}
1101
1102fn category_models(
1103    entries: impl IntoIterator<Item = (Category, Vec<String>)>,
1104) -> HashMap<Category, Vec<String>> {
1105    entries.into_iter().collect()
1106}
1107
1108fn custom_runtime_model_names(config: &CategoryModelConfig) -> HashMap<Category, Vec<String>> {
1109    config.groups().collect()
1110}
1111
1112fn classifier_runtime_model_names(
1113    config: LlmClassifierModeConfig,
1114) -> HashMap<Category, Vec<String>> {
1115    match config {
1116        LlmClassifierModeConfig::Capability(config) => category_models([
1117            (Category::Judge, vec![config.classifier_target]),
1118            (Category::Efficient, vec![config.weak_target.clone()]),
1119            (Category::Capable, vec![config.strong_target.clone()]),
1120            (
1121                Category::Any,
1122                vec![config.weak_target, config.strong_target],
1123            ),
1124        ]),
1125        LlmClassifierModeConfig::Escalation(config) => category_models([
1126            (Category::Judge, vec![config.classifier_target]),
1127            (Category::Efficient, vec![config.weak_target.clone()]),
1128            (Category::Capable, vec![config.strong_target.clone()]),
1129            (
1130                Category::Any,
1131                vec![config.strong_target, config.weak_target],
1132            ),
1133        ]),
1134        LlmClassifierModeConfig::Custom(config) => custom_runtime_model_names(&config.models),
1135    }
1136}
1137
1138fn subagent_runtime_model_names(
1139    config: &SubagentRouteConfig,
1140    route_name: &str,
1141) -> AlgorithmResult<HashMap<Category, Vec<String>>> {
1142    match config {
1143        SubagentRouteConfig::Passthrough { target } => {
1144            Ok(category_models([(Category::Any, vec![target.clone()])]))
1145        }
1146        SubagentRouteConfig::LlmClassifier(config) => {
1147            let LlmClassifierModeConfig::Custom(config) =
1148                config.validated_classifier_mode(route_name)?
1149            else {
1150                return Err(AlgorithmConfigError::new(format!(
1151                    "route {route_name}: subagents llm_classifier only supports mode custom"
1152                )));
1153            };
1154            Ok(custom_runtime_model_names(&config.models))
1155        }
1156    }
1157}
1158impl LlmClassifierRouteConfig {
1159    fn classifier_mode(&self) -> ClassifierMode {
1160        let default = if self.escalation.is_some() {
1161            ClassifierMode::Escalation
1162        } else {
1163            ClassifierMode::Capability
1164        };
1165        self.mode.unwrap_or(default)
1166    }
1167
1168    fn validated_classifier_mode(
1169        &self,
1170        route_name: &str,
1171    ) -> AlgorithmResult<LlmClassifierModeConfig> {
1172        let Self {
1173            classifier_target,
1174            mode,
1175            strong_target,
1176            weak_target,
1177            base_threshold,
1178            threshold_step,
1179            fail_open,
1180            classify_trigger,
1181            message_hash_fallback,
1182            recent_turn_window,
1183            prompt,
1184            response_format_type,
1185            max_output_tokens,
1186            escalation,
1187            decision,
1188            models,
1189            default_target,
1190            response_schema,
1191            policy,
1192        } = self;
1193
1194        let selected_mode = match (mode, escalation.is_some()) {
1195            (Some(mode), _) => *mode,
1196            (None, true) => ClassifierMode::Escalation,
1197            (None, false) => ClassifierMode::Capability,
1198        };
1199
1200        if decision.is_some() && !matches!(selected_mode, ClassifierMode::Capability) {
1201            return Err(AlgorithmConfigError::new(format!(
1202                "llm_classifier route {route_name}: decision requires capability mode"
1203            )));
1204        }
1205
1206        if !matches!(selected_mode, ClassifierMode::Capability) && *fail_open == Some(true) {
1207            return Err(AlgorithmConfigError::new(format!(
1208                "llm_classifier route {route_name}: fail_open = true requires capability mode"
1209            )));
1210        }
1211
1212        match selected_mode {
1213            ClassifierMode::Capability => {
1214                if escalation.is_some() {
1215                    return Err(classifier_field_error(
1216                        route_name,
1217                        "escalation",
1218                        "capability",
1219                    ));
1220                }
1221                reject_custom_fields(
1222                    route_name,
1223                    "capability",
1224                    models,
1225                    default_target,
1226                    response_schema,
1227                    policy,
1228                )?;
1229                let judge = if let Some(decision) = decision {
1230                    if base_threshold.is_some()
1231                        || threshold_step.is_some()
1232                        || prompt.is_some()
1233                        || *response_format_type != ClassifierResponseFormat::JsonSchema
1234                        || *max_output_tokens != default_classifier_max_output_tokens()
1235                    {
1236                        return Err(AlgorithmConfigError::new(format!(
1237                            "llm_classifier route {route_name}: decision cannot use LLM judge settings; use decision.cutoff and decision.instructions"
1238                        )));
1239                    }
1240                    CapabilityJudgeRouteConfig::Decision(decision.clone())
1241                } else {
1242                    CapabilityJudgeRouteConfig::Llm(LlmCapabilityConfig {
1243                        base_threshold: required_classifier_field(
1244                            route_name,
1245                            "base_threshold",
1246                            base_threshold,
1247                        )?,
1248                        threshold_step: threshold_step.unwrap_or_default(),
1249                        contract: classifier_contract(prompt.as_deref())
1250                            .with_response_format_type(*response_format_type),
1251                        max_output_tokens: *max_output_tokens,
1252                    })
1253                };
1254                Ok(LlmClassifierModeConfig::Capability(
1255                    CapabilityClassifierRouteConfig {
1256                        classifier_target: classifier_target.clone(),
1257                        fail_open: fail_open.unwrap_or(true),
1258                        strong_target: required_classifier_field(
1259                            route_name,
1260                            "strong_target",
1261                            strong_target,
1262                        )?,
1263                        weak_target: required_classifier_field(
1264                            route_name,
1265                            "weak_target",
1266                            weak_target,
1267                        )?,
1268                        judge,
1269                        classify_trigger: *classify_trigger,
1270                        message_hash_fallback: *message_hash_fallback,
1271                        recent_turn_window: *recent_turn_window,
1272                    },
1273                ))
1274            }
1275            ClassifierMode::Escalation => {
1276                reject_custom_fields(
1277                    route_name,
1278                    "escalation",
1279                    models,
1280                    default_target,
1281                    response_schema,
1282                    policy,
1283                )?;
1284                if *classify_trigger != ClassifyTrigger::EveryRequest {
1285                    return Err(AlgorithmConfigError::new(format!(
1286                        "llm_classifier route {route_name} mode escalation cannot use classify_trigger"
1287                    )));
1288                }
1289                let has_capability_settings = base_threshold.is_some()
1290                    || threshold_step.is_some()
1291                    || fail_open.is_some()
1292                    || *message_hash_fallback
1293                    || recent_turn_window.is_some();
1294                if mode.is_some() && has_capability_settings {
1295                    return Err(AlgorithmConfigError::new(format!(
1296                        "llm_classifier route {route_name} mode escalation cannot use capability routing settings"
1297                    )));
1298                }
1299                if has_capability_settings {
1300                    // Existing files that select escalation through the `escalation` table alone
1301                    // keep loading, but their capability keys do nothing in this mode.
1302                    tracing::warn!(
1303                        "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."
1304                    );
1305                }
1306                Ok(LlmClassifierModeConfig::Escalation(
1307                    EscalationClassifierRouteConfig {
1308                        classifier_target: classifier_target.clone(),
1309                        strong_target: required_classifier_field(
1310                            route_name,
1311                            "strong_target",
1312                            strong_target,
1313                        )?,
1314                        weak_target: required_classifier_field(
1315                            route_name,
1316                            "weak_target",
1317                            weak_target,
1318                        )?,
1319                        prompt: prompt.clone(),
1320                        response_format_type: *response_format_type,
1321                        max_output_tokens: *max_output_tokens,
1322                        judge: required_classifier_field(route_name, "escalation", escalation)?,
1323                    },
1324                ))
1325            }
1326            ClassifierMode::Custom => {
1327                if !classifier_target.is_empty()
1328                    || strong_target.is_some()
1329                    || weak_target.is_some()
1330                    || base_threshold.is_some()
1331                    || threshold_step.is_some()
1332                    || escalation.is_some()
1333                    || *response_format_type != ClassifierResponseFormat::JsonSchema
1334                {
1335                    return Err(AlgorithmConfigError::new(format!(
1336                        "llm_classifier route {route_name} mode custom cannot use capability or escalation fields and response_format_type must be 'json_schema'"
1337                    )));
1338                }
1339                let models = required_classifier_field(route_name, "models", models)?;
1340                let default_target: Category = required_classifier_field(
1341                    route_name,
1342                    "default_target",
1343                    default_target,
1344                )?
1345                .parse()
1346                .map_err(|error| {
1347                    AlgorithmConfigError::new(format!(
1348                        "llm_classifier route {route_name} has invalid default_target: {error}"
1349                    ))
1350                })?;
1351                if default_target == Category::Judge {
1352                    return Err(AlgorithmConfigError::new(format!(
1353                        "llm_classifier route {route_name} default_target cannot be judge"
1354                    )));
1355                }
1356                models.validate(route_name, &default_target)?;
1357                Ok(LlmClassifierModeConfig::Custom(
1358                    CustomClassifierRouteConfig {
1359                        models,
1360                        default_target,
1361                        prompt: required_classifier_field(route_name, "prompt", prompt)?,
1362                        response_schema: required_classifier_field(
1363                            route_name,
1364                            "response_schema",
1365                            response_schema,
1366                        )?,
1367                        policy: required_classifier_field(route_name, "policy", policy)?,
1368                        classify_trigger: *classify_trigger,
1369                        message_hash_fallback: *message_hash_fallback,
1370                        recent_turn_window: *recent_turn_window,
1371                        max_output_tokens: *max_output_tokens,
1372                    },
1373                ))
1374            }
1375        }
1376    }
1377}
1378
1379fn reject_custom_fields(
1380    route_name: &str,
1381    mode: &str,
1382    models: &Option<CategoryModelConfig>,
1383    default_target: &Option<String>,
1384    response_schema: &Option<String>,
1385    policy: &Option<ClassifierPolicyConfig>,
1386) -> AlgorithmResult<()> {
1387    if models.is_some() || default_target.is_some() || response_schema.is_some() || policy.is_some()
1388    {
1389        return Err(AlgorithmConfigError::new(format!(
1390            "llm_classifier route {route_name} mode {mode} cannot use custom classifier fields"
1391        )));
1392    }
1393    Ok(())
1394}
1395
1396fn classifier_field_error(route_name: &str, field: &str, mode: &str) -> AlgorithmConfigError {
1397    AlgorithmConfigError::new(format!(
1398        "llm_classifier route {route_name} mode {mode} cannot use {field}"
1399    ))
1400}
1401
1402fn required_classifier_field<T: Clone>(
1403    route_name: &str,
1404    field: &str,
1405    value: &Option<T>,
1406) -> AlgorithmResult<T> {
1407    value.clone().ok_or_else(|| {
1408        AlgorithmConfigError::new(format!(
1409            "llm_classifier route {route_name} requires {field}"
1410        ))
1411    })
1412}
1413
1414fn build_subagent_router_config(
1415    route_name: &str,
1416    config: &SubagentRouteConfig,
1417    targets: &BTreeMap<String, ModelId>,
1418) -> AlgorithmResult<SubagentRouterConfig> {
1419    match config {
1420        SubagentRouteConfig::Passthrough { target } => {
1421            resolve_target_model_id(route_name, target, targets)?;
1422            Ok(SubagentRouterConfig::fixed_target())
1423        }
1424        SubagentRouteConfig::LlmClassifier(config) => {
1425            let LlmClassifierModeConfig::Custom(config) =
1426                config.validated_classifier_mode(route_name)?
1427            else {
1428                return Err(AlgorithmConfigError::new(format!(
1429                    "route {route_name}: subagents llm_classifier only supports mode custom"
1430                )));
1431            };
1432            for name in config.models.all_names() {
1433                resolve_target_model_id(route_name, name, targets)?;
1434            }
1435            if config.models.get(&config.default_target).is_empty() {
1436                return Err(AlgorithmConfigError::new(format!(
1437                    "route {route_name}: subagents llm_classifier has no model for default category {}",
1438                    config.default_target.as_str()
1439                )));
1440            }
1441            let response_schema =
1442                serde_json::from_str(&config.response_schema).map_err(|error| {
1443                    AlgorithmConfigError::with_source(
1444                        format!(
1445                            "route {route_name}: subagents llm_classifier response_schema is invalid JSON: {error}"
1446                        ),
1447                        error,
1448                    )
1449                })?;
1450            warn_single_target_classifier(route_name, &config.models);
1451            let mut classifier_config = CustomClassifierConfig::new(
1452                config.prompt,
1453                response_schema,
1454                config.policy.into_libsy(),
1455            );
1456            classifier_config.recent_turn_window = config.recent_turn_window;
1457            classifier_config.max_output_tokens = config.max_output_tokens;
1458            let classifier = Arc::new(
1459                LlmTaskClassifier::new(LlmClassifierConfig::Custom {
1460                    default_target: config.default_target.clone(),
1461                    config: classifier_config,
1462                })
1463                .map_err(|error| {
1464                    AlgorithmConfigError::with_source(
1465                        format!("route {route_name}: subagents llm_classifier: {error}"),
1466                        error,
1467                    )
1468                })?,
1469            );
1470            Ok(SubagentRouterConfig {
1471                classifier,
1472                default_target: config.default_target,
1473                classify_trigger: config.classify_trigger,
1474                message_hash_fallback: config.message_hash_fallback,
1475            })
1476        }
1477    }
1478}
1479
1480fn attach_subagent_router(
1481    route_name: &str,
1482    parent: Arc<dyn Algorithm>,
1483    config: Option<&SubagentRouteConfig>,
1484    targets: &BTreeMap<String, ModelId>,
1485) -> AlgorithmResult<Arc<dyn Algorithm>> {
1486    let Some(config) = config else {
1487        return Ok(parent);
1488    };
1489    let config = build_subagent_router_config(route_name, config, targets)?;
1490    let algorithm = SubagentRouter::new(parent, config).map_err(|error| {
1491        AlgorithmConfigError::with_source(
1492            format!("route {route_name}: subagent routing: {error}"),
1493            error,
1494        )
1495    })?;
1496    Ok(Arc::new(algorithm))
1497}
1498
1499fn build_algorithm(
1500    route_name: &str,
1501    config: &AlgorithmSpec,
1502    targets: &BTreeMap<String, ModelId>,
1503) -> AlgorithmResult<Arc<dyn Algorithm>> {
1504    match config {
1505        AlgorithmSpec::Noop { .. } => Ok(Arc::new(Noop {})),
1506        AlgorithmSpec::Random {
1507            targets: names,
1508            weights,
1509            seed,
1510        } => {
1511            // The algorithm only sees its targets at request time, so a bad pairing
1512            // would otherwise fail every request instead of failing to start.
1513            if let Some(weights) = weights
1514                && weights.len() != names.len()
1515            {
1516                return Err(AlgorithmConfigError::new(format!(
1517                    "random route {route_name}: expected {} weights, got {}",
1518                    names.len(),
1519                    weights.len()
1520                )));
1521            }
1522            let mut seen = BTreeSet::new();
1523            if let Some(duplicate) = names.iter().find(|name| !seen.insert(*name)) {
1524                return Err(AlgorithmConfigError::new(format!(
1525                    "random route {route_name}: targets must be unique, {duplicate} is repeated"
1526                )));
1527            }
1528            let algorithm = Random::new(weights.clone(), *seed).map_err(|error| {
1529                AlgorithmConfigError::with_source(
1530                    format!("random route {route_name}: {error}"),
1531                    error,
1532                )
1533            })?;
1534            Ok(Arc::new(algorithm))
1535        }
1536        AlgorithmSpec::Passthrough { subagents, .. } => {
1537            let algorithm = Passthrough;
1538            let parent: Arc<dyn Algorithm> = Arc::new(algorithm);
1539            attach_subagent_router(route_name, parent, subagents.as_ref(), targets)
1540        }
1541        AlgorithmSpec::PlanExecute {
1542            tool_semantics,
1543            planning_prompt,
1544            handoff_prompt,
1545            planner_reasoning_as_text,
1546            ..
1547        } => {
1548            let mut config = PlanExecuteConfig::default();
1549            config.tool_semantics.clone_from(tool_semantics);
1550            if let Some(prompt) = planning_prompt {
1551                config.planning_prompt = prompt.clone();
1552            }
1553            config.handoff_prompt.clone_from(handoff_prompt);
1554            config.planner_reasoning_as_text = *planner_reasoning_as_text;
1555            let algorithm = PlanExecute::new(config).map_err(|error| {
1556                AlgorithmConfigError::with_source(
1557                    format!("plan_execute route {route_name}: {error}"),
1558                    error,
1559                )
1560            })?;
1561            Ok(Arc::new(algorithm))
1562        }
1563        AlgorithmSpec::LlmClassifier {
1564            config: classifier_config,
1565            ..
1566        } => {
1567            let mode = classifier_config.validated_classifier_mode(route_name)?;
1568            let algorithm = match mode {
1569                LlmClassifierModeConfig::Capability(config) => {
1570                    let classifier_config = config.into_task_config(route_name, targets)?;
1571                    LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1572                        config: classifier_config,
1573                    })
1574                }
1575                LlmClassifierModeConfig::Escalation(config) => {
1576                    LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
1577                        contract: classifier_contract(config.prompt.as_deref())
1578                            .with_response_format_type(config.response_format_type),
1579                        config: config.judge,
1580                        max_output_tokens: config.max_output_tokens,
1581                    })
1582                }
1583                LlmClassifierModeConfig::Custom(config) => {
1584                    let response_schema = serde_json::from_str(&config.response_schema).map_err(
1585                        |error| {
1586                            AlgorithmConfigError::with_source(
1587                                format!(
1588                                    "llm_classifier route {route_name}: response_schema is invalid JSON: {error}"
1589                                ),
1590                                error,
1591                            )
1592                        },
1593                    )?;
1594                    warn_single_target_classifier(route_name, &config.models);
1595                    let mut classifier_config = CustomClassifierConfig::new(
1596                        config.prompt,
1597                        response_schema,
1598                        config.policy.into_libsy(),
1599                    );
1600                    classifier_config.classify_trigger = config.classify_trigger;
1601                    classifier_config.message_hash_fallback = config.message_hash_fallback;
1602                    classifier_config.recent_turn_window = config.recent_turn_window;
1603                    classifier_config.max_output_tokens = config.max_output_tokens;
1604                    LlmTaskClassifier::new(LlmClassifierConfig::Custom {
1605                        default_target: config.default_target,
1606                        config: classifier_config,
1607                    })
1608                }
1609            }
1610            .map_err(|error| {
1611                AlgorithmConfigError::with_source(
1612                    format!("llm_classifier route {route_name}: {error}"),
1613                    error,
1614                )
1615            })?;
1616            Ok(Arc::new(algorithm))
1617        }
1618        AlgorithmSpec::StageRouter {
1619            tiers,
1620            picker,
1621            classifier,
1622            subagents,
1623            ..
1624        } => {
1625            let StageTierConfig {
1626                confidence_threshold,
1627                recent_turn_window,
1628                tool_semantics,
1629                capable_hold_turns,
1630                efficient_hold_turns,
1631                handoff_notes,
1632                ..
1633            } = tiers;
1634            if matches!(picker, PickerMode::CapableFirst) {
1635                tracing::warn!(
1636                    "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."
1637                );
1638            }
1639            tiers.warn_identical_tiers(route_name);
1640            let threshold = confidence_threshold.ok_or_else(|| {
1641                AlgorithmConfigError::new(format!(
1642                    "stage_router route {route_name}: confidence_threshold is required"
1643                ))
1644            })?;
1645            let mut config = StageRouterConfig::new(*picker, threshold);
1646            config.capable_first_threshold = tiers.capable_first_threshold;
1647            config.recent_window = *recent_turn_window;
1648            if let Some(turns) = capable_hold_turns {
1649                config.capable_hold_turns = *turns;
1650            }
1651            config.efficient_hold_turns = *efficient_hold_turns;
1652            config.tool_semantics = tool_semantics.clone();
1653            config.handoff_notes = handoff_notes.clone();
1654            // The judge is called through its own target, so it is not a routing
1655            // destination and stays out of the tier pair.
1656            config.llm_fallback = classifier
1657                .as_ref()
1658                .map(|classifier| {
1659                    classifier
1660                        .task_classifier_config(route_name, tiers, targets)
1661                        .map(|config| LlmFallback { config })
1662                })
1663                .transpose()?;
1664            let algorithm = StageRouter::new(config).map_err(|error| {
1665                AlgorithmConfigError::with_source(
1666                    format!("stage_router route {route_name}: {error}"),
1667                    error,
1668                )
1669            })?;
1670            let parent: Arc<dyn Algorithm> = Arc::new(algorithm);
1671            attach_subagent_router(route_name, parent, subagents.as_ref(), targets)
1672        }
1673        AlgorithmSpec::Auto {
1674            capable_target,
1675            efficient_target,
1676        } => {
1677            warn_identical_tiers(route_name, capable_target, efficient_target);
1678            let config = StageRouterConfig::new(PickerMode::EfficientFirst, 0.5);
1679            let algorithm = StageRouter::new(config).map_err(|error| {
1680                AlgorithmConfigError::with_source(
1681                    format!("auto route {route_name}: {error}"),
1682                    error,
1683                )
1684            })?;
1685            Ok(Arc::new(algorithm))
1686        }
1687        AlgorithmSpec::Composite {
1688            classifier,
1689            stage,
1690            subagents,
1691        } => {
1692            stage.warn_identical_tiers(route_name);
1693            let mut stage_config = StageRouterConfig::new(
1694                PickerMode::EfficientFirst,
1695                stage.confidence_threshold.unwrap_or(0.5),
1696            );
1697            stage_config.capable_first_threshold = stage.capable_first_threshold;
1698            stage_config.recent_window = stage.recent_turn_window;
1699            if let Some(turns) = stage.capable_hold_turns {
1700                stage_config.capable_hold_turns = turns;
1701            }
1702            stage_config.efficient_hold_turns = stage.efficient_hold_turns;
1703            stage_config.tool_semantics = stage.tool_semantics.clone();
1704            stage_config.handoff_notes = stage.handoff_notes.clone();
1705            let judge = if matches!(classifier.mode, Some(ClassifierMode::Custom)) {
1706                CompositeJudgeConfig::Custom(classifier.custom_classifier_config(route_name)?)
1707            } else {
1708                CompositeJudgeConfig::Capability(
1709                    classifier.task_classifier_config(route_name, stage, targets)?,
1710                )
1711            };
1712            let algorithm = CompositeRouter::new(CompositeRouterConfig {
1713                judge,
1714                stage: stage_config,
1715            })
1716            .map_err(|error| {
1717                AlgorithmConfigError::with_source(
1718                    format!("composite route {route_name}: {error}"),
1719                    error,
1720                )
1721            })?;
1722            let parent: Arc<dyn Algorithm> = Arc::new(algorithm);
1723            attach_subagent_router(route_name, parent, subagents.as_ref(), targets)
1724        }
1725        AlgorithmSpec::Advisor {
1726            reviewer_system_prompt,
1727            redo_feedback_prefix,
1728            gate_trigger,
1729            gate_trigger_pattern,
1730            max_reviews,
1731            gate_stall_turns,
1732            gate_min_tool_results,
1733            advisor_max_tokens,
1734            advisor_temperature,
1735            transcript_max_chars,
1736            fail_open,
1737            ..
1738        } => {
1739            // A pattern set under the default trigger would be silently
1740            // ignored; reject the misconfiguration instead.
1741            if *gate_trigger == AdvisorTriggerConfig::NoToolCall && gate_trigger_pattern.is_some() {
1742                return Err(AlgorithmConfigError::new(format!(
1743                    "advisor route {route_name}: gate_trigger_pattern requires \
1744                     gate_trigger = \"pattern\""
1745                )));
1746            }
1747            let mut config = AdvisorGateConfig::default();
1748            if let Some(prompt) = reviewer_system_prompt {
1749                config.reviewer_system_prompt = prompt.clone();
1750            }
1751            if let Some(prefix) = redo_feedback_prefix {
1752                config.redo_feedback_prefix = prefix.clone();
1753            }
1754            config.gate_trigger = match gate_trigger {
1755                AdvisorTriggerConfig::NoToolCall => GateTrigger::NoToolCall,
1756                AdvisorTriggerConfig::Pattern => {
1757                    GateTrigger::Pattern(gate_trigger_pattern.clone().unwrap_or_default())
1758                }
1759            };
1760            config.max_reviews = *max_reviews;
1761            config.gate_stall_turns = *gate_stall_turns;
1762            config.gate_min_tool_results = *gate_min_tool_results;
1763            config.advisor_max_tokens = *advisor_max_tokens;
1764            config.advisor_temperature = *advisor_temperature;
1765            config.transcript_max_chars = *transcript_max_chars;
1766            config.fail_open = *fail_open;
1767            let algorithm = AdvisorGate::new(config).map_err(|error| {
1768                AlgorithmConfigError::with_source(
1769                    format!("advisor route {route_name}: {error}"),
1770                    error,
1771                )
1772            })?;
1773            Ok(Arc::new(algorithm))
1774        }
1775        AlgorithmSpec::Vgr { config } => build_vgr(route_name, config, targets),
1776        AlgorithmSpec::PrefillRouter {
1777            targets: names,
1778            checkpoint,
1779            device,
1780            cache_dir,
1781            max_length,
1782            batch_size,
1783        } => {
1784            #[cfg(feature = "prefill-router")]
1785            {
1786                let targets = names
1787                    .iter()
1788                    .map(|name| resolve_target_model_id(route_name, name, targets))
1789                    .collect::<AlgorithmResult<Vec<_>>>()?;
1790                let mut config = prefill_router::PrefillRouterConfig::new(targets, checkpoint);
1791                config.device.clone_from(device);
1792                config.cache_dir.clone_from(cache_dir);
1793                if let Some(max_length) = max_length {
1794                    config.max_length = *max_length;
1795                }
1796                if let Some(batch_size) = batch_size {
1797                    config.batch_size = *batch_size;
1798                }
1799                let algorithm = config.build().map_err(|error| {
1800                    AlgorithmConfigError::with_source(
1801                        format!("prefill_router route {route_name}: {error}"),
1802                        error,
1803                    )
1804                })?;
1805                Ok(Arc::new(algorithm))
1806            }
1807
1808            #[cfg(not(feature = "prefill-router"))]
1809            {
1810                let _ = (
1811                    names, checkpoint, device, cache_dir, max_length, batch_size, targets,
1812                );
1813                Err(AlgorithmConfigError::new(format!(
1814                    "prefill_router route {route_name} requires the `prefill-router` Cargo feature"
1815                )))
1816            }
1817        }
1818    }
1819}
1820
1821fn build_vgr(
1822    route_name: &str,
1823    config: &VgrRouteConfig,
1824    targets: &BTreeMap<String, ModelId>,
1825) -> AlgorithmResult<Arc<dyn Algorithm>> {
1826    let efficient = resolve_target_model_id(route_name, &config.efficient_target, targets)?;
1827    let capable = resolve_target_model_id(route_name, &config.capable_target, targets)?;
1828    let optional = |name: &Option<String>| {
1829        name.as_deref()
1830            .map(|name| resolve_target_model_id(route_name, name, targets))
1831            .transpose()
1832    };
1833    let mut runtime = VgrConfig::new(efficient, capable);
1834    runtime.targets.judge = optional(&config.judge_target)?;
1835    runtime.targets.capable_judge = optional(&config.capable_judge_target)?;
1836    runtime.mode = match config.mode {
1837        VgrModeConfig::Off => libsy::ServingMode::Off,
1838        VgrModeConfig::Evaluate => libsy::ServingMode::Evaluate,
1839        VgrModeConfig::Shadow => libsy::ServingMode::Shadow,
1840        VgrModeConfig::Active => libsy::ServingMode::Active {
1841            approval: config.active_approval.clone().unwrap_or_default(),
1842        },
1843    };
1844    runtime.deadline = vgr_duration(route_name, "deadline_seconds", config.deadline_seconds)?;
1845    runtime.task_typing = config.task_typing;
1846    runtime.confirmed_recovery_min_clean_tail = config.confirmed_recovery_min_clean_tail;
1847    runtime.agentic_handoff = config.agentic_handoff;
1848    runtime.compact_handoff = config.compact_handoff;
1849    runtime.latch_escalation = config.latch_escalation;
1850    runtime.efficient_turn_budget = config
1851        .efficient_turn_budget_seconds
1852        .map(|seconds| vgr_duration(route_name, "efficient_turn_budget_seconds", seconds))
1853        .transpose()?;
1854    runtime.breaker = BreakerConfig {
1855        threshold: config.breaker_threshold,
1856        cooldown: vgr_duration(
1857            route_name,
1858            "breaker_cooldown_seconds",
1859            config.breaker_cooldown_seconds,
1860        )?,
1861    };
1862    Vgr::new(runtime)
1863        .map(|algorithm| Arc::new(algorithm) as Arc<dyn Algorithm>)
1864        .map_err(|error| {
1865            AlgorithmConfigError::with_source(format!("vgr route {route_name}: {error}"), error)
1866        })
1867}
1868
1869fn vgr_duration(route: &str, field: &str, seconds: f64) -> AlgorithmResult<std::time::Duration> {
1870    let message = || format!("vgr route {route}: {field} must be a positive number");
1871    let duration = std::time::Duration::try_from_secs_f64(seconds)
1872        .map_err(|error| AlgorithmConfigError::with_source(message(), error))?;
1873    if duration.is_zero() {
1874        return Err(AlgorithmConfigError::new(message()));
1875    }
1876    Ok(duration)
1877}
1878
1879const fn default_max_reviews() -> u32 {
1880    1
1881}
1882
1883const fn default_advisor_max_tokens() -> u64 {
1884    2048
1885}
1886
1887const fn default_transcript_max_chars() -> usize {
1888    200_000
1889}
1890
1891const fn default_fail_open() -> bool {
1892    true
1893}
1894
1895fn classifier_contract(prompt: Option<&str>) -> ClassifierContractConfig {
1896    prompt.map_or_else(ClassifierContractConfig::default, |prompt| {
1897        ClassifierContractConfig::default().with_prompt(prompt)
1898    })
1899}
1900
1901fn default_classifier_max_output_tokens() -> u64 {
1902    LlmCapabilityConfig::default().max_output_tokens
1903}
1904
1905/// Both tiers on one target leave the route nothing to choose between; every decision
1906/// lands on the same model. The file still loads, since a single-model deployment can
1907/// be a deliberate overhead measurement.
1908fn warn_identical_tiers(route_name: &str, capable_target: &str, efficient_target: &str) {
1909    if capable_target == efficient_target {
1910        tracing::warn!(
1911            route = route_name,
1912            target = capable_target,
1913            "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"
1914        );
1915    }
1916}
1917
1918fn warn_single_target_classifier(route_name: &str, models: &CategoryModelConfig) {
1919    if models.routing_names().len() < 2 {
1920        tracing::warn!(
1921            route = route_name,
1922            "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."
1923        );
1924    }
1925}
1926
1927const fn default_vgr_deadline() -> f64 {
1928    30.0
1929}
1930
1931const fn default_true() -> bool {
1932    true
1933}
1934
1935const fn default_vgr_breaker_threshold() -> u32 {
1936    5
1937}
1938
1939const fn default_vgr_breaker_cooldown() -> f64 {
1940    30.0
1941}
1942
1943fn resolve_target_model_id(
1944    route_name: &str,
1945    name: &str,
1946    targets: &BTreeMap<String, ModelId>,
1947) -> AlgorithmResult<ModelId> {
1948    targets.get(name).cloned().ok_or_else(|| {
1949        AlgorithmConfigError::new(format!(
1950            "route {route_name} references unknown target {name}"
1951        ))
1952    })
1953}
1954
1955fn resolve_runtime_models(
1956    route_name: &str,
1957    names: HashMap<Category, Vec<String>>,
1958    targets: &BTreeMap<String, ModelId>,
1959) -> AlgorithmResult<HashMap<Category, Vec<ModelId>>> {
1960    names
1961        .into_iter()
1962        .map(|(category, names)| {
1963            let models = names
1964                .into_iter()
1965                .map(|name| resolve_target_model_id(route_name, &name, targets))
1966                .collect::<AlgorithmResult<Vec<_>>>()?;
1967            Ok((category, models))
1968        })
1969        .collect()
1970}