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