Skip to main content

switchyard_runner/
config.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Version-1 TOML deployment loading for the shared runner.
5
6use std::collections::{BTreeMap, BTreeSet, HashMap};
7use std::fs;
8use std::path::Path;
9use std::sync::Arc;
10use std::time::Duration;
11
12use parking_lot::Mutex;
13use serde::de::DeserializeOwned;
14use serde::{Deserialize, Deserializer};
15use serde_json::Value;
16use switchyard_llm_client::{
17    AuxiliaryOperation, Backend, ClientRouter, DEFAULT_MAX_RETRIES, HttpBackendConfig, ModelConfig,
18    OpenAiDecisionClient, ResponsesReasoningPolicy, SystemOneClient, TranslatingLlmClient,
19};
20use switchyard_protocol::{
21    LlmClientError, ModelId, RoutedDecisionClient, RoutedLlmClient, WireFormat,
22};
23
24use crate::algorithm_factory::AlgorithmAndModels;
25use crate::eligibility::{EligibilityPolicy, validate_labels};
26use crate::route::ExecutionLane;
27use crate::runner::ModelPropertiesProbe;
28use crate::{
29    AlgorithmFactory, AlgorithmSpec, AuxiliaryTarget, CallerAuthKind, DecisionTarget,
30    ModelCapabilities, Route, Runner, RunnerError,
31};
32
33const SUPPORTED_SCHEMA_VERSION: u32 = 1;
34const MAX_CONFIGURED_RETRIES: u32 = 10;
35
36/// This function returns a deployment's declared credential variable names without reading their values.
37/// It parses the deployment and checks its schema version before returning names from LLM and decision clients.
38pub fn api_key_variables(source: &str) -> Result<BTreeSet<String>, RunnerError> {
39    let config: DeploymentConfig = toml::from_str(source).map_err(|_| {
40        RunnerError::configuration(
41            "Could not parse the server config to read credential references.",
42        )
43    })?;
44    if config.schema_version != SUPPORTED_SCHEMA_VERSION {
45        return Err(RunnerError::configuration(
46            "Unsupported server config schema version.",
47        ));
48    }
49    let mut variables: BTreeSet<_> = config
50        .llm_clients
51        .values()
52        .filter_map(|client| client.api_key_env.clone())
53        .collect();
54    for client in config.decision_clients.values() {
55        variables.insert(client.api_key_env.clone());
56    }
57    if variables
58        .iter()
59        .any(|name| name.trim().is_empty() || name.contains(['\0', '=']))
60    {
61        return Err(RunnerError::configuration(
62            "Invalid credential environment variable name.",
63        ));
64    }
65    Ok(variables)
66}
67
68/// RouteTargets lists the targets one route calls, as the runner reads them from the server config.
69#[derive(Clone, Debug, PartialEq, Eq)]
70pub struct RouteTargets {
71    /// This field names the route's table under `[routes]`.
72    pub name: String,
73    /// This field holds the public model ID that callers send.
74    pub id: ModelId,
75    /// This field holds the route's `type`, such as `stage_router`.
76    pub kind: &'static str,
77    /// This field lists the targets that answer requests, in algorithm order. It includes
78    /// targets that only delegated sub-agent work uses.
79    pub answer_targets: Vec<ConfiguredTarget>,
80    /// This field lists the targets the route calls only to decide, such as a judge or an advisor.
81    pub judge_targets: Vec<ConfiguredTarget>,
82}
83
84/// ConfiguredTarget names one target that a route uses.
85#[derive(Clone, Debug, PartialEq, Eq)]
86pub struct ConfiguredTarget {
87    pub name: String,
88    /// This field holds the client and model from the `[targets]` or `[decision_targets]`
89    /// entry with this name. It is `None` when no entry has the name; the server refuses to
90    /// start with such a config.
91    pub model: Option<TargetModel>,
92}
93
94/// TargetModel identifies the model a target sends requests to and the client that serves it.
95#[derive(Clone, Debug, PartialEq, Eq)]
96pub struct TargetModel {
97    pub client: String,
98    pub id: ModelId,
99}
100
101/// This function lists each route's targets without building clients or reading credentials.
102/// It returns the same parse error the server reports for a config that does not match the schema.
103pub fn route_targets(source: &str) -> Result<Vec<RouteTargets>, RunnerError> {
104    let config: DeploymentConfig = toml::from_str(source).map_err(|error| {
105        RunnerError::configuration_source(format!("failed to parse TOML: {error}"), error)
106    })?;
107    config.check_schema_version()?;
108    Ok(config
109        .routes
110        .iter()
111        .map(|(name, route)| RouteTargets {
112            name: name.clone(),
113            id: route.id.clone(),
114            kind: (&route.algorithm).into(),
115            answer_targets: config.configured_targets(route.routing_target_names()),
116            judge_targets: config.configured_targets(route.algorithm.judge_target_names()),
117        })
118        .collect())
119}
120
121type RunnerResult<T> = Result<T, RunnerError>;
122
123pub(crate) fn load_runner(path: impl AsRef<Path>) -> RunnerResult<Runner> {
124    let path = path.as_ref();
125    let source = fs::read_to_string(path).map_err(|error| {
126        RunnerError::configuration_source(
127            format!("failed to read server config {}: {error}", path.display()),
128            error,
129        )
130    })?;
131    runner_from_toml(&source).map_err(|error| {
132        RunnerError::configuration_source(
133            format!("invalid server config {}: {error}", path.display()),
134            error,
135        )
136    })
137}
138
139pub(crate) fn runner_from_toml(source: &str) -> RunnerResult<Runner> {
140    Arc::new(AlgorithmFactory::from_toml(source)?.config).build()
141}
142
143#[derive(Debug, Deserialize)]
144#[serde(deny_unknown_fields)]
145pub(crate) struct DeploymentConfig {
146    schema_version: u32,
147    fallback_client: Option<String>,
148    #[serde(default)]
149    llm_clients: BTreeMap<String, LlmClientConfig>,
150    targets: BTreeMap<String, TargetConfig>,
151    #[serde(default)]
152    decision_clients: BTreeMap<String, DecisionClientConfig>,
153    #[serde(default)]
154    decision_targets: BTreeMap<String, DecisionTargetConfig>,
155    routes: BTreeMap<String, RouteConfig>,
156}
157
158#[derive(Debug)]
159pub(crate) struct RouteConfig {
160    pub(crate) id: ModelId,
161    context_window: Option<u32>,
162    tool_calling: Option<bool>,
163    reasoning: Option<bool>,
164    vision: Option<bool>,
165    algorithm: AlgorithmSpec,
166    infra: Option<libsy::InfraPolicy>,
167    eligibility: Option<TargetEligibilityConfig>,
168}
169
170#[derive(Debug, Deserialize)]
171#[serde(deny_unknown_fields)]
172struct TargetEligibilityConfig {
173    #[serde(default)]
174    require_explicit_requirements: bool,
175    #[serde(default)]
176    target_alternatives: BTreeMap<String, Vec<String>>,
177    #[serde(default)]
178    scope: RequirementScope,
179}
180
181#[derive(Debug, Default, Deserialize)]
182#[serde(rename_all = "snake_case")]
183enum RequirementScope {
184    #[default]
185    Request,
186    Task,
187}
188
189struct TargetPromptPolicy {
190    prompts: HashMap<ModelId, String>,
191    routing_answer_target: Option<ModelId>,
192}
193
194// Algorithms keep their role names; calls use the selected target's name.
195struct ResolvedLlmTarget<'a> {
196    name: &'a str,
197    config: &'a TargetConfig,
198}
199
200type LaneTargets<'a> = BTreeMap<&'a str, ResolvedLlmTarget<'a>>;
201type TargetOverrides<'a> = BTreeMap<&'a str, &'a str>;
202type LaneDecisions<'a> = BTreeMap<&'a str, ResolvedDecisionTarget<'a>>;
203
204// Replacements bind logical names once; mappings never chain.
205fn bound_target<'a>(name: &'a str, overrides: &TargetOverrides<'a>) -> &'a str {
206    overrides.get(name).copied().unwrap_or(name)
207}
208
209fn callable_targets(algorithm: &AlgorithmSpec) -> BTreeSet<&str> {
210    algorithm.callable_target_names().into_iter().collect()
211}
212
213type TargetBindings = Vec<(String, String)>;
214
215// Cache by resolved targets, so equivalent requirements reuse algorithm-owned state.
216pub(crate) struct EligibleLanes {
217    deployment: Arc<DeploymentConfig>,
218    route_name: String,
219    clients: Arc<BTreeMap<String, Arc<TranslatingLlmClient>>>,
220    decision_clients: Arc<BTreeMap<String, Arc<dyn RoutedDecisionClient>>>,
221    lanes: Mutex<BTreeMap<TargetBindings, Arc<ExecutionLane>>>,
222}
223
224impl EligibleLanes {
225    const MAX_LANES: usize = 128;
226
227    pub(crate) fn select(
228        &self,
229        requirements: &BTreeSet<String>,
230        standard: &Arc<ExecutionLane>,
231    ) -> RunnerResult<Arc<ExecutionLane>> {
232        let route = &self.deployment.routes[&self.route_name];
233        let config = route
234            .eligibility
235            .as_ref()
236            .ok_or_else(|| RunnerError::configuration("route has no eligibility configuration"))?;
237        let callable = callable_targets(&route.algorithm);
238        let mut allowed = BTreeSet::new();
239        let mut overrides = TargetOverrides::new();
240        for name in &callable {
241            let candidates = std::iter::once(*name).chain(
242                config
243                    .target_alternatives
244                    .get(*name)
245                    .into_iter()
246                    .flatten()
247                    .map(String::as_str),
248            );
249            if let Some(target) = candidates.into_iter().find(|name| {
250                self.deployment
251                    .target_labels(name)
252                    .is_some_and(|labels| requirements.is_subset(labels))
253            }) {
254                allowed.insert(*name);
255                if target != *name {
256                    overrides.insert(*name, target);
257                }
258            }
259        }
260        if overrides.is_empty() && allowed.len() == callable.len() {
261            return Ok(Arc::clone(standard));
262        }
263        let key = allowed
264            .iter()
265            .map(|name| {
266                (
267                    (*name).to_owned(),
268                    bound_target(name, &overrides).to_owned(),
269                )
270            })
271            .collect();
272        let mut lanes = self.lanes.lock();
273        if let Some(lane) = lanes.get(&key) {
274            return Ok(Arc::clone(lane));
275        }
276        if lanes.len() >= Self::MAX_LANES {
277            return Err(LlmClientError::TemporarilyUnavailable.into());
278        }
279        let algorithm = route.algorithm.restricted_to(&allowed).map_err(|error| {
280            LlmClientError::InvalidRequest {
281                message: error.to_string(),
282            }
283        })?;
284        let targets = self
285            .deployment
286            .resolve_lane_targets(&algorithm, &overrides)?;
287        if matches!(algorithm, AlgorithmSpec::Random { .. }) && !overrides.is_empty() {
288            let mut models = BTreeSet::new();
289            for name in algorithm.routing_target_names() {
290                let target = targets[name].name;
291                if !models.insert(target) {
292                    return Err(LlmClientError::InvalidRequest {
293                        message: format!(
294                            "random route alternatives map multiple pool entries to target {target}; use distinct targets"
295                        ),
296                    }
297                    .into());
298                }
299            }
300        }
301        let decisions = self.deployment.resolve_decision_targets(
302            &self.route_name,
303            &algorithm,
304            &overrides,
305            &self.decision_clients,
306        )?;
307        // Construction is synchronous; no model I/O or await occurs under this lock.
308        let (lane, _) = self.deployment.build_lane(
309            &self.route_name,
310            &algorithm,
311            &targets,
312            &decisions,
313            &self.clients,
314            None,
315        )?;
316        let lane = Arc::new(lane);
317        lanes.insert(key, Arc::clone(&lane));
318        Ok(lane)
319    }
320}
321
322#[derive(Clone, Copy)]
323struct ResolvedDecisionTarget<'a> {
324    model: &'a ModelId,
325    client: &'a Arc<dyn RoutedDecisionClient>,
326}
327
328impl<'de> Deserialize<'de> for RouteConfig {
329    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
330    where
331        D: Deserializer<'de>,
332    {
333        let mut table = toml::Table::deserialize(deserializer)?;
334        let id = take_required(&mut table, "id")?;
335        let context_window = take_optional(&mut table, "context_window")?;
336        let tool_calling = take_optional(&mut table, "tool_calling")?;
337        let reasoning = take_optional(&mut table, "reasoning")?;
338        let vision = take_optional(&mut table, "vision")?;
339        let infra = take_optional(&mut table, "infra")?;
340        let eligibility = take_optional(&mut table, "eligibility")?;
341        let algorithm = AlgorithmSpec::deserialize(toml::Value::Table(table))
342            .map_err(serde::de::Error::custom)?;
343        Ok(Self {
344            id,
345            context_window,
346            tool_calling,
347            reasoning,
348            vision,
349            algorithm,
350            infra,
351            eligibility,
352        })
353    }
354}
355
356fn take_required<T, E>(table: &mut toml::Table, name: &'static str) -> Result<T, E>
357where
358    T: DeserializeOwned,
359    E: serde::de::Error,
360{
361    let value = table.remove(name).ok_or_else(|| E::missing_field(name))?;
362    T::deserialize(value).map_err(E::custom)
363}
364
365fn take_optional<T, E>(table: &mut toml::Table, name: &'static str) -> Result<Option<T>, E>
366where
367    T: DeserializeOwned,
368    E: serde::de::Error,
369{
370    table
371        .remove(name)
372        .map(|value| T::deserialize(value).map_err(E::custom))
373        .transpose()
374}
375
376impl RouteConfig {
377    fn capabilities(&self) -> ModelCapabilities {
378        ModelCapabilities {
379            context_window: self.context_window,
380            tool_calling: self.tool_calling,
381            reasoning: self.reasoning,
382            vision: self.vision,
383        }
384    }
385
386    fn routing_target_names(&self) -> Vec<&str> {
387        self.algorithm.routing_target_names()
388    }
389
390    fn callable_target_names(&self) -> Vec<&str> {
391        self.algorithm.callable_target_names()
392    }
393}
394
395impl DeploymentConfig {
396    /// Judges called through a decision target use the default decision settings
397    /// when the route omits a `decision` table.
398    pub(crate) fn infer_decision_judges(&mut self) {
399        for route in self.routes.values_mut() {
400            route
401                .algorithm
402                .infer_decision_judges(|name| self.decision_targets.contains_key(name));
403        }
404    }
405
406    pub(crate) fn requires_target_admission(&self) -> bool {
407        self.routes
408            .values()
409            .any(|route| route.eligibility.is_some())
410    }
411
412    fn target_labels(&self, name: &str) -> Option<&BTreeSet<String>> {
413        self.targets
414            .get(name)
415            .map(|target| &target.labels)
416            .or_else(|| self.decision_targets.get(name).map(|target| &target.labels))
417    }
418
419    fn check_schema_version(&self) -> RunnerResult<()> {
420        if self.schema_version == SUPPORTED_SCHEMA_VERSION {
421            return Ok(());
422        }
423        Err(RunnerError::configuration(format!(
424            "unsupported schema_version {}; expected {SUPPORTED_SCHEMA_VERSION}",
425            self.schema_version
426        )))
427    }
428
429    /// This function looks up each name in `[targets]`, then in `[decision_targets]`. The server
430    /// rejects a name defined in both, so the lookup order does not change the result.
431    fn configured_targets(&self, names: Vec<&str>) -> Vec<ConfiguredTarget> {
432        names
433            .into_iter()
434            .map(|name| ConfiguredTarget {
435                name: name.to_string(),
436                model: self
437                    .targets
438                    .get(name)
439                    .map(|target| (&target.llm_client, &target.id))
440                    .or_else(|| {
441                        self.decision_targets
442                            .get(name)
443                            .map(|target| (&target.decision_client, &target.id))
444                    })
445                    .map(|(client, id)| TargetModel {
446                        client: client.clone(),
447                        id: id.clone(),
448                    }),
449            })
450            .collect()
451    }
452
453    fn decision_target(&self, name: &str) -> Option<DecisionTarget> {
454        let target = self.targets.get(name)?;
455        let client = self.llm_clients.get(&target.llm_client)?;
456        Some(DecisionTarget {
457            target: name.to_string(),
458            model: target.id.clone(),
459            format: client.format.wire_format(),
460            base_url: client.base_url.as_str().to_string(),
461            extra_body: target.extra_body.clone(),
462            reasoning_effort: target.reasoning_effort.clone(),
463        })
464    }
465
466    /// Checks route identity and target references without constructing clients.
467    pub(crate) fn validate(&self) -> RunnerResult<()> {
468        self.check_schema_version()?;
469
470        let mut route_names_by_id = HashMap::new();
471        for (route_name, config) in &self.routes {
472            validate_value("route name", route_name)?;
473            validate_value(&format!("route {route_name} id"), &config.id)?;
474            if let Some(first_route_name) =
475                route_names_by_id.insert(config.id.as_str(), route_name.as_str())
476            {
477                return Err(RunnerError::configuration(format!(
478                    "routes {first_route_name} and {route_name} both use id {}; route ids must be unique",
479                    config.id
480                )));
481            }
482        }
483
484        for (name, target) in &self.targets {
485            validate_labels(&target.labels)
486                .map_err(|error| RunnerError::configuration(error.to_string()))?;
487            validate_value("target name", name)?;
488            validate_value(&format!("target {name} id"), &target.id)?;
489            if !self.llm_clients.contains_key(&target.llm_client) {
490                return Err(RunnerError::configuration(format!(
491                    "target {name} references unknown llm client {}",
492                    target.llm_client
493                )));
494            }
495        }
496        let mut decision_approvals = HashMap::new();
497        for (name, target) in &self.decision_targets {
498            validate_labels(&target.labels)
499                .map_err(|error| RunnerError::configuration(error.to_string()))?;
500            if decision_approvals
501                .insert((&target.decision_client, &target.id), &target.labels)
502                .is_some_and(|approved| approved != &target.labels)
503            {
504                return Err(RunnerError::configuration(format!(
505                    "decision target {name} shares a model and client with an alias but disagrees on approval labels"
506                )));
507            }
508            validate_value("decision target name", name)?;
509            validate_value(&format!("decision target {name} id"), &target.id)?;
510            if self.targets.contains_key(name) {
511                return Err(RunnerError::configuration(format!(
512                    "target {name} is defined as both an LLM and decision target"
513                )));
514            }
515            // Algorithms route LLM targets by name and decision targets by id.
516            if self.targets.contains_key(target.id.as_str()) {
517                return Err(RunnerError::configuration(format!(
518                    "decision target {name} id {} is also an LLM target name; rename one of them",
519                    target.id
520                )));
521            }
522            if !self.decision_clients.contains_key(&target.decision_client) {
523                return Err(RunnerError::configuration(format!(
524                    "decision target {name} references unknown decision client {}",
525                    target.decision_client
526                )));
527            }
528        }
529        for (route_name, config) in &self.routes {
530            if let Some(eligibility) = &config.eligibility {
531                self.validate_eligibility(route_name, &config.algorithm, eligibility)?;
532            }
533        }
534        Ok(())
535    }
536
537    /// Resolves each route once; native and host-owned execution share this path.
538    pub(crate) fn build_algorithms(
539        &self,
540    ) -> impl Iterator<Item = RunnerResult<(&str, &RouteConfig, AlgorithmAndModels)>> {
541        let targets = self.build_targets();
542        self.routes.iter().map(move |(route_name, config)| {
543            let decision_targets = config.algorithm.decision_target_names();
544            for name in config.callable_target_names() {
545                let (exists, kind) = if decision_targets.contains(&name) {
546                    (self.decision_targets.contains_key(name), "decision")
547                } else {
548                    (self.targets.contains_key(name), "LLM")
549                };
550                if !exists {
551                    return Err(RunnerError::configuration(format!(
552                        "route references unknown target {name}; route {route_name} requires target kind {kind}"
553                    )));
554                }
555            }
556            for name in config.routing_target_names() {
557                if !self.targets.contains_key(name) {
558                    return Err(RunnerError::configuration(format!(
559                        "route {route_name} completion target {name} must be an LLM target"
560                    )));
561                }
562            }
563            if config.context_window == Some(0) {
564                return Err(RunnerError::configuration(format!(
565                    "route {route_name} context_window must be greater than zero"
566                )));
567            }
568            let (algorithm, models) = config
569                .algorithm
570                .build_with_runtime_models(route_name, &targets)
571                .map_err(|error| RunnerError::configuration_source(error.to_string(), error))?;
572            let models = models.with_provider_models(
573                config
574                    .callable_target_names()
575                    .into_iter()
576                    .filter_map(|name| {
577                        self.targets
578                            .get(name)
579                            .map(|target| (ModelId::from(name), target.id.clone()))
580                    })
581                    .collect(),
582            );
583            if let Some(infra) = &config.infra {
584                if !matches!(&config.algorithm, AlgorithmSpec::LlmClassifier { config }
585                    if matches!(config.mode, None | Some(crate::ClassifierMode::Capability))
586                        && config.escalation.is_none())
587                {
588                    return Err(RunnerError::configuration(format!(
589                        "route {route_name}: infra requires capability llm_classifier without escalation"
590                    )));
591                }
592                infra.validate().map_err(|error| {
593                    RunnerError::configuration_source(format!("route {route_name}: {error}"), error)
594                })?;
595            }
596            Ok((route_name.as_str(), config, (algorithm, models)))
597        })
598    }
599
600    fn build(self: Arc<Self>) -> RunnerResult<Runner> {
601        self.validate()?;
602
603        let mut approvals = HashMap::new();
604        for (target_name, target) in &self.targets {
605            if let Some((first_name, labels)) = approvals.insert(
606                (&target.llm_client, &target.id),
607                (target_name, &target.labels),
608            ) && labels != &target.labels
609            {
610                return Err(RunnerError::configuration(format!(
611                    "targets {first_name} and {target_name} share a model and llm client but disagree on approval labels"
612                )));
613            }
614        }
615
616        let mut provider_api_keys = Vec::new();
617        let clients = Arc::new(self.build_clients(&mut provider_api_keys)?);
618        let decision_clients = Arc::new(self.build_decision_clients(&mut provider_api_keys)?);
619        let fallback_base_url = self.fallback_base_url()?;
620        let mut routes = Vec::with_capacity(self.routes.len());
621        let mut model_properties_probes = BTreeMap::new();
622        for entry in self.build_algorithms() {
623            let (route_name, config, (algorithm, models)) = entry?;
624            let capabilities = config.capabilities();
625            if let AlgorithmSpec::Vgr { config: vgr } = &config.algorithm
626                && let Some(probe) =
627                    self.build_model_properties_probe(&vgr.efficient_target, &clients)
628            {
629                model_properties_probes.insert(config.id.clone(), probe);
630            }
631            let no_overrides = TargetOverrides::new();
632            let standard_targets = self.resolve_lane_targets(&config.algorithm, &no_overrides)?;
633            let decisions = self.resolve_decision_targets(
634                route_name,
635                &config.algorithm,
636                &no_overrides,
637                &decision_clients,
638            )?;
639            let (standard, caller_auth) = self.build_lane(
640                route_name,
641                &config.algorithm,
642                &standard_targets,
643                &decisions,
644                &clients,
645                Some((algorithm, models)),
646            )?;
647            // Judges are included so a judge answer maps to its provider model too.
648            let mut target_names = callable_targets(&config.algorithm);
649            if let Some(eligibility) = &config.eligibility {
650                target_names.extend(
651                    eligibility
652                        .target_alternatives
653                        .values()
654                        .flatten()
655                        .map(String::as_str),
656                );
657            }
658            let route_targets = target_names
659                .into_iter()
660                .filter_map(|name| self.decision_target(name))
661                .collect();
662            let mut route = Route::from_lane(standard, caller_auth, capabilities, route_targets)
663                .with_infra(config.infra.clone());
664            if let Some(eligibility) = &config.eligibility {
665                let policy = EligibilityPolicy::new(
666                    eligibility.require_explicit_requirements,
667                    matches!(eligibility.scope, RequirementScope::Task),
668                );
669                route = route.with_eligibility(
670                    policy,
671                    EligibleLanes {
672                        deployment: Arc::clone(&self),
673                        route_name: route_name.to_owned(),
674                        clients: Arc::clone(&clients),
675                        decision_clients: Arc::clone(&decision_clients),
676                        lanes: Mutex::new(BTreeMap::new()),
677                    },
678                );
679            }
680            routes.push((config.id.clone(), route));
681        }
682        let runner = Runner::new(routes)
683            .with_fallback_url(fallback_base_url)
684            .with_provider_api_keys(provider_api_keys)
685            .with_model_properties_probes(model_properties_probes);
686        Ok(runner)
687    }
688
689    // Alternatives preserve the router's existing roles and never chain.
690    fn validate_eligibility(
691        &self,
692        route_name: &str,
693        algorithm: &AlgorithmSpec,
694        config: &TargetEligibilityConfig,
695    ) -> RunnerResult<()> {
696        if !algorithm.supports_target_restrictions() {
697            return Err(RunnerError::configuration(format!(
698                "route {route_name} cannot use eligibility with prefill_router"
699            )));
700        }
701        let callable = callable_targets(algorithm);
702        for (source, alternatives) in &config.target_alternatives {
703            if !callable.contains(source.as_str()) {
704                return Err(RunnerError::configuration(format!(
705                    "route {route_name} target_alternatives source {source} is not used by the algorithm"
706                )));
707            }
708            if alternatives.is_empty() {
709                return Err(RunnerError::configuration(
710                    "target alternatives must not be empty",
711                ));
712            }
713            for target in alternatives {
714                let same_kind = (self.targets.contains_key(source)
715                    && self.targets.contains_key(target))
716                    || (self.decision_targets.contains_key(source)
717                        && self.decision_targets.contains_key(target));
718                if !same_kind {
719                    return Err(RunnerError::configuration(format!(
720                        "route {route_name} alternative {target} must exist and match the target kind of {source}"
721                    )));
722                }
723            }
724        }
725        for name in callable.into_iter().chain(
726            config
727                .target_alternatives
728                .values()
729                .flatten()
730                .map(String::as_str),
731        ) {
732            if let Some(target) = self.targets.get(name)
733                && self
734                    .llm_clients
735                    .get(&target.llm_client)
736                    .is_some_and(|client| client.forward_auth)
737            {
738                return Err(RunnerError::configuration(format!(
739                    "route {route_name} cannot use eligibility with forwarded caller authentication"
740                )));
741            }
742        }
743        Ok(())
744    }
745
746    fn resolve_lane_targets<'a>(
747        &'a self,
748        algorithm: &'a AlgorithmSpec,
749        overrides: &TargetOverrides<'_>,
750    ) -> RunnerResult<LaneTargets<'a>> {
751        let decisions = algorithm.decision_target_names();
752        callable_targets(algorithm)
753            .into_iter()
754            .filter(|name| !decisions.contains(name))
755            .map(|name| {
756                let bound = bound_target(name, overrides);
757                let (resolved_name, config) =
758                    self.targets.get_key_value(bound).ok_or_else(|| {
759                        RunnerError::configuration(format!(
760                            "route references unknown target {bound}"
761                        ))
762                    })?;
763                Ok((
764                    name,
765                    ResolvedLlmTarget {
766                        name: resolved_name.as_str(),
767                        config,
768                    },
769                ))
770            })
771            .collect()
772    }
773
774    fn resolve_decision_targets<'a>(
775        &'a self,
776        route_name: &str,
777        algorithm: &'a AlgorithmSpec,
778        overrides: &TargetOverrides<'_>,
779        clients: &'a BTreeMap<String, Arc<dyn RoutedDecisionClient>>,
780    ) -> RunnerResult<LaneDecisions<'a>> {
781        algorithm
782            .decision_target_names()
783            .into_iter()
784            .map(|name| {
785                let bound = bound_target(name, overrides);
786                let target = self.decision_targets.get(bound).ok_or_else(|| {
787                    RunnerError::configuration(format!(
788                        "route {route_name} requires unknown decision target {bound}"
789                    ))
790                })?;
791                let client = clients.get(&target.decision_client).ok_or_else(|| {
792                    RunnerError::configuration(format!(
793                        "decision target {bound} references unknown decision client {}",
794                        target.decision_client
795                    ))
796                })?;
797                Ok((
798                    name,
799                    ResolvedDecisionTarget {
800                        model: &target.id,
801                        client,
802                    },
803                ))
804            })
805            .collect()
806    }
807
808    fn build_lane(
809        &self,
810        route_name: &str,
811        route: &AlgorithmSpec,
812        targets: &LaneTargets<'_>,
813        decisions: &LaneDecisions<'_>,
814        clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
815        built: Option<AlgorithmAndModels>,
816    ) -> RunnerResult<(ExecutionLane, Option<CallerAuthKind>)> {
817        let (algorithm, mut models) = if let Some(built) = built {
818            built
819        } else {
820            let model_ids = targets
821                .iter()
822                .map(|(name, target)| ((*name).to_owned(), ModelId::from(target.name)))
823                .chain(
824                    decisions
825                        .iter()
826                        .map(|(name, target)| ((*name).to_owned(), target.model.clone())),
827                )
828                .collect();
829            let (algorithm, models) = route
830                .build_with_runtime_models(route_name, &model_ids)
831                .map_err(|error| RunnerError::configuration_source(error.to_string(), error))?;
832            let models = models.with_provider_models(
833                targets
834                    .values()
835                    .map(|target| (ModelId::from(target.name), target.config.id.clone()))
836                    .collect(),
837            );
838            (algorithm, models)
839        };
840        let (route_clients, caller_auth) =
841            self.build_route_clients(route_name, route, targets, decisions, clients)?;
842        let anthropic_auxiliary_target =
843            self.build_anthropic_auxiliary_target(route, targets, clients);
844        let responses_auxiliary_target =
845            self.build_responses_auxiliary_target(route, targets, clients);
846        if self
847            .routes
848            .get(route_name)
849            .is_some_and(|config| config.eligibility.is_some())
850        {
851            models = models.with_target_restriction();
852        }
853        Ok((
854            ExecutionLane::new(
855                algorithm,
856                route_clients,
857                anthropic_auxiliary_target,
858                responses_auxiliary_target,
859                models,
860            ),
861            caller_auth,
862        ))
863    }
864
865    fn build_clients(
866        &self,
867        provider_api_keys: &mut Vec<String>,
868    ) -> RunnerResult<BTreeMap<String, Arc<TranslatingLlmClient>>> {
869        let mut models_by_client = self
870            .llm_clients
871            .keys()
872            .map(|name| (name.clone(), Vec::new()))
873            .collect::<BTreeMap<String, Vec<ModelConfig>>>();
874
875        for (name, client_config) in &self.llm_clients {
876            validate_value("llm client name", name)?;
877            if client_config.responses_reasoning.is_some()
878                && !matches!(client_config.format, ClientFormat::OpenAiResponses)
879            {
880                return Err(RunnerError::configuration(format!(
881                    "llm client {name} responses_reasoning is only valid for openai_responses"
882                )));
883            }
884            let backend = build_backend(
885                name,
886                client_config,
887                &BTreeMap::new(),
888                &BTreeSet::new(),
889                None,
890            )?;
891            let (Backend::OpenAiChat(config)
892            | Backend::OpenAiResponses(config)
893            | Backend::Anthropic(config)) = backend;
894            if let Some(key) = config.api_key {
895                provider_api_keys.push(key);
896            }
897        }
898        for (target_name, target) in &self.targets {
899            let client_config = self.llm_clients.get(&target.llm_client).ok_or_else(|| {
900                RunnerError::configuration(format!(
901                    "target {target_name} references unknown llm client {}",
902                    target.llm_client
903                ))
904            })?;
905            let model_configs = models_by_client
906                .get_mut(&target.llm_client)
907                .ok_or_else(|| {
908                    RunnerError::configuration("validated llm client was not initialized")
909                })?;
910            if let Some(effort) = &target.reasoning_effort {
911                if effort.trim().is_empty() {
912                    return Err(RunnerError::configuration(format!(
913                        "target {target_name} reasoning_effort must not be empty"
914                    )));
915                }
916                // Anthropic rejects any other value with a 400 on every request.
917                if matches!(client_config.format, ClientFormat::AnthropicMessages)
918                    && !ANTHROPIC_EFFORTS.contains(&effort.as_str())
919                {
920                    return Err(RunnerError::configuration(format!(
921                        "target {target_name} reasoning_effort {effort:?} is not an anthropic_messages effort; use one of {}",
922                        ANTHROPIC_EFFORTS.join(", ")
923                    )));
924                }
925            }
926            model_configs.push(
927                ModelConfig::new(
928                    target_name.as_str(),
929                    build_backend(
930                        &target.llm_client,
931                        client_config,
932                        &target.extra_body,
933                        &target.omit_body_fields,
934                        target.reasoning_effort.clone(),
935                    )?,
936                    None,
937                )
938                .with_responses_reasoning(client_config.responses_reasoning.unwrap_or_default())
939                .with_provider_model(target.id.clone()),
940            );
941        }
942
943        let mut clients = BTreeMap::new();
944        for (name, model_configs) in models_by_client {
945            let mut client = TranslatingLlmClient::new(&model_configs)
946                .map_err(|error| RunnerError::configuration(error.to_string()))?;
947            if !self.llm_clients[&name].fallback_on_rate_limit {
948                client = client.without_rate_limit_cooldown();
949            }
950            clients.insert(name, Arc::new(client));
951        }
952        Ok(clients)
953    }
954
955    fn build_decision_clients(
956        &self,
957        provider_api_keys: &mut Vec<String>,
958    ) -> RunnerResult<BTreeMap<String, Arc<dyn RoutedDecisionClient>>> {
959        self.decision_clients
960            .iter()
961            .map(|(name, config)| {
962                validate_value("decision client name", name)?;
963                let DecisionClientConfig {
964                    format,
965                    endpoint,
966                    api_key_env,
967                    timeout_ms,
968                } = config;
969                if *timeout_ms == 0 {
970                    return Err(RunnerError::configuration(format!(
971                        "decision client {name} timeout_ms must be at least 1"
972                    )));
973                }
974                let api_key = read_api_key(&format!("decision client {name}"), api_key_env)?;
975                let timeout = Duration::from_millis(*timeout_ms);
976                let client = match format {
977                    DecisionFormat::SystemOne => {
978                        SystemOneClient::new(endpoint.0.clone(), api_key.clone(), timeout)
979                            .map(|client| Arc::new(client) as Arc<dyn RoutedDecisionClient>)
980                    }
981                    DecisionFormat::OpenaiDecisions => {
982                        OpenAiDecisionClient::new(endpoint.0.clone(), api_key.clone(), timeout)
983                            .map(|client| Arc::new(client) as Arc<dyn RoutedDecisionClient>)
984                    }
985                }
986                .map_err(|error| RunnerError::configuration(error.to_string()))?;
987                provider_api_keys.push(api_key);
988                Ok((name.clone(), client))
989            })
990            .collect()
991    }
992
993    /// Algorithms route on LLM target names, so targets that share a provider model keep
994    /// their own settings. The LLM client sends each target's `id` upstream. Decision targets
995    /// route on their `id`, which the decision client sends as is.
996    fn build_targets(&self) -> BTreeMap<String, ModelId> {
997        self.targets
998            .keys()
999            .map(|name| (name.clone(), ModelId::from(name.as_str())))
1000            .chain(
1001                self.decision_targets
1002                    .iter()
1003                    .map(|(name, config)| (name.clone(), config.id.clone())),
1004            )
1005            .collect()
1006    }
1007
1008    /// Builds the client router for one route. The second value is the caller credential family
1009    /// that the route's forwarding clients need, or `None` when no client forwards the caller's
1010    /// credential. A request through the other family's APIs fails before any upstream call.
1011    fn build_route_clients(
1012        &self,
1013        route_name: &str,
1014        route: &AlgorithmSpec,
1015        targets: &LaneTargets<'_>,
1016        decisions: &LaneDecisions<'_>,
1017        clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
1018    ) -> RunnerResult<(ClientRouter, Option<CallerAuthKind>)> {
1019        let TargetPromptPolicy {
1020            prompts,
1021            routing_answer_target,
1022        } = self.build_route_target_prompts(route_name, route, targets)?;
1023        let mut by_model = HashMap::new();
1024        let mut caller_auth = None;
1025        let mut has_mixed_families = false;
1026        let mut forwarding_origins = BTreeSet::new();
1027        let mut decisions_by_model = HashMap::new();
1028        for name in route.callable_target_names() {
1029            if let Some(target) = decisions.get(name) {
1030                if let Some(previous) =
1031                    decisions_by_model.insert(target.model.clone(), Arc::clone(target.client))
1032                    && !Arc::ptr_eq(&previous, target.client)
1033                {
1034                    return Err(RunnerError::configuration(format!(
1035                        "route {route_name} maps decision model {} to different clients",
1036                        target.model
1037                    )));
1038                }
1039                continue;
1040            }
1041            let target = targets.get(name).ok_or_else(|| {
1042                RunnerError::configuration(format!("route references unknown target {name}"))
1043            })?;
1044            let client = clients.get(&target.config.llm_client).ok_or_else(|| {
1045                RunnerError::configuration(format!("target {name} has no constructed llm client"))
1046            })?;
1047            let client_config =
1048                self.llm_clients
1049                    .get(&target.config.llm_client)
1050                    .ok_or_else(|| {
1051                        RunnerError::configuration(format!(
1052                            "target {name} references unknown llm client {}",
1053                            target.config.llm_client
1054                        ))
1055                    })?;
1056            if client_config.forward_auth {
1057                let target_auth = client_config.format.caller_auth_kind();
1058                has_mixed_families |= caller_auth.is_some_and(|kind| kind != target_auth);
1059                caller_auth = Some(target_auth);
1060                forwarding_origins.insert(client_config.base_url.0.origin().ascii_serialization());
1061            }
1062            let client: Arc<dyn RoutedLlmClient> = client.clone();
1063            by_model.insert(ModelId::from(target.name), client);
1064        }
1065        // Only forwarding clients count: an api_key_env client sends the server's own key.
1066        // Forwarding clients must share one credential family unless they all use the same
1067        // scheme, host, and port, such as one LLM gateway that accepts the caller's gateway key
1068        // on every endpoint. Such a route serves Chat Completions and Responses callers because
1069        // Anthropic clients forward the caller's `authorization` header unchanged.
1070        if has_mixed_families {
1071            if forwarding_origins.len() > 1 {
1072                let origins = Vec::from_iter(forwarding_origins).join(", ");
1073                return Err(RunnerError::configuration(format!(
1074                    "route {route_name} cannot forward both Anthropic and OpenAI caller credentials to different origins ({origins}); point all of its forwarding clients at one origin (same scheme, host, and port), such as an LLM gateway, or set api_key_env instead of forward_auth on one provider's clients"
1075                )));
1076            }
1077            caller_auth = Some(CallerAuthKind::OpenAi);
1078        }
1079        let completion_targets = route
1080            .routing_target_names()
1081            .into_iter()
1082            .map(|name| ModelId::from(targets[name].name))
1083            .collect::<Vec<_>>();
1084        let router = ClientRouter::new_with_decision_clients(
1085            by_model,
1086            decisions_by_model,
1087            prompts,
1088            routing_answer_target,
1089            &completion_targets,
1090        )
1091        .without_rate_limit_fallback(
1092            route
1093                .routing_target_names()
1094                .into_iter()
1095                .filter_map(|name| {
1096                    let target = &targets[name];
1097                    (!self.llm_clients[&target.config.llm_client].fallback_on_rate_limit)
1098                        .then(|| ModelId::from(target.name))
1099                })
1100                .collect(),
1101        );
1102        Ok((router, caller_auth))
1103    }
1104
1105    /// Builds the effective prompt policy for this route's completion targets.
1106    fn build_route_target_prompts(
1107        &self,
1108        route_name: &str,
1109        route: &AlgorithmSpec,
1110        targets: &LaneTargets<'_>,
1111    ) -> RunnerResult<TargetPromptPolicy> {
1112        let mut prompts = HashMap::new();
1113        for name in route.routing_target_names() {
1114            let target = targets.get(name).ok_or_else(|| {
1115                RunnerError::configuration(format!("route references unknown target {name}"))
1116            })?;
1117            if let Some(prompt) = &target.config.system_prompt {
1118                prompts.insert(ModelId::from(target.name), prompt.clone());
1119            }
1120        }
1121        let mut policy = TargetPromptPolicy {
1122            prompts,
1123            routing_answer_target: None,
1124        };
1125        let Some((response_name, dependency_name)) = route.routing_response_and_dependency() else {
1126            return Ok(policy);
1127        };
1128        let response = &targets[response_name];
1129        if !policy.prompts.contains_key(response.name) {
1130            return Ok(policy);
1131        }
1132        if std::iter::once(dependency_name)
1133            .chain(route.judge_target_names())
1134            .any(|name| {
1135                targets
1136                    .get(name)
1137                    .is_some_and(|dependency| dependency.name == response.name)
1138            })
1139        {
1140            return Err(RunnerError::configuration(format!(
1141                "route {route_name} cannot apply system_prompt to target {response_name}: it is also the routing-only target"
1142            )));
1143        }
1144        policy.routing_answer_target = Some(ModelId::from(response.name));
1145        Ok(policy)
1146    }
1147
1148    fn fallback_base_url(&self) -> RunnerResult<Option<String>> {
1149        let Some(name) = &self.fallback_client else {
1150            return Ok(None);
1151        };
1152        let config = self.llm_clients.get(name).ok_or_else(|| {
1153            RunnerError::configuration(format!(
1154                "fallback_client references unknown llm client {name}"
1155            ))
1156        })?;
1157        Ok(Some(config.base_url.as_str().to_string()))
1158    }
1159
1160    fn build_model_properties_probe(
1161        &self,
1162        efficient_target_name: &str,
1163        clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
1164    ) -> Option<ModelPropertiesProbe> {
1165        let efficient_target = self.targets.get(efficient_target_name)?;
1166        let client_config = self.llm_clients.get(&efficient_target.llm_client)?;
1167        if !matches!(
1168            client_config.context_discovery,
1169            Some(ContextDiscovery::LlamaCpp)
1170        ) {
1171            return None;
1172        }
1173        let client = clients.get(&efficient_target.llm_client)?;
1174        Some(ModelPropertiesProbe {
1175            model: ModelId::from(efficient_target_name),
1176            format: client_config.format.wire_format(),
1177            client: Arc::clone(client),
1178        })
1179    }
1180
1181    fn build_anthropic_auxiliary_target(
1182        &self,
1183        route: &AlgorithmSpec,
1184        targets: &LaneTargets<'_>,
1185        clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
1186    ) -> Option<AuxiliaryTarget> {
1187        route
1188            .routing_target_names()
1189            .into_iter()
1190            .enumerate()
1191            .filter_map(|(index, name)| {
1192                let config = targets.get(name)?;
1193                let target = self.build_auxiliary_target(
1194                    config.name,
1195                    config.config,
1196                    clients,
1197                    AuxiliaryOperation::AnthropicCountTokens,
1198                )?;
1199                Some((
1200                    count_tokens_priority(config.name, &config.config.id),
1201                    index,
1202                    target,
1203                ))
1204            })
1205            .min_by_key(|(priority, index, _)| (*priority, *index))
1206            .map(|(_, _, target)| target)
1207    }
1208
1209    fn build_responses_auxiliary_target(
1210        &self,
1211        route: &AlgorithmSpec,
1212        targets: &LaneTargets<'_>,
1213        clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
1214    ) -> Option<AuxiliaryTarget> {
1215        route.routing_target_names().into_iter().find_map(|name| {
1216            let target = targets.get(name)?;
1217            self.build_auxiliary_target(
1218                target.name,
1219                target.config,
1220                clients,
1221                AuxiliaryOperation::ResponsesInputTokens,
1222            )
1223        })
1224    }
1225
1226    fn build_auxiliary_target(
1227        &self,
1228        name: &str,
1229        target: &TargetConfig,
1230        clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
1231        operation: AuxiliaryOperation,
1232    ) -> Option<AuxiliaryTarget> {
1233        let client = clients.get(&target.llm_client)?;
1234        let model = ModelId::from(name);
1235        client
1236            .supports_auxiliary(&model, operation)
1237            .then(|| AuxiliaryTarget {
1238                model,
1239                client: client.clone(),
1240            })
1241    }
1242}
1243
1244/// Values Anthropic accepts for `output_config.effort`.
1245const ANTHROPIC_EFFORTS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
1246
1247fn count_tokens_priority(target_name: &str, model_id: &ModelId) -> usize {
1248    let target_name = target_name.to_ascii_lowercase();
1249    let model_id = model_id.to_ascii_lowercase();
1250    ["opus", "sonnet", "haiku"]
1251        .iter()
1252        .position(|hint| target_name.contains(hint) || model_id.contains(hint))
1253        .unwrap_or(3)
1254}
1255
1256/// A client endpoint, parsed when the config loads rather than checked afterwards.
1257///
1258/// Holding a `HttpBaseUrl` is proof the value is an absolute HTTP(S) URL, so no
1259/// later stage has to re-check it or can forget to.
1260#[derive(Clone, Debug)]
1261struct HttpBaseUrl(reqwest::Url);
1262
1263impl HttpBaseUrl {
1264    fn as_str(&self) -> &str {
1265        self.0.as_str()
1266    }
1267}
1268
1269impl<'de> Deserialize<'de> for HttpBaseUrl {
1270    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1271    where
1272        D: serde::Deserializer<'de>,
1273    {
1274        let raw = String::deserialize(deserializer)?;
1275        let url = reqwest::Url::parse(raw.trim()).map_err(|error| {
1276            serde::de::Error::custom(format!("base_url must be an absolute HTTP(S) URL: {error}"))
1277        })?;
1278        if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
1279            return Err(serde::de::Error::custom(
1280                "base_url must be an absolute HTTP(S) URL",
1281            ));
1282        }
1283        Ok(Self(url))
1284    }
1285}
1286
1287#[derive(Debug, Deserialize)]
1288#[serde(deny_unknown_fields)]
1289struct LlmClientConfig {
1290    format: ClientFormat,
1291    base_url: HttpBaseUrl,
1292    /// Opt-in context discovery for VGR's efficient target. Unset disables discovery.
1293    context_discovery: Option<ContextDiscovery>,
1294    api_key_env: Option<String>,
1295    #[serde(default)]
1296    forward_auth: bool,
1297    #[serde(default)]
1298    extra_headers: BTreeMap<String, String>,
1299    #[serde(default = "default_max_retries")]
1300    max_retries: u32,
1301    #[serde(default = "default_fallback_on_rate_limit")]
1302    fallback_on_rate_limit: bool,
1303    /// Cooldown after an exhausted transient completion failure. Defaults to 5 seconds; zero disables it.
1304    #[serde(default = "default_failure_cooldown_ms")]
1305    failure_cooldown_ms: u64,
1306    /// Deadline in milliseconds for all attempts and the complete response. Unset is unbounded.
1307    timeout_ms: Option<u64>,
1308    responses_reasoning: Option<ResponsesReasoningPolicy>,
1309}
1310
1311#[derive(Debug, Deserialize)]
1312#[serde(rename_all = "snake_case")]
1313enum ContextDiscovery {
1314    LlamaCpp,
1315}
1316
1317#[derive(Debug, Deserialize)]
1318#[serde(deny_unknown_fields)]
1319struct DecisionClientConfig {
1320    format: DecisionFormat,
1321    endpoint: HttpBaseUrl,
1322    api_key_env: String,
1323    timeout_ms: u64,
1324}
1325
1326#[derive(Debug, Deserialize)]
1327#[serde(rename_all = "snake_case")]
1328enum DecisionFormat {
1329    SystemOne,
1330    OpenaiDecisions,
1331}
1332
1333#[derive(Debug, Deserialize)]
1334#[serde(deny_unknown_fields)]
1335struct DecisionTargetConfig {
1336    #[serde(default)]
1337    labels: BTreeSet<String>,
1338    id: ModelId,
1339    decision_client: String,
1340}
1341
1342#[derive(Debug, Deserialize)]
1343#[serde(deny_unknown_fields)]
1344struct TargetConfig {
1345    #[serde(default)]
1346    labels: BTreeSet<String>,
1347    id: ModelId,
1348    llm_client: String,
1349    #[serde(default)]
1350    extra_body: BTreeMap<String, Value>,
1351    /// Top-level request fields dropped from the outbound body before `extra_body` is merged.
1352    /// For providers that reject an otherwise standard field.
1353    #[serde(default)]
1354    omit_body_fields: BTreeSet<String>,
1355    system_prompt: Option<String>,
1356    /// Reasoning effort forced on every request to this target, replacing the caller's value.
1357    reasoning_effort: Option<String>,
1358}
1359
1360#[derive(Clone, Copy, Debug, Deserialize)]
1361enum ClientFormat {
1362    #[serde(rename = "openai_chat")]
1363    OpenAiChat,
1364    #[serde(rename = "openai_responses")]
1365    OpenAiResponses,
1366    #[serde(rename = "anthropic_messages")]
1367    AnthropicMessages,
1368}
1369
1370impl ClientFormat {
1371    const fn wire_format(self) -> WireFormat {
1372        match self {
1373            Self::OpenAiChat => WireFormat::OpenAiChat,
1374            Self::OpenAiResponses => WireFormat::OpenAiResponses,
1375            Self::AnthropicMessages => WireFormat::AnthropicMessages,
1376        }
1377    }
1378
1379    const fn caller_auth_kind(self) -> CallerAuthKind {
1380        match self {
1381            Self::AnthropicMessages => CallerAuthKind::Anthropic,
1382            Self::OpenAiChat | Self::OpenAiResponses => CallerAuthKind::OpenAi,
1383        }
1384    }
1385}
1386
1387fn build_backend(
1388    client_name: &str,
1389    config: &LlmClientConfig,
1390    extra_body: &BTreeMap<String, Value>,
1391    omit_body_fields: &BTreeSet<String>,
1392    reasoning_effort: Option<String>,
1393) -> RunnerResult<Backend> {
1394    if config.max_retries > MAX_CONFIGURED_RETRIES {
1395        return Err(RunnerError::configuration(format!(
1396            "llm client {client_name} max_retries must be at most {MAX_CONFIGURED_RETRIES}"
1397        )));
1398    }
1399    if config.timeout_ms == Some(0) {
1400        return Err(RunnerError::configuration(format!(
1401            "llm client {client_name} timeout_ms must be at least 1"
1402        )));
1403    }
1404    if config.forward_auth && config.api_key_env.is_some() {
1405        return Err(RunnerError::configuration(format!(
1406            "llm client {client_name} cannot set both forward_auth and api_key_env"
1407        )));
1408    }
1409    let api_key = config
1410        .api_key_env
1411        .as_deref()
1412        .map(|variable| read_api_key(&format!("llm client {client_name}"), variable))
1413        .transpose()?;
1414    let http = HttpBackendConfig {
1415        base_url: config.base_url.as_str().to_string(),
1416        api_key,
1417        forward_auth: config.forward_auth,
1418        extra_headers: config.extra_headers.clone(),
1419        extra_body: extra_body.clone(),
1420        omit_body_fields: omit_body_fields.clone(),
1421        reasoning_effort,
1422        max_retries: config.max_retries,
1423        failure_cooldown: Duration::from_millis(config.failure_cooldown_ms),
1424        timeout: config.timeout_ms.map(Duration::from_millis),
1425    };
1426    let backend = match config.format {
1427        ClientFormat::OpenAiChat => Backend::OpenAiChat(http),
1428        ClientFormat::OpenAiResponses => Backend::OpenAiResponses(http),
1429        ClientFormat::AnthropicMessages => Backend::Anthropic(http),
1430    };
1431    Ok(backend)
1432}
1433
1434fn read_api_key(client: &str, variable: &str) -> RunnerResult<String> {
1435    if variable.trim().is_empty() {
1436        return Err(RunnerError::configuration(format!(
1437            "{client} api_key_env must not be empty"
1438        )));
1439    }
1440    // A name with `=` is often a pasted `NAME=key` line, so this error omits the name.
1441    if variable.contains(['\0', '=']) {
1442        return Err(RunnerError::configuration(format!(
1443            "{client} api_key_env must not contain '=' or NUL"
1444        )));
1445    }
1446    let api_key = std::env::var(variable).map_err(|error| {
1447        // VarError can contain the raw credential, so errors report only its category.
1448        let reason = match error {
1449            std::env::VarError::NotPresent => "environment variable not found",
1450            std::env::VarError::NotUnicode(_) => "environment variable is not valid Unicode",
1451        };
1452        RunnerError::configuration(format!(
1453            "{client} could not read api_key_env {variable}: {reason}"
1454        ))
1455    })?;
1456    if api_key.trim().is_empty() {
1457        return Err(RunnerError::configuration(format!(
1458            "{client} api_key_env {variable} is empty"
1459        )));
1460    }
1461    Ok(api_key)
1462}
1463
1464// A function so that serde default can use it.
1465const fn default_max_retries() -> u32 {
1466    DEFAULT_MAX_RETRIES
1467}
1468
1469const fn default_fallback_on_rate_limit() -> bool {
1470    true
1471}
1472
1473const fn default_failure_cooldown_ms() -> u64 {
1474    5000
1475}
1476
1477fn validate_value(label: &str, value: &str) -> RunnerResult<()> {
1478    if value.trim().is_empty() || value.trim() != value {
1479        return Err(RunnerError::configuration(format!(
1480            "{label} must be non-empty and have no surrounding whitespace"
1481        )));
1482    }
1483    Ok(())
1484}
1485
1486#[cfg(test)]
1487mod tests {
1488    use super::*;
1489
1490    #[test]
1491    fn route_presentation_fields_are_split_from_the_algorithm() {
1492        let route: RouteConfig = toml::from_str(
1493            r#"
1494type = "random"
1495id = "switchyard/random"
1496context_window = 128000
1497tool_calling = true
1498reasoning = false
1499targets = ["fast", "strong"]
1500weights = [1.0, 2.0]
1501seed = 7
1502"#,
1503        )
1504        .expect("route should deserialize");
1505
1506        assert_eq!(route.id, "switchyard/random");
1507        assert_eq!(route.context_window, Some(128_000));
1508        assert_eq!(route.tool_calling, Some(true));
1509        assert_eq!(route.reasoning, Some(false));
1510        assert_eq!(route.algorithm.routing_target_names(), ["fast", "strong"]);
1511    }
1512
1513    #[test]
1514    fn route_algorithm_unknown_fields_are_rejected() {
1515        let error = toml::from_str::<RouteConfig>(
1516            r#"
1517type = "passthrough"
1518id = "switchyard/fast"
1519target = "fast"
1520bogus = true
1521"#,
1522        )
1523        .expect_err("unknown route keys must be rejected");
1524
1525        assert!(error.to_string().contains("bogus"), "{error}");
1526    }
1527
1528    #[test]
1529    fn deployment_errors_keep_one_path_context() {
1530        let path = Path::new("/definitely/missing/switchyard-routes.toml");
1531        let error = match load_runner(path) {
1532            Ok(_) => panic!("missing config should fail"),
1533            Err(error) => error,
1534        };
1535        let message = error.to_string();
1536
1537        assert_eq!(message.matches(&path.display().to_string()).count(), 1);
1538        assert!(message.starts_with("failed to read server config"));
1539    }
1540}
1541#[cfg(test)]
1542mod deployment_tests {
1543    use super::*;
1544    use crate::require_target_labels;
1545    use serde_json::json;
1546    use switchyard_protocol::{Category, Request};
1547
1548    const VALID_CONFIG: &str = r#"
1549schema_version = 1
1550
1551[llm_clients.primary]
1552format = "openai_chat"
1553base_url = "https://example.test/v1"
1554
1555[llm_clients.responses]
1556format = "openai_responses"
1557base_url = "https://example.test/v1"
1558
1559[llm_clients.anthropic]
1560format = "anthropic_messages"
1561base_url = "https://example.test"
1562
1563[targets.classifier]
1564id = "classifier/model"
1565llm_client = "primary"
1566
1567[targets.strong]
1568id = "strong/model"
1569llm_client = "responses"
1570
1571[targets.weak]
1572id = "weak/model"
1573llm_client = "anthropic"
1574
1575[routes.noop]
1576id = "switchyard/noop"
1577type = "noop"
1578
1579[routes.random]
1580id = "switchyard/random"
1581type = "random"
1582targets = ["strong", "weak"]
1583
1584[routes.classifier]
1585id = "switchyard/classifier"
1586type = "llm_classifier"
1587classifier_target = "classifier"
1588strong_target = "strong"
1589weak_target = "weak"
1590base_threshold = 0.5
1591
1592[routes.passthrough]
1593id = "switchyard/passthrough"
1594type = "passthrough"
1595target = "weak"
1596
1597[routes.plan_execute]
1598id = "switchyard/plan-execute"
1599type = "stage_router"
1600capable_target = "strong"
1601efficient_target = "weak"
1602picker = "capable_first"
1603confidence_threshold = 0.0
1604efficient_hold_turns = -1
1605capable_system_prompt = "Inspect the task and plan before modifying files."
1606"#;
1607
1608    fn decision_eligibility_config() -> String {
1609        let config = VALID_CONFIG
1610            .replace(
1611                "id = \"strong/model\"",
1612                "id = \"strong/model\"\nlabels = ['private']",
1613            )
1614            .replace(
1615                "id = \"weak/model\"",
1616                "id = \"weak/model\"\nlabels = ['private']",
1617            );
1618        format!(
1619            r#"{config}
1620
1621[decision_clients.privacy]
1622format = "system_one"
1623endpoint = "https://example.test/v1/systemone"
1624api_key_env = "PATH"
1625timeout_ms = 5000
1626
1627[decision_targets.standard]
1628id = "decision/standard"
1629decision_client = "privacy"
1630labels = ["private"]
1631
1632[routes.typed]
1633id = "switchyard/typed"
1634type = "llm_classifier"
1635mode = "capability"
1636classifier_target = "standard"
1637strong_target = "strong"
1638weak_target = "weak"
1639
1640[routes.typed.decision]
1641cutoff = 0.4
1642
1643[routes.typed.eligibility]
1644"#
1645        )
1646    }
1647
1648    fn eligible_pool_config() -> String {
1649        let config = VALID_CONFIG
1650            .replace("id = \"strong/model\"", "id = \"strong/model\"\nlabels = ['private']")
1651            .replace(
1652                "type = \"passthrough\"\ntarget = \"weak\"",
1653                "type = \"random\"\ntargets = [\"weak\", \"strong\"]\nweights = [3.0, 1.0]\nseed = 7",
1654            );
1655        format!(
1656            r#"{config}
1657
1658[routes.passthrough.eligibility]
1659"#
1660        )
1661    }
1662
1663    #[tokio::test]
1664    async fn eligibility_requires_approved_roles_and_decision_targets() -> RunnerResult<()> {
1665        let configured = decision_eligibility_config();
1666        let runner = Runner::from_toml(&configured)?;
1667        let route = runner
1668            .route("switchyard/typed")
1669            .expect("typed eligible route should exist");
1670        assert_eq!(
1671            route.models().models_for(&Category::Judge),
1672            [ModelId::from("decision/standard")]
1673        );
1674
1675        for (config, expected) in [
1676            (
1677                configured.replace(
1678                    "decision_client = \"privacy\"\nlabels = [\"private\"]",
1679                    "decision_client = \"privacy\"",
1680                ),
1681                "required target standard",
1682            ),
1683            (
1684                configured.replace(
1685                    "id = \"weak/model\"\nlabels = ['private']",
1686                    "id = \"weak/model\"",
1687                ),
1688                "required target weak",
1689            ),
1690        ] {
1691            let runner = Runner::from_toml(&config)?;
1692            let mut request = Request::default();
1693            require_target_labels(&mut request, ["private"])?;
1694            let message = runner
1695                .route("switchyard/typed")
1696                .expect("typed route")
1697                .decide(request)
1698                .await
1699                .err()
1700                .expect("unapproved role must fail before model I/O")
1701                .to_string();
1702            assert!(
1703                message.contains(expected),
1704                "expected {expected:?}, got {message}"
1705            );
1706        }
1707        Ok(())
1708    }
1709
1710    #[tokio::test]
1711    async fn label_requirements_select_only_approved_targets() -> RunnerResult<()> {
1712        let configured = eligible_pool_config();
1713        let runner = Runner::from_toml(&configured)?;
1714        let route = runner
1715            .route("switchyard/passthrough")
1716            .expect("eligible route should exist");
1717
1718        let mut request = Request::default();
1719        require_target_labels(&mut request, ["private"])?;
1720        let outcome = route.decide(request).await?;
1721        assert_eq!(outcome.selected_model_id()?, "strong");
1722        assert_eq!(
1723            &runner
1724                .describe_decision(&ModelId::from("switchyard/passthrough"), &outcome)
1725                .expect("selected target metadata")
1726                .selected
1727                .target,
1728            outcome.selected_model_id()?.as_str()
1729        );
1730        for (config, route_id, expected) in [
1731            (
1732                configured.replace("labels = ['private']", "labels = []"),
1733                "switchyard/passthrough",
1734                "without an eligible target",
1735            ),
1736            (
1737                format!("{configured}\n[routes.plan_execute.eligibility]\n"),
1738                "switchyard/plan-execute",
1739                "required target weak",
1740            ),
1741        ] {
1742            let runner = Runner::from_toml(&config)?;
1743            let mut request = Request::default();
1744            require_target_labels(&mut request, ["private"])?;
1745            let error = runner
1746                .route(route_id)
1747                .expect("configured route")
1748                .decide(request)
1749                .await
1750                .err()
1751                .expect("no eligible setup");
1752            assert!(error.to_string().contains(expected), "{error}");
1753        }
1754        Ok(())
1755    }
1756
1757    #[tokio::test]
1758    async fn ordinary_routes_reject_label_requirements() -> RunnerResult<()> {
1759        let runner = Runner::from_toml(VALID_CONFIG)?;
1760        let ordinary = runner.route("switchyard/random").expect("ordinary route");
1761        for labels in [&[][..], &["private"][..]] {
1762            let mut request = Request::default();
1763            require_target_labels(&mut request, labels)?;
1764            assert!(ordinary.decide(request).await.is_err());
1765        }
1766        Ok(())
1767    }
1768
1769    fn alternative_config(algorithm: &str) -> String {
1770        let globals = VALID_CONFIG.split("[routes.noop]").next().expect("globals");
1771        let mut config = globals.to_string();
1772        for name in ["weak", "strong", "classifier"] {
1773            config.push_str(&format!(
1774                "\n[targets.approved_{name}]\nid = 'approved/{name}'\nllm_client = 'primary'\nlabels = ['private']\n"
1775            ));
1776        }
1777        config.push_str(&format!(
1778            "\n[routes.test]\nid = 'route'\n{algorithm}\n[routes.test.eligibility.target_alternatives]\n"
1779        ));
1780        let spec: AlgorithmSpec = toml::from_str(algorithm).expect("test algorithm");
1781        for name in spec
1782            .callable_target_names()
1783            .into_iter()
1784            .collect::<BTreeSet<_>>()
1785        {
1786            config.push_str(&format!("{name} = ['approved_{name}']\n"));
1787        }
1788        config
1789    }
1790
1791    // Alternatives preserve fixed roles and selected-target metadata.
1792    #[tokio::test]
1793    async fn alternatives_preserve_router_roles() -> RunnerResult<()> {
1794        for (algorithm, initial) in [
1795            ("type = 'passthrough'\ntarget = 'weak'", "weak"),
1796            (
1797                "type = 'stage_router'\ncapable_target = 'strong'\nefficient_target = 'weak'\npicker = 'capable_first'\nconfidence_threshold = 0.0",
1798                "strong",
1799            ),
1800        ] {
1801            let runner = Runner::from_toml(&alternative_config(algorithm))?;
1802            let route = runner.route("route").expect("route");
1803            let standard = route.decide(Request::default()).await?;
1804            assert_eq!(standard.selected_model_id()?.as_str(), initial);
1805            let mut request = Request::default();
1806            require_target_labels(&mut request, ["private"])?;
1807            let outcome = route.decide(request).await?;
1808            assert_eq!(outcome.selected_model_id()?, &format!("approved_{initial}"));
1809            assert!(
1810                outcome
1811                    .selected_model_ids
1812                    .iter()
1813                    .all(|model| model.starts_with("approved_"))
1814            );
1815            let description = runner
1816                .describe_decision(&ModelId::from("route"), &outcome)
1817                .expect("mapped metadata");
1818            assert_eq!(description.selected.target, format!("approved_{initial}"));
1819        }
1820        Ok(())
1821    }
1822
1823    // Invalid references fail at load; unavailable approved bindings fail at admission.
1824    #[tokio::test]
1825    async fn alternatives_reject_invalid_bindings() -> RunnerResult<()> {
1826        let config = alternative_config(
1827            "type = 'auto'\ncapable_target = 'strong'\nefficient_target = 'weak'",
1828        );
1829        for (invalid, expected) in [
1830            (
1831                format!("{config}typo = ['approved_weak']\n"),
1832                "not used by the algorithm",
1833            ),
1834            (
1835                config.replace("weak = ['approved_weak']", "weak = ['missing']"),
1836                "must exist and match the target kind",
1837            ),
1838            (
1839                config.replace("weak = ['approved_weak']", "weak = []"),
1840                "target alternatives must not be empty",
1841            ),
1842            (
1843                config.replace(
1844                    "[llm_clients.primary]",
1845                    "[llm_clients.primary]\nforward_auth = true",
1846                ),
1847                "forwarded caller authentication",
1848            ),
1849        ] {
1850            let message = error_message(&invalid);
1851            assert!(
1852                message.contains(expected),
1853                "expected {expected:?}, got {message}"
1854            );
1855        }
1856        let random =
1857            alternative_config("type = 'random'\ntargets = ['weak', 'strong']\nweights = [0, 1]")
1858                .replace("strong = ['approved_strong']", "strong = ['approved_weak']");
1859        for (source, expected) in [
1860            (
1861                config.replace("weak = ['approved_weak']", "weak = ['strong']"),
1862                "required target weak",
1863            ),
1864            (random, "multiple pool entries to target approved_weak"),
1865        ] {
1866            let runner = Runner::from_toml(&source)?;
1867            let mut request = Request::default();
1868            require_target_labels(&mut request, ["private"])?;
1869            let error = runner
1870                .route("route")
1871                .expect("configured route")
1872                .decide(request)
1873                .await
1874                .err()
1875                .expect("invalid eligible bindings");
1876            assert!(error.to_string().contains(expected), "{error}");
1877        }
1878        Runner::from_toml(&config.replace("id = 'approved/weak'", "id = 'weak/model'"))?;
1879        let shared_model =
1880            alternative_config("type = 'random'\ntargets = ['weak', 'strong']\nweights = [0, 1]")
1881                .replace("id = 'approved/strong'", "id = 'approved/weak'");
1882        let runner = Runner::from_toml(&shared_model)?;
1883        let mut request = Request::default();
1884        require_target_labels(&mut request, ["private"])?;
1885        assert_eq!(
1886            runner
1887                .route("route")
1888                .expect("route")
1889                .decide(request)
1890                .await?
1891                .selected_model_id()?,
1892            "approved_strong"
1893        );
1894        let wrong_kind = format!(
1895            "{}\n[routes.typed.eligibility.target_alternatives]\nstandard = ['weak']\n",
1896            decision_eligibility_config()
1897        );
1898        assert!(error_message(&wrong_kind).contains("must exist and match the target kind"));
1899        Ok(())
1900    }
1901
1902    // A secondary verifier must not inherit the answer target's prompt after binding.
1903    #[tokio::test]
1904    async fn alternatives_reject_prompted_vgr_secondary_judge_alias() -> RunnerResult<()> {
1905        let source = alternative_config(
1906            "type = 'vgr'\nefficient_target = 'weak'\ncapable_target = 'strong'\njudge_target = 'strong'\ncapable_judge_target = 'classifier'",
1907        )
1908        .replace(
1909            "classifier = ['approved_classifier']",
1910            "classifier = ['approved_weak']",
1911        )
1912        .replace(
1913            "[targets.approved_weak]",
1914            "[targets.approved_weak]\nsystem_prompt = 'Answer the user.'",
1915        );
1916        let runner = Runner::from_toml(&source)?;
1917        let mut request = Request::default();
1918        require_target_labels(&mut request, ["private"])?;
1919        let error = runner
1920            .route("route")
1921            .expect("VGR route")
1922            .decide(request)
1923            .await
1924            .err()
1925            .expect("secondary judge alias must fail before model I/O");
1926        assert!(matches!(error, RunnerError::Configuration { .. }));
1927        assert!(error.to_string().contains("routing-only target"), "{error}");
1928        Ok(())
1929    }
1930
1931    // Decision IDs remain provider keys, so bindings cannot give one ID two clients.
1932    #[tokio::test]
1933    async fn alternatives_reject_ambiguous_composite_decision_clients() -> RunnerResult<()> {
1934        let source = decision_eligibility_config().replace(
1935            "decision_client = \"privacy\"\nlabels = [\"private\"]",
1936            "decision_client = \"privacy\"",
1937        );
1938        let source = format!(
1939            r#"{source}
1940[decision_clients.other]
1941format = "system_one"
1942endpoint = "http://127.0.0.1:1/decisions"
1943api_key_env = "PATH"
1944timeout_ms = 1
1945
1946[decision_targets.secondary]
1947id = "decision/secondary"
1948decision_client = "privacy"
1949
1950[decision_targets.approved_primary]
1951id = "decision/shared"
1952decision_client = "privacy"
1953labels = ["private"]
1954
1955[decision_targets.approved_secondary]
1956id = "decision/shared"
1957decision_client = "other"
1958labels = ["private"]
1959
1960[routes.ambiguous]
1961id = "switchyard/ambiguous"
1962type = "composite"
1963classifier = {{ target = "standard", classify_trigger = "user_turn", decision = {{ cutoff = 0.4 }} }}
1964stage = {{ capable_target = "strong", efficient_target = "weak", confidence_threshold = 0.5 }}
1965efficient = {{ classifier = {{ target = "secondary", decision = {{ cutoff = 0.4 }} }} }}
1966
1967[routes.ambiguous.eligibility.target_alternatives]
1968standard = ["approved_primary"]
1969secondary = ["approved_secondary"]
1970"#
1971        );
1972        let runner = Runner::from_toml(&source)?;
1973        let mut request = Request::default();
1974        require_target_labels(&mut request, ["private"])?;
1975        let error = runner
1976            .route("switchyard/ambiguous")
1977            .expect("composite route")
1978            .decide(request)
1979            .await
1980            .err()
1981            .expect("ambiguous decision clients must fail before model I/O");
1982        assert!(matches!(error, RunnerError::Configuration { .. }));
1983        assert!(
1984            error
1985                .to_string()
1986                .contains("maps decision model decision/shared to different clients"),
1987            "{error}"
1988        );
1989        Ok(())
1990    }
1991
1992    #[test]
1993    fn eligibility_rejects_unknown_fields_and_invalid_scope() {
1994        let configured = eligible_pool_config();
1995        for (invalid, expected) in [
1996            (
1997                configured.replace("labels = ['private']", "label = ['private']"),
1998                "unknown field `label`",
1999            ),
2000            (
2001                configured.replace(
2002                    "[routes.passthrough.eligibility]",
2003                    "[routes.passthrough.eligible]",
2004                ),
2005                "unknown field `eligible`",
2006            ),
2007            (
2008                decision_eligibility_config().replace(
2009                    "[decision_targets.standard]",
2010                    "[decision_targets.standard]\nlabel = ['private']",
2011                ),
2012                "unknown field `label`",
2013            ),
2014            (
2015                format!("{configured}\nrequire_explicit_requirement = true\n"),
2016                "unknown field `require_explicit_requirement`",
2017            ),
2018            (
2019                format!("{configured}\nscope = 'tenant'\n"),
2020                "unknown variant `tenant`",
2021            ),
2022        ] {
2023            let message = error_message(&invalid);
2024            assert!(
2025                message.contains(expected),
2026                "expected {expected:?}, got {message}"
2027            );
2028        }
2029    }
2030
2031    #[test]
2032    fn eligibility_rejects_conflicting_approvals_and_forwarded_auth() {
2033        let configured = eligible_pool_config();
2034        for (invalid, expected) in [
2035            (
2036                format!(
2037                    "{configured}\n[targets.alias]\nid = \"strong/model\"\nllm_client = \"responses\"\n"
2038                ),
2039                "disagree on approval labels",
2040            ),
2041            (
2042                format!(
2043                    "{}\n[decision_targets.alias]\nid = \"decision/standard\"\ndecision_client = \"privacy\"\n",
2044                    decision_eligibility_config()
2045                ),
2046                "disagrees on approval labels",
2047            ),
2048            (
2049                configured.replace(
2050                    "[llm_clients.responses]\nformat = \"openai_responses\"",
2051                    "[llm_clients.responses]\nformat = \"openai_responses\"\nforward_auth = true",
2052                ),
2053                "cannot use eligibility with forwarded caller authentication",
2054            ),
2055            (
2056                alternative_config(
2057                    "type = 'prefill_router'\ntargets = ['weak']\ncheckpoint = 'unused'",
2058                ),
2059                "cannot use eligibility with prefill_router",
2060            ),
2061        ] {
2062            let message = error_message(&invalid);
2063            assert!(
2064                message.contains(expected),
2065                "expected {expected:?}, got {message}"
2066            );
2067        }
2068    }
2069
2070    #[test]
2071    fn public_runner_from_toml_builds_a_deployment() -> RunnerResult<()> {
2072        let config = format!(
2073            "{VALID_CONFIG}\n[routes.classifier.infra]\n\
2074             max_signal_age_ms = 1000\nmin_waiting_requests = 4\nmax_capable_inflight = 2"
2075        );
2076        let runner = Runner::from_toml(&config)?;
2077        assert!(
2078            Runner::from_toml(
2079                &config.replace("min_waiting_requests = 4", "min_waiting_requests = 0")
2080            )
2081            .is_err()
2082        );
2083
2084        let classifier = runner
2085            .route("switchyard/classifier")
2086            .expect("classifier route should exist");
2087        let models = classifier.models();
2088        assert_eq!(
2089            models.models_for(&Category::Judge),
2090            [ModelId::from("classifier")]
2091        );
2092        assert_eq!(
2093            models.models_for(&Category::Efficient),
2094            [ModelId::from("weak")]
2095        );
2096        assert_eq!(
2097            models.models_for(&Category::Capable),
2098            [ModelId::from("strong")]
2099        );
2100        assert_eq!(
2101            models.models_for(&Category::Any),
2102            [ModelId::from("weak"), ModelId::from("strong")]
2103        );
2104        let plan_execute = runner
2105            .route("switchyard/plan-execute")
2106            .expect("plan-execute route should exist");
2107        assert_eq!(
2108            plan_execute.models().models_for(&Category::Capable),
2109            [ModelId::from("strong")]
2110        );
2111        assert_eq!(
2112            plan_execute.models().models_for(&Category::Efficient),
2113            [ModelId::from("weak")]
2114        );
2115        assert!(runner.route("switchyard/passthrough").is_some());
2116        Ok(())
2117    }
2118
2119    /// Both the TOML and JSON parsers must reject the removed `plan_execute` type.
2120    #[test]
2121    fn removed_plan_execute_type_is_rejected() {
2122        let route = r#"
2123type = "plan_execute"
2124capable_target = "strong"
2125efficient_target = "weak"
2126"#;
2127        let config = format!("{VALID_CONFIG}\n[routes.obsolete]\nid = \"obsolete\"\n{route}");
2128        assert!(error_message(&config).contains("unknown variant `plan_execute`"));
2129
2130        let error = serde_json::from_str::<AlgorithmSpec>(
2131            r#"{"type":"plan_execute","capable_target":"strong","efficient_target":"weak"}"#,
2132        )
2133        .expect_err("the removed algorithm must be rejected");
2134        assert!(error.to_string().contains("unknown variant `plan_execute`"));
2135    }
2136
2137    /// TOML and JSON parsers must reject old `plan_execute` fields on `stage_router` routes.
2138    /// Changing only the route type must not load a route that ignores its handoff settings.
2139    #[test]
2140    fn stage_rejects_obsolete_plan_execute_settings() {
2141        let stage = json!({
2142            "type": "stage_router", "capable_target": "strong", "efficient_target": "weak",
2143            "picker": "capable_first", "confidence_threshold": 0.0,
2144            "efficient_hold_turns": -1, "capable_system_prompt": "Plan before editing.",
2145        });
2146        assert!(serde_json::from_str::<AlgorithmSpec>(&stage.to_string()).is_ok());
2147        for (field, value, toml_value) in [
2148            ("planning_prompt", json!("plan"), r#""plan""#),
2149            ("handoff_prompt", json!("execute"), r#""execute""#),
2150            ("planner_reasoning_as_text", json!(true), "true"),
2151            ("execution_trigger", json!("edit"), r#""edit""#),
2152            (
2153                "judge",
2154                json!({"target": "classifier"}),
2155                r#"{ target = "classifier" }"#,
2156            ),
2157        ] {
2158            let mut invalid = stage.clone();
2159            invalid[field] = value;
2160            let error = serde_json::from_str::<AlgorithmSpec>(&invalid.to_string())
2161                .expect_err("obsolete fields must be rejected");
2162            assert!(error.to_string().contains(field), "{error}");
2163            let error = error_message(&format!("{VALID_CONFIG}\n{field} = {toml_value}\n"));
2164            assert!(error.contains(field), "{error}");
2165        }
2166    }
2167
2168    #[test]
2169    fn duplicate_route_ids_are_rejected() {
2170        let config = format!(
2171            r#"{VALID_CONFIG}
2172
2173[routes.duplicate]
2174id = "switchyard/passthrough"
2175type = "passthrough"
2176target = "strong"
2177"#
2178        );
2179
2180        let error = error_message(&config);
2181
2182        assert!(
2183            error.contains(
2184                "routes duplicate and passthrough both use id switchyard/passthrough; route ids must be unique"
2185            ),
2186            "{error}"
2187        );
2188    }
2189
2190    fn error_message(toml: &str) -> String {
2191        match runner_from_toml(toml) {
2192            Ok(_) => "configuration unexpectedly succeeded".to_string(),
2193            Err(error) => error.to_string(),
2194        }
2195    }
2196
2197    fn vgr_config() -> String {
2198        format!(
2199            r#"{VALID_CONFIG}
2200
2201[routes.vgr]
2202id = "switchyard/vgr"
2203type = "vgr"
2204efficient_target = "weak"
2205capable_target = "strong"
2206judge_target = "classifier"
2207mode = "active"
2208active_approval = "prospective-validation-and-canary-approved"
2209task_typing = false
2210"#
2211        )
2212    }
2213
2214    #[test]
2215    fn vgr_compaction_is_opt_in_from_toml() -> RunnerResult<()> {
2216        for setting in ["", "compact_handoff = false", "compact_handoff = true"] {
2217            let source = format!("{}\nagentic_handoff = true\n{setting}\n", vgr_config());
2218            let parsed: DeploymentConfig = toml::from_str(&source).expect("valid VGR config");
2219            let AlgorithmSpec::Vgr { config } = &parsed.routes["vgr"].algorithm else {
2220                panic!("expected VGR route");
2221            };
2222            assert_eq!(config.compact_handoff, setting.ends_with("= true"));
2223            assert!(runner_from_toml(&source)?.route("switchyard/vgr").is_some());
2224        }
2225        Ok(())
2226    }
2227
2228    #[test]
2229    fn vgr_route_builds_and_rejects_unsafe_configuration() -> RunnerResult<()> {
2230        let runner = runner_from_toml(&vgr_config())?;
2231        assert!(runner.route("switchyard/vgr").is_some());
2232
2233        let compact = format!("{}\ncompact_handoff = true\n", vgr_config());
2234        assert!(error_message(&compact).contains("compact_handoff requires agentic_handoff"));
2235        let compact = format!("{compact}\nagentic_handoff = true\n");
2236        assert!(
2237            runner_from_toml(&compact)?
2238                .route("switchyard/vgr")
2239                .is_some()
2240        );
2241
2242        let identical_ids = vgr_config().replace(
2243            "efficient_target = \"weak\"",
2244            "efficient_target = \"strong\"",
2245        );
2246        assert!(error_message(&identical_ids).contains("distinct efficient and capable model IDs"));
2247
2248        let bad_approval =
2249            vgr_config().replace("prospective-validation-and-canary-approved", "approved");
2250        assert!(error_message(&bad_approval).contains("approval attestation"));
2251
2252        let missing_target = vgr_config().replace(
2253            "efficient_target = \"weak\"",
2254            "efficient_target = \"missing\"",
2255        );
2256        assert!(error_message(&missing_target).contains("unknown target missing"));
2257
2258        let zero_cooldown = format!("{}breaker_cooldown_seconds = 0\n", vgr_config());
2259        assert!(
2260            error_message(&zero_cooldown).contains("breaker_cooldown_seconds must be a positive")
2261        );
2262        Ok(())
2263    }
2264
2265    #[test]
2266    fn vgr_local_server_example_builds_and_rejects_unknown_discovery() -> RunnerResult<()> {
2267        let example = include_str!("../../../examples/vgr-local-servers.toml");
2268        let runner = runner_from_toml(example)?;
2269        assert!(runner.route("switchyard/vgr").is_some());
2270        assert!(runner.route("switchyard/only-efficient").is_some());
2271        let parsed: DeploymentConfig = toml::from_str(example).expect("valid VGR example");
2272        assert!(matches!(
2273            &parsed.routes["auxiliary"].algorithm,
2274            AlgorithmSpec::Passthrough { target, .. } if target == "efficient"
2275        ));
2276        let invalid = example.replace(
2277            "context_discovery = \"llama_cpp\"",
2278            "context_discovery = \"guess\"",
2279        );
2280        assert!(error_message(&invalid).contains("unknown variant `guess`"));
2281        Ok(())
2282    }
2283
2284    fn with_subagent_llm_classifier(config: &str, route: &str, extra: &str) -> String {
2285        let mut configured = config.to_string();
2286        configured.push_str(&format!("\n[routes.{route}.subagents]\n"));
2287        configured.push_str(
2288            r#"type = "llm_classifier"
2289mode = "custom"
2290models = { judge = ["classifier"], capable = ["strong"], efficient = ["weak"], any = ["strong", "weak"] }
2291default_target = "efficient"
2292prompt = "Select a target for this delegated task."
2293response_schema = '{"type":"object","properties":{"target":{"type":"string","enum":["capable","efficient"]}},"required":["target"],"additionalProperties":false}'
2294policy = { type = "target_selector", selector = "/target" }
2295classify_trigger = "new_session""#,
2296        );
2297        configured.push_str(extra);
2298        configured
2299    }
2300
2301    #[test]
2302    fn subagent_models_stay_separate_from_the_parent_tiers() -> RunnerResult<()> {
2303        // The sub-agent target is also the parent's capable tier. Merged into one group it
2304        // would be indistinguishable from that tier, and delegated work would follow the
2305        // parent's ordering instead of its own configured target.
2306        let runner = runner_from_toml(&with_subagent_passthrough(&stage_config(), "stage"))?;
2307        let models = runner
2308            .route("switchyard/stage")
2309            .expect("stage route should exist")
2310            .models();
2311
2312        assert_eq!(
2313            models.subagent_models_for(&Category::Any),
2314            [ModelId::from("strong")]
2315        );
2316        assert_eq!(
2317            models.models_for(&Category::Any),
2318            [ModelId::from("strong"), ModelId::from("weak")]
2319        );
2320        Ok(())
2321    }
2322
2323    fn with_subagent_passthrough(config: &str, route: &str) -> String {
2324        format!("{config}\n[routes.{route}.subagents]\ntype = \"passthrough\"\ntarget = \"strong\"")
2325    }
2326
2327    fn stage_config() -> String {
2328        format!(
2329            r#"{VALID_CONFIG}
2330[targets.stage_judge]
2331id = "stage-judge/model"
2332llm_client = "primary"
2333
2334[routes.stage]
2335id = "switchyard/stage"
2336type = "stage_router"
2337capable_target = "strong"
2338efficient_target = "weak"
2339picker = "efficient_first"
2340confidence_threshold = 1.0
2341capable_hold_turns = 2
2342
2343[routes.stage.tool_semantics]
2344observe = ["lookup_customer"]
2345mutate = ["send_payment"]
2346plan = ["create_workflow"]
2347new = ["send_message"]
2348
2349[routes.stage.classifier]
2350target = "stage_judge"
2351base_threshold = 0.5
2352"#
2353        )
2354    }
2355
2356    fn composite_config() -> String {
2357        format!(
2358            r#"{VALID_CONFIG}
2359[targets.tier_judge]
2360id = "tier-judge/model"
2361llm_client = "primary"
2362
2363[routes.composed]
2364id = "switchyard/hier"
2365type = "composite"
2366
2367[routes.composed.classifier]
2368target = "tier_judge"
2369base_threshold = 0.5
2370classify_trigger = "user_turn"
2371
2372[routes.composed.stage]
2373capable_target = "strong"
2374efficient_target = "weak"
2375confidence_threshold = 0.5
2376capable_hold_turns = 2
2377
2378[routes.composed.stage.tool_semantics]
2379new = ["send_message"]
2380"#
2381        )
2382    }
2383
2384    #[test]
2385    fn stage_and_composite_validate_hold_turns() {
2386        let composite =
2387            composite_config().replace("capable_hold_turns = 2", "capable_hold_turns = 0");
2388        for source in [
2389            stage_config(),
2390            stage_config().replace("efficient_first", "capable_first"),
2391            composite_config(),
2392            format!("{composite}\n[routes.composed.efficient]\ncapable_hold_turns = 2"),
2393            format!("{composite}\n[routes.composed.capable]\ncapable_hold_turns = 2"),
2394        ] {
2395            for field in ["capable_hold_turns", "efficient_hold_turns"] {
2396                for (turns, valid) in [
2397                    (-2_i64, false),
2398                    (-1, true),
2399                    (0, true),
2400                    (3, true),
2401                    (i64::from(u32::MAX), true),
2402                    (i64::from(u32::MAX) + 1, false),
2403                ] {
2404                    let configured =
2405                        source.replace("capable_hold_turns = 2", &format!("{field} = {turns}"));
2406                    let result = AlgorithmFactory::from_toml(&configured)
2407                        .and_then(|factory| factory.build());
2408                    assert_eq!(result.is_ok(), valid, "{field} = {turns}");
2409                    if let Err(error) = result {
2410                        assert!(error.to_string().contains(field), "{error}");
2411                    }
2412                }
2413            }
2414        }
2415    }
2416
2417    #[test]
2418    fn composite_decision_judge_reuses_capability_validation() {
2419        let source = composite_config().replace(
2420            "[routes.composed.classifier]\ntarget = \"tier_judge\"\nbase_threshold = 0.5",
2421            "[routes.composed.classifier]\ntarget = \"tier_judge\"",
2422        ) + r#"
2423[routes.composed.classifier.decision]
2424cutoff = 0.4
2425instructions = "candidate comparison"
2426"#;
2427        for (source, valid) in [
2428            (source.clone(), true),
2429            (
2430                source.replace(
2431                    "target = \"tier_judge\"",
2432                    "target = \"tier_judge\"\nbase_threshold = 0.5",
2433                ),
2434                false,
2435            ),
2436            (source.replace("cutoff = 0.4", "cutoff = 1.1"), false),
2437        ] {
2438            let deployment: DeploymentConfig = toml::from_str(&source).unwrap();
2439            let spec = &deployment.routes["composed"].algorithm;
2440            assert_eq!(spec.classifier_targets().next().unwrap().0, "tier_judge");
2441            assert_eq!(
2442                spec.build_with_runtime_models("composed", &deployment.build_targets())
2443                    .is_ok(),
2444                valid
2445            );
2446        }
2447    }
2448
2449    #[test]
2450    fn decision_target_judges_use_default_decision_settings() -> RunnerResult<()> {
2451        let source = format!(
2452            r#"{VALID_CONFIG}
2453[decision_clients.decisions]
2454format = "openai_decisions"
2455endpoint = "https://example.test/v1/decisions"
2456api_key_env = "PATH"
2457timeout_ms = 5000
2458
2459[decision_targets.judge]
2460id = "decision/judge"
2461decision_client = "decisions"
2462
2463[routes.judged]
2464id = "switchyard/judged"
2465type = "llm_classifier"
2466classifier_target = "judge"
2467strong_target = "strong"
2468weak_target = "weak"
2469
2470[routes.staged]
2471id = "switchyard/staged"
2472type = "stage_router"
2473capable_target = "strong"
2474efficient_target = "weak"
2475picker = "efficient_first"
2476confidence_threshold = 1.0
2477
2478[routes.staged.classifier]
2479target = "judge"
2480
2481[routes.composed]
2482id = "switchyard/composed"
2483type = "composite"
2484
2485[routes.composed.classifier]
2486target = "judge"
2487classify_trigger = "user_turn"
2488
2489[routes.composed.stage]
2490capable_target = "strong"
2491efficient_target = "weak"
2492
2493[routes.composed.efficient.classifier]
2494target = "judge"
2495"#
2496        );
2497        let factory = AlgorithmFactory::from_toml(&source)?;
2498        let routes = &factory.config.routes;
2499        for route in ["judged", "staged", "composed"] {
2500            let judges: Vec<_> = routes[route].algorithm.classifier_targets().collect();
2501            assert!(!judges.is_empty(), "{route}");
2502            assert!(
2503                judges
2504                    .iter()
2505                    .all(|(target, decision)| *target == "judge" && decision.is_some()),
2506                "{route}"
2507            );
2508        }
2509        // LLM judges keep their capability settings.
2510        assert!(
2511            routes["classifier"]
2512                .algorithm
2513                .classifier_targets()
2514                .all(|(_, d)| d.is_none())
2515        );
2516        factory.build()?;
2517        runner_from_toml(&source)?;
2518        Ok(())
2519    }
2520
2521    #[test]
2522    fn composite_custom_judge_builds_and_rejects_capability_settings() -> RunnerResult<()> {
2523        let source = composite_config().replace(
2524            "base_threshold = 0.5\nclassify_trigger",
2525            r#"mode = "custom"
2526prompt = 'Select efficient or capable.'
2527response_schema = '{"type":"object","properties":{"decision":{"type":"object","properties":{"target":{"type":"string","enum":["efficient","capable"]}},"required":["target"],"additionalProperties":false}},"required":["decision"],"additionalProperties":false}'
2528policy = { type = "target_selector", selector = "/decision/target" }
2529classify_trigger"#,
2530        );
2531        runner_from_toml(&source)?;
2532        runner_from_toml(
2533            &source.replace("mode = \"custom\"", "mode = \"custom\"\nfail_open = true"),
2534        )?;
2535        for (setting, expected) in [
2536            (
2537                "base_threshold = 0.5",
2538                "composite custom classifier cannot use capability thresholds",
2539            ),
2540            (
2541                "fail_open = false",
2542                "composite custom classifiers always fail open",
2543            ),
2544        ] {
2545            let invalid = source.replace(
2546                "mode = \"custom\"",
2547                &format!("mode = \"custom\"\n{setting}"),
2548            );
2549            assert!(error_message(&invalid).contains(expected));
2550        }
2551        Ok(())
2552    }
2553
2554    #[test]
2555    fn composite_route_builds_and_claims_both_tiers_and_its_judge() -> RunnerResult<()> {
2556        let runner = runner_from_toml(&composite_config())?;
2557        assert!(
2558            runner
2559                .models()
2560                .any(|model| model.id.as_str() == "switchyard/hier")
2561        );
2562        Ok(())
2563    }
2564
2565    #[test]
2566    fn builds_all_supported_algorithm_types() -> RunnerResult<()> {
2567        let state = runner_from_toml(VALID_CONFIG)?;
2568        // The model id array is sorted alphabetically
2569        assert_eq!(
2570            state
2571                .models()
2572                .map(|model| model.id.as_str())
2573                .collect::<Vec<_>>(),
2574            [
2575                "switchyard/classifier",
2576                "switchyard/noop",
2577                "switchyard/passthrough",
2578                "switchyard/plan-execute",
2579                "switchyard/random",
2580            ]
2581        );
2582        Ok(())
2583    }
2584
2585    #[test]
2586    fn stage_rejects_ambiguous_or_non_additive_tool_semantics() {
2587        for (configured, expected) in [
2588            (
2589                stage_config().replace(
2590                    "mutate = [\"send_payment\"]",
2591                    "mutate = [\"LOOKUP_CUSTOMER\"]",
2592                ),
2593                "appears in both tool_semantics.observe and tool_semantics.mutate",
2594            ),
2595            (
2596                stage_config().replace("new = [\"send_message\"]", "new = [\"Read\"]"),
2597                "already has built-in semantics and cannot be reclassified",
2598            ),
2599            (
2600                stage_config().replace("observe = [\"lookup_customer\"]", "observe = [\" \"]"),
2601                "contains an empty tool name",
2602            ),
2603        ] {
2604            let message = error_message(&configured);
2605            assert!(
2606                message.contains(expected),
2607                "expected {expected:?} in error, got: {message}"
2608            );
2609        }
2610    }
2611
2612    #[test]
2613    fn passthrough_and_stage_accept_subagent_routing() -> RunnerResult<()> {
2614        let stage = stage_config();
2615        let stage_with_classifier = with_subagent_llm_classifier(&stage, "stage", "");
2616        let parsed: DeploymentConfig = toml::from_str(&stage_with_classifier).map_err(|error| {
2617            RunnerError::configuration(format!("failed to parse stage config: {error}"))
2618        })?;
2619        let Some(stage_route) = parsed.routes.get("stage") else {
2620            return Err(RunnerError::configuration("stage route is missing"));
2621        };
2622        let callable_targets = stage_route.callable_target_names();
2623        for expected in ["strong", "weak", "stage_judge", "classifier"] {
2624            assert!(callable_targets.contains(&expected));
2625        }
2626
2627        for configured in [
2628            with_subagent_llm_classifier(VALID_CONFIG, "passthrough", ""),
2629            with_subagent_passthrough(VALID_CONFIG, "passthrough"),
2630            stage_with_classifier,
2631            with_subagent_passthrough(&stage, "stage"),
2632        ] {
2633            runner_from_toml(&configured)?;
2634        }
2635        Ok(())
2636    }
2637
2638    // A routing-only judge cannot inherit the answer target's system prompt.
2639    #[test]
2640    fn rejects_prompted_answer_target_used_as_routing_only_target() {
2641        let configured = VALID_CONFIG
2642            .replace(
2643                "classifier_target = \"classifier\"",
2644                "classifier_target = \"weak\"",
2645            )
2646            .replace(
2647                "id = \"weak/model\"\nllm_client = \"anthropic\"",
2648                "id = \"weak/model\"\nllm_client = \"anthropic\"\nsystem_prompt = \"answer prompt\"",
2649            )
2650            .replace(
2651                "base_threshold = 0.5",
2652                "base_threshold = 0.5\nescalation = { confirmations = 1 }",
2653            );
2654
2655        assert_eq!(
2656            error_message(&configured),
2657            "route classifier cannot apply system_prompt to target weak: it is also the routing-only target"
2658        );
2659    }
2660
2661    #[test]
2662    fn rejects_invalid_unreferenced_llm_client() {
2663        for (settings, expected) in [
2664            (
2665                "base_url = \"not a url\"",
2666                "base_url must be an absolute HTTP(S) URL",
2667            ),
2668            (
2669                "base_url = \"https://example.test\"\ntimeout_ms = 0",
2670                "timeout_ms must be at least 1",
2671            ),
2672        ] {
2673            let invalid = format!(
2674                "{VALID_CONFIG}\n[llm_clients.unused]\nformat = \"openai_chat\"\n{settings}"
2675            );
2676            let message = error_message(&invalid);
2677            assert!(message.contains(expected), "unexpected error: {message}");
2678        }
2679    }
2680
2681    #[test]
2682    fn an_escalation_table_switches_the_classifier_route_to_escalation() -> RunnerResult<()> {
2683        // Present: the classifier target judges the weak tier's reply each turn instead of
2684        // picking a tier ahead of it. The route builds either way, so the assertion is that
2685        // the knob parses and its settings reach the algorithm's validation.
2686        let escalating = VALID_CONFIG.replace(
2687            "base_threshold = 0.5",
2688            "base_threshold = 0.5\nescalation = { confirmations = 2 }",
2689        );
2690        runner_from_toml(&escalating)?;
2691
2692        let reversible = VALID_CONFIG.replace(
2693            "base_threshold = 0.5",
2694            "base_threshold = 0.5\nescalation = { confirmations = 2, deescalation = { strong_min_calls = 3, confirmations = 2, strong_max_calls = 6, weak_cooldown_calls = 8 } }",
2695        );
2696        runner_from_toml(&reversible)?;
2697
2698        // A setting that would starve the judge is rejected here rather than on the first
2699        // request, the same as any other unusable route configuration.
2700        let starved = VALID_CONFIG.replace(
2701            "base_threshold = 0.5",
2702            "base_threshold = 0.5\nescalation = { confirmations = 0 }",
2703        );
2704        assert!(error_message(&starved).contains("confirmations must be at least 1"));
2705
2706        Ok(())
2707    }
2708
2709    #[test]
2710    fn unmatched_steps_is_a_capability_setting() -> RunnerResult<()> {
2711        // Accepted on a capability route; the value range is checked by the algorithm.
2712        runner_from_toml(&VALID_CONFIG.replace(
2713            "base_threshold = 0.5",
2714            "base_threshold = 0.5\nunmatched_steps = 2",
2715        ))?;
2716        assert!(
2717            error_message(&VALID_CONFIG.replace(
2718                "base_threshold = 0.5",
2719                "base_threshold = 0.5\nunmatched_steps = 3",
2720            ))
2721            .contains("unmatched_steps must be 0, 1, or 2")
2722        );
2723
2724        // Rejected on an escalation route, including the implicit form where the
2725        // `escalation` table alone selects the mode: the key is new, so no existing
2726        // configuration relies on it being ignored there.
2727        assert!(
2728            error_message(&VALID_CONFIG.replace(
2729                "base_threshold = 0.5",
2730                "base_threshold = 0.5\nunmatched_steps = 2\nescalation = { confirmations = 2 }",
2731            ))
2732            .contains("mode escalation cannot use capability routing settings")
2733        );
2734        Ok(())
2735    }
2736
2737    #[test]
2738    fn a_target_reasoning_effort_parses_on_every_client_format() -> RunnerResult<()> {
2739        let strong = "[targets.strong]\nid = \"strong/model\"\nllm_client = \"responses\"";
2740        let weak = "[targets.weak]\nid = \"weak/model\"\nllm_client = \"anthropic\"";
2741        assert!(VALID_CONFIG.contains(strong) && VALID_CONFIG.contains(weak));
2742
2743        let forced = VALID_CONFIG.replace(strong, &format!("{strong}\nreasoning_effort = \"max\""));
2744        runner_from_toml(&forced)?;
2745
2746        let blank = VALID_CONFIG.replace(strong, &format!("{strong}\nreasoning_effort = \" \""));
2747        assert!(error_message(&blank).contains("reasoning_effort must not be empty"));
2748
2749        let anthropic = VALID_CONFIG.replace(weak, &format!("{weak}\nreasoning_effort = \"high\""));
2750        runner_from_toml(&anthropic)?;
2751
2752        let minimal =
2753            VALID_CONFIG.replace(weak, &format!("{weak}\nreasoning_effort = \"minimal\""));
2754        assert!(
2755            error_message(&minimal).contains("is not an anthropic_messages effort"),
2756            "{}",
2757            error_message(&minimal)
2758        );
2759        Ok(())
2760    }
2761
2762    #[test]
2763    fn decision_target_ids_must_not_match_llm_target_names() {
2764        let configured = format!(
2765            "{VALID_CONFIG}\n[decision_clients.decisions]\nformat = \"system_one\"\nendpoint = \"https://example.test/decisions\"\napi_key_env = \"PATH\"\ntimeout_ms = 5000\n\n[decision_targets.judge]\nid = \"strong\"\ndecision_client = \"decisions\"\n"
2766        );
2767        let message = error_message(&configured);
2768        assert!(
2769            message.contains("decision target judge id strong is also an LLM target name"),
2770            "{message}"
2771        );
2772    }
2773
2774    #[test]
2775    fn classifier_judge_completion_caps_are_configurable() -> RunnerResult<()> {
2776        let capability = VALID_CONFIG.replace(
2777            "base_threshold = 0.5",
2778            "base_threshold = 0.5\nmax_output_tokens = 512",
2779        );
2780        runner_from_toml(&capability)?;
2781
2782        let escalation = VALID_CONFIG.replace(
2783            "base_threshold = 0.5",
2784            "base_threshold = 0.5\nmax_output_tokens = 256\nescalation = { confirmations = 2 }",
2785        );
2786        runner_from_toml(&escalation)?;
2787        Ok(())
2788    }
2789
2790    #[test]
2791    fn classifier_prompts_are_configurable_in_both_modes() -> RunnerResult<()> {
2792        let capability = VALID_CONFIG.replace(
2793            "base_threshold = 0.5",
2794            "base_threshold = 0.5\nprompt = \"custom capability rubric\"",
2795        );
2796        runner_from_toml(&capability)?;
2797
2798        let escalation = VALID_CONFIG.replace(
2799            "base_threshold = 0.5",
2800            "base_threshold = 0.5\nprompt = \"custom trajectory rubric\"\nescalation = { confirmations = 2 }",
2801        );
2802        runner_from_toml(&escalation)?;
2803
2804        let empty = VALID_CONFIG.replace(
2805            "base_threshold = 0.5",
2806            "base_threshold = 0.5\nprompt = \"   \"",
2807        );
2808        assert!(error_message(&empty).contains("classifier prompt must not be empty"));
2809
2810        let schema_placeholder = VALID_CONFIG.replace(
2811            "base_threshold = 0.5",
2812            "base_threshold = 0.5\nprompt = \"{{RESPONSE_SCHEMA}}\"",
2813        );
2814        assert!(
2815            error_message(&schema_placeholder)
2816                .contains("Switchyard supplies the schema automatically")
2817        );
2818        Ok(())
2819    }
2820
2821    #[test]
2822    fn mode_custom_rejects_capability_fields() {
2823        let mixed = VALID_CONFIG.replace(
2824            "base_threshold = 0.5",
2825            "mode = \"custom\"\nbase_threshold = 0.5",
2826        );
2827
2828        assert!(
2829            error_message(&mixed)
2830                .contains("mode custom cannot use capability or escalation fields")
2831        );
2832    }
2833
2834    #[test]
2835    fn stage_router_rejects_an_unknown_field() {
2836        let config = stage_config().replace(
2837            "picker = \"efficient_first\"",
2838            "picker = \"efficient_first\"\nmagic = true",
2839        );
2840        assert!(error_message(&config).contains("unknown field"));
2841    }
2842
2843    #[test]
2844    fn auto_route_builds_a_stage_router_with_no_extra_fields() -> RunnerResult<()> {
2845        let config = format!(
2846            r#"{VALID_CONFIG}
2847[routes.auto]
2848id = "switchyard/auto"
2849type = "auto"
2850capable_target = "strong"
2851efficient_target = "weak"
2852"#
2853        );
2854        let runner = runner_from_toml(&config)?;
2855        assert!(runner.route("switchyard/auto").is_some());
2856        Ok(())
2857    }
2858
2859    #[test]
2860    fn composite_stage_block_rejects_an_unknown_field() {
2861        let config = composite_config().replace(
2862            "confidence_threshold = 0.5",
2863            "confidence_threshold = 0.5\nmagic = true",
2864        );
2865        assert!(error_message(&config).contains("unknown field"));
2866    }
2867
2868    #[test]
2869    fn rejects_unknown_fields_and_algorithm_types() {
2870        let unknown_field =
2871            VALID_CONFIG.replace("schema_version = 1", "schema_version = 1\nmagic = true");
2872        assert!(error_message(&unknown_field).contains("unknown field"));
2873
2874        let nested_completion_cap = VALID_CONFIG.replace(
2875            "base_threshold = 0.5",
2876            "base_threshold = 0.5\nescalation = { max_output_tokens = 256 }",
2877        );
2878        assert!(error_message(&nested_completion_cap).contains("unknown field"));
2879
2880        let unknown_classifier_field = VALID_CONFIG.replace(
2881            "base_threshold = 0.5",
2882            "base_threshold = 0.5\nclassifier_magic = true",
2883        );
2884        assert!(error_message(&unknown_classifier_field).contains("unknown field"));
2885
2886        let target_capability = VALID_CONFIG.replace(
2887            "llm_client = \"responses\"",
2888            "llm_client = \"responses\"\ncontext_window = 1000000",
2889        );
2890        assert!(error_message(&target_capability).contains("unknown field `context_window`"));
2891
2892        let unknown_algorithm = VALID_CONFIG.replace("type = \"noop\"", "type = \"imaginary\"");
2893        assert!(error_message(&unknown_algorithm).contains("unknown variant"));
2894    }
2895
2896    #[test]
2897    fn rejects_unknown_stage_classifier_fields() {
2898        // Nested classifier typos must fail instead of silently using a default.
2899        let config = format!(
2900            r#"{VALID_CONFIG}
2901
2902[routes.stage]
2903id = "switchyard/stage"
2904type = "stage_router"
2905capable_target = "strong"
2906efficient_target = "weak"
2907picker = "efficient_first"
2908confidence_threshold = 1.0
2909
2910[routes.stage.classifier]
2911target = "classifier"
2912base_threshold = 0.5
2913classifier_magic = true
2914"#
2915        );
2916
2917        let error = error_message(&config);
2918        assert!(
2919            error.contains("unknown field `classifier_magic`"),
2920            "{error}"
2921        );
2922    }
2923
2924    #[test]
2925    fn rejects_invalid_references_and_parameters() {
2926        let cases = [
2927            (
2928                VALID_CONFIG.replace("llm_client = \"primary\"", "llm_client = \"missing\""),
2929                "unknown llm client missing",
2930            ),
2931            (
2932                VALID_CONFIG.replace(
2933                    "targets = [\"strong\", \"weak\"]",
2934                    "targets = [\"strong\", \"missing\"]",
2935                ),
2936                "unknown target missing",
2937            ),
2938            (
2939                VALID_CONFIG.replace(
2940                    "targets = [\"strong\", \"weak\"]",
2941                    "targets = [\"strong\", \"weak\"]\nweights = [0, 0]",
2942                ),
2943                "at least one weight must be positive",
2944            ),
2945            (
2946                VALID_CONFIG.replace(
2947                    "targets = [\"strong\", \"weak\"]",
2948                    "targets = [\"strong\", \"strong\"]",
2949                ),
2950                "targets must be unique, strong is repeated",
2951            ),
2952            (
2953                VALID_CONFIG.replace(
2954                    "targets = [\"strong\", \"weak\"]",
2955                    "targets = [\"strong\", \"weak\"]\nweights = [1]",
2956                ),
2957                "expected 2 weights, got 1",
2958            ),
2959            (
2960                VALID_CONFIG.replace("base_threshold = 0.5", "base_threshold = 1.5"),
2961                "base_threshold must be between 0 and 1",
2962            ),
2963            (
2964                VALID_CONFIG.replace("classifier_target = \"classifier\"\n", ""),
2965                "route references unknown target",
2966            ),
2967            (
2968                VALID_CONFIG.replace(
2969                    "classifier_target = \"classifier\"",
2970                    "classifier_target = \"\"",
2971                ),
2972                "route references unknown target",
2973            ),
2974            (
2975                VALID_CONFIG.replace(
2976                    "classifier_target = \"classifier\"",
2977                    "classifier_target = \"   \"",
2978                ),
2979                "route references unknown target",
2980            ),
2981            (
2982                VALID_CONFIG.replace(
2983                    "base_threshold = 0.5",
2984                    "base_threshold = 0.5\nthreshold_step = -0.1",
2985                ),
2986                "threshold_step must be finite and greater than or equal to 0",
2987            ),
2988            (
2989                VALID_CONFIG.replace(
2990                    "base_threshold = 0.5",
2991                    "base_threshold = 0.8\nthreshold_step = 0.11",
2992                ),
2993                "base_threshold + 2 * threshold_step must be at most 1",
2994            ),
2995            (
2996                VALID_CONFIG.replace(
2997                    "base_threshold = 0.5",
2998                    "base_threshold = 0.5\nmax_output_tokens = 0\nescalation = { confirmations = 2 }",
2999                ),
3000                "max_output_tokens must be at least 1",
3001            ),
3002            (
3003                VALID_CONFIG.replace(
3004                    "base_threshold = 0.5",
3005                    "base_threshold = 0.5\nmessage_hash_fallback = true",
3006                ),
3007                "message_hash_fallback requires classify_trigger = new_session",
3008            ),
3009            (
3010                with_subagent_llm_classifier(
3011                    VALID_CONFIG,
3012                    "passthrough",
3013                    "\nmessage_hash_fallback = true",
3014                ),
3015                "cannot use message_hash_fallback",
3016            ),
3017            (
3018                with_subagent_llm_classifier(VALID_CONFIG, "passthrough", "")
3019                    .replace("mode = \"custom\"", "mode = \"capability\""),
3020                "mode capability cannot use custom classifier fields",
3021            ),
3022            (
3023                VALID_CONFIG.replace(
3024                    "base_threshold = 0.5",
3025                    "escalation = { confirmations = 2 }\nclassify_trigger = \"user_turn\"",
3026                ),
3027                "mode escalation cannot use classify_trigger",
3028            ),
3029            (
3030                VALID_CONFIG.replace("schema_version = 1", "schema_version = 2"),
3031                "unsupported schema_version 2",
3032            ),
3033            (
3034                VALID_CONFIG.replace("[targets.strong]", "[targets.\" strong \"]"),
3035                "target name must be non-empty and have no surrounding whitespace",
3036            ),
3037            (
3038                VALID_CONFIG.replace(
3039                    "targets = [\"strong\", \"weak\"]",
3040                    "targets = [\"strong\", \"weak\"]\ncontext_window = 0",
3041                ),
3042                "route random context_window must be greater than zero",
3043            ),
3044        ];
3045
3046        for (toml, expected) in cases {
3047            let error = AlgorithmFactory::from_toml(&toml)
3048                .and_then(|factory| factory.build())
3049                .err()
3050                .expect("invalid configuration should fail")
3051                .to_string();
3052            assert!(
3053                error.contains(expected),
3054                "expected error containing {expected}, got {error}"
3055            );
3056        }
3057    }
3058
3059    #[test]
3060    fn accepts_duplicate_target_model_ids_on_one_client() -> RunnerResult<()> {
3061        // Two targets share one model id on one client. The client keeps one and drops the
3062        // other, so the build warns and still succeeds, and both routes resolve. Serving one
3063        // model under two route names this way is allowed; pointing both routes at one target
3064        // is the tidier form.
3065        const SAME_MODEL_TWO_ROUTES: &str = r#"
3066schema_version = 1
3067
3068[llm_clients.primary]
3069format = "openai_chat"
3070base_url = "https://example.test/v1"
3071
3072[targets.fast]
3073id = "gpt-4o"
3074llm_client = "primary"
3075
3076[targets.smart]
3077id = "gpt-4o"
3078llm_client = "primary"
3079
3080[routes.fast]
3081id = "switchyard/fast"
3082type = "passthrough"
3083target = "fast"
3084
3085[routes.smart]
3086id = "switchyard/smart"
3087type = "passthrough"
3088target = "smart"
3089"#;
3090        let state = runner_from_toml(SAME_MODEL_TWO_ROUTES)?;
3091        assert_eq!(
3092            state
3093                .models()
3094                .map(|model| model.id.as_str())
3095                .collect::<Vec<_>>(),
3096            ["switchyard/fast", "switchyard/smart"]
3097        );
3098        Ok(())
3099    }
3100
3101    #[test]
3102    fn accepts_same_model_id_on_different_llm_clients() -> RunnerResult<()> {
3103        // One model may be served through different clients in separate routes.
3104        const CROSS_PROVIDER: &str = r#"
3105schema_version = 1
3106
3107[llm_clients.openai]
3108format = "openai_chat"
3109base_url = "https://example.test/v1"
3110
3111[llm_clients.azure]
3112format = "openai_chat"
3113base_url = "https://azure.test/v1"
3114
3115[targets.openai]
3116id = "gpt-4o"
3117llm_client = "openai"
3118
3119[targets.azure]
3120id = "gpt-4o"
3121llm_client = "azure"
3122
3123[routes.openai]
3124id = "switchyard/openai-gpt4o"
3125type = "passthrough"
3126target = "openai"
3127
3128[routes.azure]
3129id = "switchyard/azure-gpt4o"
3130type = "passthrough"
3131target = "azure"
3132"#;
3133        runner_from_toml(CROSS_PROVIDER)?;
3134        Ok(())
3135    }
3136
3137    #[test]
3138    fn accepts_relative_weights_and_seed() -> RunnerResult<()> {
3139        let weighted = VALID_CONFIG.replace(
3140            "targets = [\"strong\", \"weak\"]",
3141            "targets = [\"strong\", \"weak\"]\nweights = [1, 3]\nseed = 42",
3142        );
3143        runner_from_toml(&weighted)?;
3144        Ok(())
3145    }
3146
3147    #[test]
3148    fn accepts_new_session_trigger_with_message_hash_fallback() -> RunnerResult<()> {
3149        let configured = VALID_CONFIG.replace(
3150            "base_threshold = 0.5",
3151            "base_threshold = 0.25\nthreshold_step = 0.1\nclassify_trigger = \"new_session\"\nmessage_hash_fallback = true",
3152        );
3153        runner_from_toml(&configured)?;
3154        Ok(())
3155    }
3156
3157    #[test]
3158    fn target_extra_body_is_parsed_and_applied_to_its_backend() -> RunnerResult<()> {
3159        let configured = VALID_CONFIG.replacen(
3160            "llm_client = \"primary\"",
3161            "llm_client = \"primary\"\n\
3162             extra_body = { service_tier = \"priority\", \
3163             chat_template_kwargs = { enable_thinking = false } }",
3164            1,
3165        );
3166        let config: DeploymentConfig = toml::from_str(&configured).map_err(|error| {
3167            RunnerError::configuration(format!("failed to parse config: {error}"))
3168        })?;
3169        let Some(target) = config.targets.get("classifier") else {
3170            return Err(RunnerError::configuration("classifier target is missing"));
3171        };
3172        let Some(client) = config.llm_clients.get("primary") else {
3173            return Err(RunnerError::configuration("primary llm client is missing"));
3174        };
3175        let backend = build_backend(
3176            "primary",
3177            client,
3178            &target.extra_body,
3179            &target.omit_body_fields,
3180            None,
3181        )?;
3182
3183        assert_eq!(
3184            backend.extra_body().get("service_tier"),
3185            Some(&json!("priority"))
3186        );
3187        assert_eq!(
3188            backend
3189                .extra_body()
3190                .get("chat_template_kwargs")
3191                .and_then(|value| value.get("enable_thinking")),
3192            Some(&json!(false))
3193        );
3194        Ok(())
3195    }
3196
3197    #[test]
3198    fn target_omit_body_fields_is_parsed_and_applied_to_its_backend() -> RunnerResult<()> {
3199        let configured = VALID_CONFIG.replacen(
3200            "llm_client = \"primary\"",
3201            "llm_client = \"primary\"\n\
3202             omit_body_fields = [\"max_output_tokens\"]",
3203            1,
3204        );
3205        let config: DeploymentConfig = toml::from_str(&configured).map_err(|error| {
3206            RunnerError::configuration(format!("failed to parse config: {error}"))
3207        })?;
3208        let Some(target) = config.targets.get("classifier") else {
3209            return Err(RunnerError::configuration("classifier target is missing"));
3210        };
3211        let Some(client) = config.llm_clients.get("primary") else {
3212            return Err(RunnerError::configuration("primary llm client is missing"));
3213        };
3214        let backend = build_backend(
3215            "primary",
3216            client,
3217            &target.extra_body,
3218            &target.omit_body_fields,
3219            None,
3220        )?;
3221
3222        assert!(backend.omit_body_fields().contains("max_output_tokens"));
3223        Ok(())
3224    }
3225
3226    #[test]
3227    fn client_deadline_defaults_and_rejects_zero() -> RunnerResult<()> {
3228        for (setting, expected) in [
3229            ("", None),
3230            ("timeout_ms = 1500", Some(1500)),
3231            ("timeout_ms = 0", Some(0)),
3232        ] {
3233            let source = format!(
3234                "format = \"openai_chat\"\nbase_url = \"https://example.test/v1\"\n{setting}"
3235            );
3236            let config: LlmClientConfig = toml::from_str(&source).expect("valid deadline config");
3237            let backend = build_backend("test", &config, &BTreeMap::new(), &BTreeSet::new(), None);
3238            if expected == Some(0) {
3239                assert!(backend.is_err());
3240            } else {
3241                assert_eq!(backend?.timeout(), expected.map(Duration::from_millis));
3242            }
3243        }
3244        Ok(())
3245    }
3246
3247    #[test]
3248    fn retry_budget_defaults_and_accepts_an_override() -> RunnerResult<()> {
3249        let default: DeploymentConfig = toml::from_str(VALID_CONFIG).map_err(|error| {
3250            RunnerError::configuration(format!("failed to parse default config: {error}"))
3251        })?;
3252        let Some(primary) = default.llm_clients.get("primary") else {
3253            return Err(RunnerError::configuration("primary llm client is missing"));
3254        };
3255        assert_eq!(primary.max_retries, DEFAULT_MAX_RETRIES);
3256
3257        let explicit = VALID_CONFIG.replacen(
3258            "base_url = \"https://example.test/v1\"",
3259            "base_url = \"https://example.test/v1\"\nmax_retries = 0",
3260            1,
3261        );
3262        let config: DeploymentConfig = toml::from_str(&explicit).map_err(|error| {
3263            RunnerError::configuration(format!("failed to parse explicit retry config: {error}"))
3264        })?;
3265        let Some(primary) = config.llm_clients.get("primary") else {
3266            return Err(RunnerError::configuration("primary llm client is missing"));
3267        };
3268        assert_eq!(primary.max_retries, 0);
3269
3270        let maximum = VALID_CONFIG.replacen(
3271            "base_url = \"https://example.test/v1\"",
3272            &format!(
3273                "base_url = \"https://example.test/v1\"\nmax_retries = {MAX_CONFIGURED_RETRIES}"
3274            ),
3275            1,
3276        );
3277        let config: DeploymentConfig = toml::from_str(&maximum).map_err(|error| {
3278            RunnerError::configuration(format!("failed to parse maximum retry config: {error}"))
3279        })?;
3280        let Some(primary) = config.llm_clients.get("primary") else {
3281            return Err(RunnerError::configuration("primary llm client is missing"));
3282        };
3283        assert_eq!(primary.max_retries, MAX_CONFIGURED_RETRIES);
3284        Ok(())
3285    }
3286
3287    #[test]
3288    fn responses_reasoning_defaults_and_accepts_drop() -> RunnerResult<()> {
3289        let default: DeploymentConfig = toml::from_str(VALID_CONFIG).map_err(|error| {
3290            RunnerError::configuration(format!("failed to parse config: {error}"))
3291        })?;
3292        let responses = default
3293            .llm_clients
3294            .get("responses")
3295            .ok_or_else(|| RunnerError::configuration("responses llm client is missing"))?;
3296        assert_eq!(responses.responses_reasoning, None);
3297
3298        let configured = VALID_CONFIG.replacen(
3299            "[llm_clients.responses]\nformat = \"openai_responses\"\nbase_url = \"https://example.test/v1\"",
3300            "[llm_clients.responses]\nformat = \"openai_responses\"\nbase_url = \"https://example.test/v1\"\nresponses_reasoning = \"drop\"",
3301            1,
3302        );
3303        let config: DeploymentConfig = toml::from_str(&configured).map_err(|error| {
3304            RunnerError::configuration(format!("failed to parse config: {error}"))
3305        })?;
3306        let responses = config
3307            .llm_clients
3308            .get("responses")
3309            .ok_or_else(|| RunnerError::configuration("responses llm client is missing"))?;
3310        assert_eq!(
3311            responses.responses_reasoning,
3312            Some(ResponsesReasoningPolicy::Drop)
3313        );
3314        runner_from_toml(&configured)?;
3315        Ok(())
3316    }
3317
3318    #[test]
3319    fn responses_reasoning_rejects_other_formats() {
3320        let invalid = VALID_CONFIG.replacen(
3321            "format = \"openai_chat\"",
3322            "format = \"openai_chat\"\nresponses_reasoning = \"drop\"",
3323            1,
3324        );
3325        assert!(
3326            error_message(&invalid)
3327                .contains("responses_reasoning is only valid for openai_responses")
3328        );
3329    }
3330
3331    #[test]
3332    fn responses_reasoning_rejects_unreferenced_other_format() {
3333        let invalid = format!(
3334            "{VALID_CONFIG}\n[llm_clients.unused]\nformat = \"openai_chat\"\nbase_url = \"https://example.test/v1\"\nresponses_reasoning = \"drop\"\n"
3335        );
3336        assert!(
3337            error_message(&invalid)
3338                .contains("responses_reasoning is only valid for openai_responses")
3339        );
3340    }
3341
3342    #[test]
3343    fn rejects_headers_that_switchyard_sets() {
3344        let cases = [
3345            (
3346                "base_url = \"https://example.test/v1\"",
3347                "base_url = \"https://example.test/v1\"\n\
3348                 extra_headers = { AUTHORIZATION = \"Bearer custom-key\" }",
3349                "AUTHORIZATION",
3350            ),
3351            (
3352                "base_url = \"https://example.test\"",
3353                "base_url = \"https://example.test\"\n\
3354                 extra_headers = { \"X-Api-Key\" = \"custom-key\" }",
3355                "X-Api-Key",
3356            ),
3357            (
3358                "base_url = \"https://example.test\"",
3359                "base_url = \"https://example.test\"\n\
3360                 extra_headers = { \"ANTHROPIC-VERSION\" = \"custom-version\" }",
3361                "ANTHROPIC-VERSION",
3362            ),
3363        ];
3364
3365        for (original, replacement, header) in cases {
3366            let configured = VALID_CONFIG.replacen(original, replacement, 1);
3367            let error = error_message(&configured);
3368            assert!(
3369                error.contains(&format!("extra_headers cannot set {header:?}")),
3370                "expected {header} to be rejected, got: {error}"
3371            );
3372        }
3373    }
3374
3375    #[test]
3376    fn accepts_additional_headers() -> RunnerResult<()> {
3377        let configured = VALID_CONFIG.replacen(
3378            "base_url = \"https://example.test/v1\"",
3379            "base_url = \"https://example.test/v1\"\n\
3380             extra_headers = { X-Inference-Priority = \"batch\" }",
3381            1,
3382        );
3383
3384        runner_from_toml(&configured)?;
3385        Ok(())
3386    }
3387
3388    #[test]
3389    fn retry_budget_rejects_negative_values() {
3390        let invalid = VALID_CONFIG.replacen(
3391            "base_url = \"https://example.test/v1\"",
3392            "base_url = \"https://example.test/v1\"\nmax_retries = -1",
3393            1,
3394        );
3395        assert!(error_message(&invalid).contains("max_retries"));
3396    }
3397
3398    #[test]
3399    fn retry_budget_rejects_excessive_values() {
3400        let invalid = VALID_CONFIG.replacen(
3401            "base_url = \"https://example.test/v1\"",
3402            "base_url = \"https://example.test/v1\"\nmax_retries = 11",
3403            1,
3404        );
3405        assert!(
3406            error_message(&invalid).contains("llm client primary max_retries must be at most 10")
3407        );
3408    }
3409
3410    #[test]
3411    fn api_key_environment_reference_is_validated() {
3412        let missing = VALID_CONFIG.replacen(
3413            "base_url = \"https://example.test/v1\"",
3414            "base_url = \"https://example.test/v1\"\napi_key_env = \"SWITCHYARD_CONFIG_TEST_KEY_THAT_IS_NOT_SET\"",
3415            1,
3416        );
3417        assert!(error_message(&missing).contains("SWITCHYARD_CONFIG_TEST_KEY_THAT_IS_NOT_SET"));
3418
3419        const EMPTY_KEY_ENV: &str = "SWITCHYARD_CONFIG_TEST_EMPTY_KEY";
3420        unsafe {
3421            // "unsafe" is for concurrent reads and writes, very rare
3422            std::env::set_var(EMPTY_KEY_ENV, "");
3423        }
3424        let empty = VALID_CONFIG.replacen(
3425            "base_url = \"https://example.test/v1\"",
3426            &format!("base_url = \"https://example.test/v1\"\napi_key_env = \"{EMPTY_KEY_ENV}\""),
3427            1,
3428        );
3429        let message = error_message(&empty);
3430        unsafe {
3431            std::env::remove_var(EMPTY_KEY_ENV);
3432        }
3433        assert!(message.contains("is empty"));
3434
3435        // macOS reads `FOO` for the name `FOO=x`, so the config would use another variable. A
3436        // name with `=` is often a pasted `NAME=key` line, so the error leaves it out.
3437        let with_equals = VALID_CONFIG.replacen(
3438            "base_url = \"https://example.test/v1\"",
3439            "base_url = \"https://example.test/v1\"\napi_key_env = \"NVIDIA_API_KEY=DUMMY_SECRET_DO_NOT_LOG\"",
3440            1,
3441        );
3442        let message = error_message(&with_equals);
3443        assert!(message.contains("llm client primary api_key_env must not contain '=' or NUL"));
3444        assert!(!message.contains("DUMMY_SECRET_DO_NOT_LOG"), "{message}");
3445    }
3446
3447    // `VarError::NotUnicode` holds the variable's value, so printing that error prints the key.
3448    #[cfg(any(unix, windows))]
3449    #[test]
3450    fn invalid_unicode_api_key_is_not_printed() {
3451        const RAW_KEY_ENV: &str = "SWITCHYARD_CONFIG_TEST_RAW_KEY";
3452        #[cfg(unix)]
3453        let raw = {
3454            use std::os::unix::ffi::OsStringExt;
3455            let mut bytes = b"DUMMY_SECRET_DO_NOT_LOG".to_vec();
3456            bytes.push(0xff);
3457            std::ffi::OsString::from_vec(bytes)
3458        };
3459        #[cfg(windows)]
3460        let raw = {
3461            use std::os::windows::ffi::OsStringExt;
3462            let mut words: Vec<_> = "DUMMY_SECRET_DO_NOT_LOG".encode_utf16().collect();
3463            words.push(0xd800);
3464            std::ffi::OsString::from_wide(&words)
3465        };
3466        unsafe {
3467            // "unsafe" is for concurrent reads and writes, very rare
3468            std::env::set_var(RAW_KEY_ENV, raw);
3469        }
3470        let result = read_api_key("llm client fixture", RAW_KEY_ENV);
3471        unsafe {
3472            std::env::remove_var(RAW_KEY_ENV);
3473        }
3474        let error = result.expect_err("invalid Unicode credential").to_string();
3475        assert!(error.contains("not valid Unicode"), "{error}");
3476        assert!(!error.contains("DUMMY_SECRET_DO_NOT_LOG"), "{error}");
3477    }
3478
3479    #[test]
3480    fn forward_auth_rejects_conflicting_credentials() {
3481        let competing_auth = VALID_CONFIG.replacen(
3482            "base_url = \"https://example.test/v1\"",
3483            "base_url = \"https://example.test/v1\"\n\
3484                 forward_auth = true\n\
3485                 api_key_env = \"UNUSED_TEST_KEY\"",
3486            1,
3487        );
3488        assert!(
3489            error_message(&competing_auth).contains("cannot set both forward_auth and api_key_env")
3490        );
3491
3492        let static_auth = VALID_CONFIG.replacen(
3493            "base_url = \"https://example.test\"",
3494            "base_url = \"https://example.test\"\n\
3495                 forward_auth = true\n\
3496                 extra_headers = { Authorization = \"static-value\" }",
3497            1,
3498        );
3499        assert!(error_message(&static_auth).contains("extra_headers cannot set \"Authorization\""));
3500
3501        let static_beta = static_auth.replace("Authorization", "anthropic-beta");
3502        assert!(
3503            error_message(&static_beta).contains("extra_headers cannot set \"anthropic-beta\"")
3504        );
3505
3506        for header in ["chatgpt-account-id", "x-openai-fedramp"] {
3507            let static_context = VALID_CONFIG.replacen(
3508                "base_url = \"https://example.test/v1\"",
3509                &format!(
3510                    "base_url = \"https://example.test/v1\"\n\
3511                     forward_auth = true\n\
3512                     extra_headers = {{ \"{header}\" = \"static-value\" }}"
3513                ),
3514                1,
3515            );
3516            assert!(
3517                error_message(&static_context)
3518                    .contains(&format!("extra_headers cannot set \"{header}\""))
3519            );
3520        }
3521    }
3522
3523    /// Returns a config whose `mixed` route has a GPT target on a Responses client and a Claude
3524    /// target on a Messages client at `messages_url`. Its `claude` route uses Claude only.
3525    fn mixed_forwarding_config(messages_url: &str) -> String {
3526        format!(
3527            r#"
3528schema_version = 1
3529
3530[llm_clients.responses]
3531format = "openai_responses"
3532base_url = "https://gateway.example.test/v1"
3533forward_auth = true
3534
3535[llm_clients.messages]
3536format = "anthropic_messages"
3537base_url = "{messages_url}"
3538forward_auth = true
3539
3540[targets]
3541gpt = {{ id = "gpt/model", llm_client = "responses" }}
3542claude = {{ id = "claude/model", llm_client = "messages" }}
3543
3544[routes.mixed]
3545id = "switchyard/mixed"
3546type = "random"
3547targets = ["gpt", "claude"]
3548
3549[routes.claude]
3550id = "switchyard/claude"
3551type = "passthrough"
3552target = "claude"
3553"#
3554        )
3555    }
3556
3557    /// A forwarding route can mix OpenAI and Anthropic clients only when they all use the same
3558    /// scheme, host, and port.
3559    #[test]
3560    fn forwarding_route_mixes_formats_only_on_one_host() -> RunnerResult<()> {
3561        // Same host, different paths: the mixed route serves OpenAI callers, and the route that
3562        // uses only the Messages client keeps serving Messages callers.
3563        let runner = runner_from_toml(&mixed_forwarding_config("https://gateway.example.test"))?;
3564        let caller_auth = |id| runner.route(id).and_then(Route::caller_auth);
3565        assert_eq!(
3566            caller_auth("switchyard/mixed"),
3567            Some(CallerAuthKind::OpenAi)
3568        );
3569        assert_eq!(
3570            caller_auth("switchyard/claude"),
3571            Some(CallerAuthKind::Anthropic)
3572        );
3573
3574        // A different host, port, or scheme fails, and the error names it.
3575        for other in [
3576            "https://api.anthropic.test",
3577            "https://gateway.example.test:8443",
3578            "http://gateway.example.test",
3579        ] {
3580            let error = error_message(&mixed_forwarding_config(other));
3581            assert!(
3582                error.contains("different origins") && error.contains(other),
3583                "{error}"
3584            );
3585        }
3586        Ok(())
3587    }
3588
3589    const ADVISOR_CONFIG: &str = r#"
3590schema_version = 1
3591
3592[llm_clients.anthropic]
3593format = "anthropic_messages"
3594base_url = "https://example.test"
3595
3596[targets.executor]
3597id = "executor/model"
3598llm_client = "anthropic"
3599
3600[targets.advisor]
3601id = "advisor/model"
3602llm_client = "anthropic"
3603
3604[routes.gated]
3605id = "switchyard/advisor"
3606type = "advisor"
3607executor_target = "executor"
3608advisor_target = "advisor"
3609"#;
3610
3611    #[test]
3612    fn advisor_route_parses_with_defaults_and_builds() -> RunnerResult<()> {
3613        let state = runner_from_toml(ADVISOR_CONFIG)?;
3614        let route = state
3615            .route("switchyard/advisor")
3616            .expect("advisor route should exist");
3617        let models = route.models();
3618        // The gate calls the executor through `efficient`; `any` keeps it in the
3619        // route's last-resort pool.
3620        assert_eq!(
3621            models.models_for(&Category::Efficient),
3622            [ModelId::from("executor")]
3623        );
3624        assert_eq!(
3625            models.models_for(&Category::Any),
3626            [ModelId::from("executor")]
3627        );
3628        assert_eq!(
3629            models.models_for(&Category::Judge),
3630            [ModelId::from("advisor")]
3631        );
3632        assert_eq!(
3633            state
3634                .models()
3635                .map(|model| model.id.as_str())
3636                .collect::<Vec<_>>(),
3637            ["switchyard/advisor"]
3638        );
3639        Ok(())
3640    }
3641
3642    #[test]
3643    fn advisor_route_accepts_every_gate_knob() -> RunnerResult<()> {
3644        let tuned = ADVISOR_CONFIG.replace(
3645            "advisor_target = \"advisor\"",
3646            concat!(
3647                "advisor_target = \"advisor\"\n",
3648                "reviewer_system_prompt = \"review it\"\n",
3649                "redo_feedback_prefix = \"REVIEWER SAYS: \"\n",
3650                "gate_trigger = \"pattern\"\n",
3651                "gate_trigger_pattern = 'task_complete[\"\\s>:]*true'\n",
3652                "max_reviews = 2\n",
3653                "gate_stall_turns = 40\n",
3654                "gate_min_tool_results = 1\n",
3655                "advisor_max_tokens = 1024\n",
3656                "advisor_temperature = 0.0\n",
3657                "transcript_max_chars = 100000\n",
3658                "fail_open = false\n",
3659                "context_window = 200000\n",
3660                "tool_calling = true\n",
3661                "reasoning = true",
3662            ),
3663        );
3664        runner_from_toml(&tuned)?;
3665        Ok(())
3666    }
3667
3668    #[test]
3669    fn advisor_route_rejects_unknown_keys() {
3670        let invalid = ADVISOR_CONFIG.replace(
3671            "advisor_target = \"advisor\"",
3672            "advisor_target = \"advisor\"\nbogus_field = 1",
3673        );
3674        assert!(error_message(&invalid).contains("bogus_field"));
3675    }
3676
3677    #[test]
3678    fn advisor_route_requires_both_targets() {
3679        let missing = ADVISOR_CONFIG.replace("advisor_target = \"advisor\"\n", "");
3680        assert!(error_message(&missing).contains("advisor_target"));
3681    }
3682
3683    #[test]
3684    fn advisor_route_rejects_unknown_target() {
3685        let invalid = ADVISOR_CONFIG.replace(
3686            "advisor_target = \"advisor\"",
3687            "advisor_target = \"missing\"",
3688        );
3689        assert!(error_message(&invalid).contains("missing"));
3690    }
3691
3692    #[test]
3693    fn advisor_route_rejects_invalid_pattern() {
3694        let invalid = ADVISOR_CONFIG.replace(
3695            "advisor_target = \"advisor\"",
3696            "advisor_target = \"advisor\"\ngate_trigger = \"pattern\"\ngate_trigger_pattern = \"(unclosed\"",
3697        );
3698        assert!(error_message(&invalid).contains("not a valid regex"));
3699    }
3700
3701    #[test]
3702    fn advisor_route_rejects_pattern_without_pattern_trigger() {
3703        let invalid = ADVISOR_CONFIG.replace(
3704            "advisor_target = \"advisor\"",
3705            "advisor_target = \"advisor\"\ngate_trigger_pattern = \"done\"",
3706        );
3707        assert!(
3708            error_message(&invalid)
3709                .contains("gate_trigger_pattern requires gate_trigger = \"pattern\"")
3710        );
3711    }
3712
3713    #[test]
3714    fn advisor_route_pattern_trigger_requires_pattern() {
3715        let invalid = ADVISOR_CONFIG.replace(
3716            "advisor_target = \"advisor\"",
3717            "advisor_target = \"advisor\"\ngate_trigger = \"pattern\"",
3718        );
3719        assert!(error_message(&invalid).contains("non-empty gate_trigger_pattern"));
3720    }
3721
3722    #[test]
3723    fn advisor_route_rejects_zero_max_reviews() {
3724        let invalid = ADVISOR_CONFIG.replace(
3725            "advisor_target = \"advisor\"",
3726            "advisor_target = \"advisor\"\nmax_reviews = 0",
3727        );
3728        assert!(error_message(&invalid).contains("max_reviews must be at least 1"));
3729    }
3730}