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