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