Skip to main content

switchyard_runner/
config.rs

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