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 serde::de::DeserializeOwned;
13use serde::{Deserialize, Deserializer};
14use serde_json::Value;
15use switchyard_llm_client::{
16    AuxiliaryOperation, Backend, ClientRouter, DEFAULT_MAX_RETRIES, HttpBackendConfig, ModelConfig,
17    OpenAiDecisionClient, ResponsesReasoningPolicy, SystemOneClient, TranslatingLlmClient,
18};
19use switchyard_protocol::{ModelId, RoutedDecisionClient, RoutedLlmClient, WireFormat};
20
21use crate::algorithm_factory::AlgorithmAndModels;
22use crate::runner::ModelPropertiesProbe;
23use crate::{
24    AlgorithmFactory, AlgorithmSpec, AuxiliaryTarget, CallerAuthKind, DecisionTarget,
25    ModelCapabilities, Route, Runner, RunnerError,
26};
27
28const SUPPORTED_SCHEMA_VERSION: u32 = 1;
29const MAX_CONFIGURED_RETRIES: u32 = 10;
30
31/// This function returns a deployment's declared credential variable names without reading their values.
32/// It parses the deployment and checks its schema version before returning names from LLM and decision clients.
33pub fn api_key_variables(source: &str) -> Result<BTreeSet<String>, RunnerError> {
34    let config: DeploymentConfig = toml::from_str(source).map_err(|_| {
35        RunnerError::configuration(
36            "Could not parse the server config to read credential references.",
37        )
38    })?;
39    if config.schema_version != SUPPORTED_SCHEMA_VERSION {
40        return Err(RunnerError::configuration(
41            "Unsupported server config schema version.",
42        ));
43    }
44    let mut variables: BTreeSet<_> = config
45        .llm_clients
46        .values()
47        .filter_map(|client| client.api_key_env.clone())
48        .collect();
49    for client in config.decision_clients.values() {
50        variables.insert(client.api_key_env.clone());
51    }
52    if variables
53        .iter()
54        .any(|name| name.trim().is_empty() || name.contains(['\0', '=']))
55    {
56        return Err(RunnerError::configuration(
57            "Invalid credential environment variable name.",
58        ));
59    }
60    Ok(variables)
61}
62
63/// RouteTargets lists the targets one route calls, as the runner reads them from the server config.
64#[derive(Clone, Debug, PartialEq, Eq)]
65pub struct RouteTargets {
66    /// This field names the route's table under `[routes]`.
67    pub name: String,
68    /// This field holds the public model ID that callers send.
69    pub id: ModelId,
70    /// This field holds the route's `type`, such as `stage_router`.
71    pub kind: &'static str,
72    /// This field lists the targets that answer requests, in algorithm order. It includes
73    /// targets that only delegated sub-agent work uses.
74    pub answer_targets: Vec<ConfiguredTarget>,
75    /// This field lists the targets the route calls only to decide, such as a judge or an advisor.
76    pub judge_targets: Vec<ConfiguredTarget>,
77}
78
79/// ConfiguredTarget names one target that a route uses.
80#[derive(Clone, Debug, PartialEq, Eq)]
81pub struct ConfiguredTarget {
82    pub name: String,
83    /// This field holds the client and model from the `[targets]` or `[decision_targets]`
84    /// entry with this name. It is `None` when no entry has the name; the server refuses to
85    /// start with such a config.
86    pub model: Option<TargetModel>,
87}
88
89/// TargetModel identifies the model a target sends requests to and the client that serves it.
90#[derive(Clone, Debug, PartialEq, Eq)]
91pub struct TargetModel {
92    pub client: String,
93    pub id: ModelId,
94}
95
96/// This function lists each route's targets without building clients or reading credentials.
97/// It returns the same parse error the server reports for a config that does not match the schema.
98pub fn route_targets(source: &str) -> Result<Vec<RouteTargets>, RunnerError> {
99    let config: DeploymentConfig = toml::from_str(source).map_err(|error| {
100        RunnerError::configuration_source(format!("failed to parse TOML: {error}"), error)
101    })?;
102    config.check_schema_version()?;
103    Ok(config
104        .routes
105        .iter()
106        .map(|(name, route)| RouteTargets {
107            name: name.clone(),
108            id: route.id.clone(),
109            kind: (&route.algorithm).into(),
110            answer_targets: config.configured_targets(route.routing_target_names()),
111            judge_targets: config.configured_targets(route.algorithm.judge_target_names()),
112        })
113        .collect())
114}
115
116type RunnerResult<T> = Result<T, RunnerError>;
117
118pub(crate) fn load_runner(path: impl AsRef<Path>) -> RunnerResult<Runner> {
119    let path = path.as_ref();
120    let source = fs::read_to_string(path).map_err(|error| {
121        RunnerError::configuration_source(
122            format!("failed to read server config {}: {error}", path.display()),
123            error,
124        )
125    })?;
126    runner_from_toml(&source).map_err(|error| {
127        RunnerError::configuration_source(
128            format!("invalid server config {}: {error}", path.display()),
129            error,
130        )
131    })
132}
133
134pub(crate) fn runner_from_toml(source: &str) -> RunnerResult<Runner> {
135    AlgorithmFactory::from_toml(source)?.config.build()
136}
137
138#[derive(Debug, Deserialize)]
139#[serde(deny_unknown_fields)]
140pub(crate) struct DeploymentConfig {
141    schema_version: u32,
142    fallback_client: Option<String>,
143    #[serde(default)]
144    llm_clients: BTreeMap<String, LlmClientConfig>,
145    targets: BTreeMap<String, TargetConfig>,
146    #[serde(default)]
147    decision_clients: BTreeMap<String, DecisionClientConfig>,
148    #[serde(default)]
149    decision_targets: BTreeMap<String, DecisionTargetConfig>,
150    routes: BTreeMap<String, RouteConfig>,
151}
152
153#[derive(Debug)]
154pub(crate) struct RouteConfig {
155    pub(crate) id: ModelId,
156    context_window: Option<u32>,
157    tool_calling: Option<bool>,
158    reasoning: Option<bool>,
159    vision: Option<bool>,
160    algorithm: AlgorithmSpec,
161    infra: Option<libsy::InfraPolicy>,
162}
163
164struct TargetPromptPolicy {
165    prompts: HashMap<ModelId, String>,
166    routing_answer_target: Option<ModelId>,
167}
168
169impl<'de> Deserialize<'de> for RouteConfig {
170    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
171    where
172        D: Deserializer<'de>,
173    {
174        let mut table = toml::Table::deserialize(deserializer)?;
175        let id = take_required(&mut table, "id")?;
176        let context_window = take_optional(&mut table, "context_window")?;
177        let tool_calling = take_optional(&mut table, "tool_calling")?;
178        let reasoning = take_optional(&mut table, "reasoning")?;
179        let vision = take_optional(&mut table, "vision")?;
180        let infra = take_optional(&mut table, "infra")?;
181        let algorithm = AlgorithmSpec::deserialize(toml::Value::Table(table))
182            .map_err(serde::de::Error::custom)?;
183        Ok(Self {
184            id,
185            context_window,
186            tool_calling,
187            reasoning,
188            vision,
189            algorithm,
190            infra,
191        })
192    }
193}
194
195fn take_required<T, E>(table: &mut toml::Table, name: &'static str) -> Result<T, E>
196where
197    T: DeserializeOwned,
198    E: serde::de::Error,
199{
200    let value = table.remove(name).ok_or_else(|| E::missing_field(name))?;
201    T::deserialize(value).map_err(E::custom)
202}
203
204fn take_optional<T, E>(table: &mut toml::Table, name: &'static str) -> Result<Option<T>, E>
205where
206    T: DeserializeOwned,
207    E: serde::de::Error,
208{
209    table
210        .remove(name)
211        .map(|value| T::deserialize(value).map_err(E::custom))
212        .transpose()
213}
214
215impl RouteConfig {
216    fn capabilities(&self) -> ModelCapabilities {
217        ModelCapabilities {
218            context_window: self.context_window,
219            tool_calling: self.tool_calling,
220            reasoning: self.reasoning,
221            vision: self.vision,
222        }
223    }
224
225    fn routing_target_names(&self) -> Vec<&str> {
226        self.algorithm.routing_target_names()
227    }
228
229    fn callable_target_names(&self) -> Vec<&str> {
230        self.algorithm.callable_target_names()
231    }
232}
233
234impl DeploymentConfig {
235    fn check_schema_version(&self) -> RunnerResult<()> {
236        if self.schema_version == SUPPORTED_SCHEMA_VERSION {
237            return Ok(());
238        }
239        Err(RunnerError::configuration(format!(
240            "unsupported schema_version {}; expected {SUPPORTED_SCHEMA_VERSION}",
241            self.schema_version
242        )))
243    }
244
245    /// This function looks up each name in `[targets]`, then in `[decision_targets]`. The server
246    /// rejects a name defined in both, so the lookup order does not change the result.
247    fn configured_targets(&self, names: Vec<&str>) -> Vec<ConfiguredTarget> {
248        names
249            .into_iter()
250            .map(|name| ConfiguredTarget {
251                name: name.to_string(),
252                model: self
253                    .targets
254                    .get(name)
255                    .map(|target| (&target.llm_client, &target.id))
256                    .or_else(|| {
257                        self.decision_targets
258                            .get(name)
259                            .map(|target| (&target.decision_client, &target.id))
260                    })
261                    .map(|(client, id)| TargetModel {
262                        client: client.clone(),
263                        id: id.clone(),
264                    }),
265            })
266            .collect()
267    }
268
269    fn decision_target(&self, name: &str) -> Option<DecisionTarget> {
270        let target = self.targets.get(name)?;
271        let client = self.llm_clients.get(&target.llm_client)?;
272        Some(DecisionTarget {
273            target: name.to_string(),
274            model: target.id.clone(),
275            format: client.format.wire_format(),
276            base_url: client.base_url.as_str().to_string(),
277            extra_body: target.extra_body.clone(),
278        })
279    }
280
281    /// Checks route identity and target references without constructing clients.
282    pub(crate) fn validate(&self) -> RunnerResult<()> {
283        self.check_schema_version()?;
284
285        let mut route_names_by_id = HashMap::new();
286        for (route_name, config) in &self.routes {
287            validate_value("route name", route_name)?;
288            validate_value(&format!("route {route_name} id"), &config.id)?;
289            if let Some(first_route_name) =
290                route_names_by_id.insert(config.id.as_str(), route_name.as_str())
291            {
292                return Err(RunnerError::configuration(format!(
293                    "routes {first_route_name} and {route_name} both use id {}; route ids must be unique",
294                    config.id
295                )));
296            }
297        }
298
299        for (name, target) in &self.targets {
300            validate_value("target name", name)?;
301            validate_value(&format!("target {name} id"), &target.id)?;
302            if !self.llm_clients.contains_key(&target.llm_client) {
303                return Err(RunnerError::configuration(format!(
304                    "target {name} references unknown llm client {}",
305                    target.llm_client
306                )));
307            }
308        }
309        for (name, target) in &self.decision_targets {
310            validate_value("decision target name", name)?;
311            validate_value(&format!("decision target {name} id"), &target.id)?;
312            if self.targets.contains_key(name) {
313                return Err(RunnerError::configuration(format!(
314                    "target {name} is defined as both an LLM and decision target"
315                )));
316            }
317            if !self.decision_clients.contains_key(&target.decision_client) {
318                return Err(RunnerError::configuration(format!(
319                    "decision target {name} references unknown decision client {}",
320                    target.decision_client
321                )));
322            }
323        }
324        Ok(())
325    }
326
327    /// Resolves each route once; native and host-owned execution share this path.
328    pub(crate) fn build_algorithms(
329        &self,
330    ) -> impl Iterator<Item = RunnerResult<(&str, &RouteConfig, AlgorithmAndModels)>> {
331        let targets = self.build_targets();
332        self.routes.iter().map(move |(route_name, config)| {
333            let decision_judge = config.algorithm.decision_judge();
334            for name in config.callable_target_names() {
335                let (exists, kind) = if decision_judge.is_some_and(|(judge, _)| judge == name) {
336                    (self.decision_targets.contains_key(name), "decision")
337                } else {
338                    (self.targets.contains_key(name), "LLM")
339                };
340                if !exists {
341                    return Err(RunnerError::configuration(format!(
342                        "route references unknown target {name}; route {route_name} requires target kind {kind}"
343                    )));
344                }
345            }
346            for name in config.routing_target_names().into_iter().chain(
347                decision_judge
348                    .into_iter()
349                    .flat_map(|(_, judge)| judge.candidates.values().map(String::as_str)),
350            ) {
351                if !self.targets.contains_key(name) {
352                    return Err(RunnerError::configuration(format!(
353                        "route {route_name} completion and candidate target {name} must be an LLM target"
354                    )));
355                }
356            }
357            if config.context_window == Some(0) {
358                return Err(RunnerError::configuration(format!(
359                    "route {route_name} context_window must be greater than zero"
360                )));
361            }
362            let (algorithm, models) = config
363                .algorithm
364                .build_with_runtime_models(route_name, &targets)
365                .map_err(|error| RunnerError::configuration_source(error.to_string(), error))?;
366            if let Some(infra) = &config.infra {
367                if !matches!(&config.algorithm, AlgorithmSpec::LlmClassifier { config }
368                    if matches!(config.mode, None | Some(crate::ClassifierMode::Capability))
369                        && config.escalation.is_none())
370                {
371                    return Err(RunnerError::configuration(format!(
372                        "route {route_name}: infra requires capability llm_classifier without escalation"
373                    )));
374                }
375                infra.validate().map_err(|error| {
376                    RunnerError::configuration_source(format!("route {route_name}: {error}"), error)
377                })?;
378            }
379            Ok((route_name.as_str(), config, (algorithm, models)))
380        })
381    }
382
383    fn build(self) -> RunnerResult<Runner> {
384        self.validate()?;
385
386        // The LLM client keeps one backend per model id, so two targets naming the same model on
387        // the same client share it. That is harmless when their request settings agree (an alias
388        // for a different system prompt, say) and silently wrong when they do not: the second
389        // target's reasoning_effort or extra_body would never reach the wire.
390        let mut seen_client_model_ids: HashMap<(&str, &str), (&String, &TargetConfig)> =
391            HashMap::new();
392        for (target_name, target) in &self.targets {
393            match seen_client_model_ids.entry((target.llm_client.as_str(), target.id.as_str())) {
394                std::collections::hash_map::Entry::Vacant(slot) => {
395                    slot.insert((target_name, target));
396                }
397                std::collections::hash_map::Entry::Occupied(slot) => {
398                    let (first_name, first) = slot.get();
399                    if first.reasoning_effort != target.reasoning_effort
400                        || first.extra_body != target.extra_body
401                        || first.omit_body_fields != target.omit_body_fields
402                    {
403                        return Err(RunnerError::configuration(format!(
404                            "targets {first_name} and {target_name} both name model {} on llm client {} but with different reasoning_effort, extra_body, or omit_body_fields; one target per model id is kept, so give each its own model id or llm client",
405                            target.id, target.llm_client
406                        )));
407                    }
408                    tracing::warn!(
409                        "target {target_name} reuses model id {} on llm client {}; only one target per id is kept and the other is dropped. Give each target a unique model id, or point both routes at one target.",
410                        target.id,
411                        target.llm_client
412                    );
413                }
414            }
415        }
416
417        let mut provider_api_keys = Vec::new();
418        let clients = self.build_clients(&mut provider_api_keys)?;
419        let decision_clients = self.build_decision_clients(&mut provider_api_keys)?;
420        let fallback_base_url = self.fallback_base_url()?;
421        let mut routes = Vec::with_capacity(self.routes.len());
422        let mut model_properties_probes = BTreeMap::new();
423        for entry in self.build_algorithms() {
424            let (route_name, config, (algorithm, models)) = entry?;
425            let capabilities = config.capabilities();
426            if let AlgorithmSpec::Vgr { config: vgr } = &config.algorithm
427                && let Some(probe) =
428                    self.build_model_properties_probe(&vgr.efficient_target, &clients)
429            {
430                model_properties_probes.insert(config.id.clone(), probe);
431            }
432            let (route_clients, caller_auth) =
433                self.build_route_clients(route_name, config, &clients, &decision_clients)?;
434            let anthropic_auxiliary_target =
435                self.build_anthropic_auxiliary_target(config, &clients);
436            let responses_auxiliary_target =
437                self.build_responses_auxiliary_target(config, &clients);
438            let decision_targets = config
439                .routing_target_names()
440                .into_iter()
441                .filter_map(|name| self.decision_target(name))
442                .collect();
443            let route = Route::new(
444                algorithm,
445                route_clients,
446                caller_auth,
447                capabilities,
448                anthropic_auxiliary_target,
449                responses_auxiliary_target,
450                decision_targets,
451                models,
452            )
453            .with_infra(config.infra.clone());
454            routes.push((config.id.clone(), route));
455        }
456        let runner = Runner::new(routes)
457            .with_fallback_url(fallback_base_url)
458            .with_provider_api_keys(provider_api_keys)
459            .with_model_properties_probes(model_properties_probes);
460        Ok(runner)
461    }
462
463    fn build_clients(
464        &self,
465        provider_api_keys: &mut Vec<String>,
466    ) -> RunnerResult<BTreeMap<String, Arc<TranslatingLlmClient>>> {
467        let mut models_by_client = self
468            .llm_clients
469            .keys()
470            .map(|name| (name.clone(), Vec::new()))
471            .collect::<BTreeMap<String, Vec<ModelConfig>>>();
472
473        for (name, client_config) in &self.llm_clients {
474            validate_value("llm client name", name)?;
475            if client_config.responses_reasoning.is_some()
476                && !matches!(client_config.format, ClientFormat::OpenAiResponses)
477            {
478                return Err(RunnerError::configuration(format!(
479                    "llm client {name} responses_reasoning is only valid for openai_responses"
480                )));
481            }
482            let backend = build_backend(
483                name,
484                client_config,
485                &BTreeMap::new(),
486                &BTreeSet::new(),
487                None,
488            )?;
489            let (Backend::OpenAiChat(config)
490            | Backend::OpenAiResponses(config)
491            | Backend::Anthropic(config)) = backend;
492            if let Some(key) = config.api_key {
493                provider_api_keys.push(key);
494            }
495        }
496        for (target_name, target) in &self.targets {
497            let client_config = self.llm_clients.get(&target.llm_client).ok_or_else(|| {
498                RunnerError::configuration(format!(
499                    "target {target_name} references unknown llm client {}",
500                    target.llm_client
501                ))
502            })?;
503            let model_configs = models_by_client
504                .get_mut(&target.llm_client)
505                .ok_or_else(|| {
506                    RunnerError::configuration("validated llm client was not initialized")
507                })?;
508            if let Some(effort) = &target.reasoning_effort {
509                if effort.trim().is_empty() {
510                    return Err(RunnerError::configuration(format!(
511                        "target {target_name} reasoning_effort must not be empty"
512                    )));
513                }
514                if matches!(client_config.format, ClientFormat::AnthropicMessages) {
515                    return Err(RunnerError::configuration(format!(
516                        "target {target_name} reasoning_effort is only supported on openai_chat and openai_responses clients"
517                    )));
518                }
519            }
520            model_configs.push(
521                ModelConfig::new(
522                    target.id.clone(),
523                    build_backend(
524                        &target.llm_client,
525                        client_config,
526                        &target.extra_body,
527                        &target.omit_body_fields,
528                        target.reasoning_effort.clone(),
529                    )?,
530                    None,
531                )
532                .with_responses_reasoning(client_config.responses_reasoning.unwrap_or_default()),
533            );
534        }
535
536        let mut clients = BTreeMap::new();
537        for (name, model_configs) in models_by_client {
538            let mut client = TranslatingLlmClient::new(&model_configs)
539                .map_err(|error| RunnerError::configuration(error.to_string()))?;
540            if !self.llm_clients[&name].fallback_on_rate_limit {
541                client = client.without_rate_limit_cooldown();
542            }
543            clients.insert(name, Arc::new(client));
544        }
545        Ok(clients)
546    }
547
548    fn build_decision_clients(
549        &self,
550        provider_api_keys: &mut Vec<String>,
551    ) -> RunnerResult<BTreeMap<String, Arc<dyn RoutedDecisionClient>>> {
552        self.decision_clients
553            .iter()
554            .map(|(name, config)| {
555                validate_value("decision client name", name)?;
556                let DecisionClientConfig {
557                    format,
558                    endpoint,
559                    api_key_env,
560                    timeout_ms,
561                } = config;
562                if *timeout_ms == 0 {
563                    return Err(RunnerError::configuration(format!(
564                        "decision client {name} timeout_ms must be at least 1"
565                    )));
566                }
567                let api_key = read_api_key(&format!("decision client {name}"), api_key_env)?;
568                let timeout = Duration::from_millis(*timeout_ms);
569                let client = match format {
570                    DecisionFormat::SystemOne => {
571                        SystemOneClient::new(endpoint.0.clone(), api_key.clone(), timeout)
572                            .map(|client| Arc::new(client) as Arc<dyn RoutedDecisionClient>)
573                    }
574                    DecisionFormat::OpenaiDecisions => {
575                        OpenAiDecisionClient::new(endpoint.0.clone(), api_key.clone(), timeout)
576                            .map(|client| Arc::new(client) as Arc<dyn RoutedDecisionClient>)
577                    }
578                }
579                .map_err(|error| RunnerError::configuration(error.to_string()))?;
580                provider_api_keys.push(api_key);
581                Ok((name.clone(), client))
582            })
583            .collect()
584    }
585
586    fn build_targets(&self) -> BTreeMap<String, ModelId> {
587        self.targets
588            .iter()
589            .map(|(name, config)| (name.clone(), config.id.clone()))
590            .chain(
591                self.decision_targets
592                    .iter()
593                    .map(|(name, config)| (name.clone(), config.id.clone())),
594            )
595            .collect()
596    }
597
598    /// Builds the client router for one route. The second value is the caller credential family
599    /// that the route's forwarding clients need, or `None` when no client forwards the caller's
600    /// credential. A request through the other family's APIs fails before any upstream call.
601    fn build_route_clients(
602        &self,
603        route_name: &str,
604        route: &RouteConfig,
605        clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
606        decision_clients: &BTreeMap<String, Arc<dyn RoutedDecisionClient>>,
607    ) -> RunnerResult<(ClientRouter, Option<CallerAuthKind>)> {
608        let TargetPromptPolicy {
609            prompts,
610            routing_answer_target,
611        } = self.build_route_target_prompts(route_name, route)?;
612        let mut by_model = HashMap::new();
613        let mut targets_by_model: HashMap<&str, (&str, &TargetConfig)> = HashMap::new();
614        let mut caller_auth = None;
615        let mut has_mixed_families = false;
616        let mut forwarding_origins = BTreeSet::new();
617        let mut decisions_by_model = HashMap::new();
618        for name in route.callable_target_names() {
619            if route
620                .algorithm
621                .decision_judge()
622                .is_some_and(|(judge, _)| judge == name)
623            {
624                let target = &self.decision_targets[name];
625                decisions_by_model.insert(
626                    target.id.clone(),
627                    decision_clients[&target.decision_client].clone(),
628                );
629                continue;
630            }
631            let target = self.targets.get(name).ok_or_else(|| {
632                RunnerError::configuration(format!("route references unknown target {name}"))
633            })?;
634            if let Some((first_name, first)) = targets_by_model.insert(&target.id, (name, target))
635                && first.llm_client != target.llm_client
636            {
637                return Err(RunnerError::configuration(format!(
638                    "route {route_name} targets {first_name} and {name} use model {} on different llm clients; execution is keyed by model id, so use distinct model ids within this route or put these targets in separate routes",
639                    target.id
640                )));
641            }
642            let client = clients.get(&target.llm_client).ok_or_else(|| {
643                RunnerError::configuration(format!("target {name} has no constructed llm client"))
644            })?;
645            let client_config = self.llm_clients.get(&target.llm_client).ok_or_else(|| {
646                RunnerError::configuration(format!(
647                    "target {name} references unknown llm client {}",
648                    target.llm_client
649                ))
650            })?;
651            if client_config.forward_auth {
652                let target_auth = client_config.format.caller_auth_kind();
653                has_mixed_families |= caller_auth.is_some_and(|kind| kind != target_auth);
654                caller_auth = Some(target_auth);
655                forwarding_origins.insert(client_config.base_url.0.origin().ascii_serialization());
656            }
657            let client: Arc<dyn RoutedLlmClient> = client.clone();
658            by_model.insert(target.id.clone(), client);
659        }
660        // Only forwarding clients count: an api_key_env client sends the server's own key.
661        // Forwarding clients must share one credential family unless they all use the same
662        // scheme, host, and port, such as one LLM gateway that accepts the caller's gateway key
663        // on every endpoint. Such a route serves Chat Completions and Responses callers because
664        // Anthropic clients forward the caller's `authorization` header unchanged.
665        if has_mixed_families {
666            if forwarding_origins.len() > 1 {
667                let origins = Vec::from_iter(forwarding_origins).join(", ");
668                return Err(RunnerError::configuration(format!(
669                    "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"
670                )));
671            }
672            caller_auth = Some(CallerAuthKind::OpenAi);
673        }
674        let completion_targets = route
675            .routing_target_names()
676            .into_iter()
677            .map(|name| self.targets[name].id.clone())
678            .collect::<Vec<_>>();
679        let router = ClientRouter::new_with_decision_clients(
680            by_model,
681            decisions_by_model,
682            prompts,
683            routing_answer_target,
684            &completion_targets,
685        )
686        .without_rate_limit_fallback(
687            route
688                .routing_target_names()
689                .into_iter()
690                .filter_map(|name| {
691                    let target = &self.targets[name];
692                    (!self.llm_clients[&target.llm_client].fallback_on_rate_limit)
693                        .then(|| target.id.clone())
694                })
695                .collect(),
696        );
697        Ok((router, caller_auth))
698    }
699
700    /// Builds the effective prompt policy for this route's completion targets.
701    fn build_route_target_prompts(
702        &self,
703        route_name: &str,
704        route: &RouteConfig,
705    ) -> RunnerResult<TargetPromptPolicy> {
706        let mut prompts = HashMap::new();
707        let mut aliases = HashMap::<&ModelId, Option<&str>>::new();
708        for name in route.algorithm.routing_target_names() {
709            let target = self.targets.get(name).ok_or_else(|| {
710                RunnerError::configuration(format!("route references unknown target {name}"))
711            })?;
712            let prompt = target.system_prompt.as_deref();
713            if aliases
714                .insert(&target.id, prompt)
715                .is_some_and(|configured| configured != prompt)
716            {
717                return Err(RunnerError::configuration(format!(
718                    "route {route_name} maps completion target aliases to model {} with different system_prompt values",
719                    target.id
720                )));
721            }
722            if let Some(prompt) = prompt {
723                prompts.insert(target.id.clone(), prompt.to_string());
724            }
725        }
726        let mut policy = TargetPromptPolicy {
727            prompts,
728            routing_answer_target: None,
729        };
730        let Some((response_name, dependency_name)) =
731            route.algorithm.routing_response_and_dependency()
732        else {
733            return Ok(policy);
734        };
735        let response = self.targets.get(response_name).ok_or_else(|| {
736            RunnerError::configuration(format!("route references unknown target {response_name}"))
737        })?;
738        if !policy.prompts.contains_key(&response.id) {
739            return Ok(policy);
740        }
741        let dependency = self.targets.get(dependency_name).ok_or_else(|| {
742            RunnerError::configuration(format!("route references unknown target {dependency_name}"))
743        })?;
744        if response.id == dependency.id {
745            return Err(RunnerError::configuration(format!(
746                "route {route_name} cannot apply system_prompt to target {response_name}: model {} is also used by routing-only target {dependency_name}",
747                response.id,
748            )));
749        }
750        policy.routing_answer_target = Some(response.id.clone());
751        Ok(policy)
752    }
753
754    fn fallback_base_url(&self) -> RunnerResult<Option<String>> {
755        let Some(name) = &self.fallback_client else {
756            return Ok(None);
757        };
758        let config = self.llm_clients.get(name).ok_or_else(|| {
759            RunnerError::configuration(format!(
760                "fallback_client references unknown llm client {name}"
761            ))
762        })?;
763        Ok(Some(config.base_url.as_str().to_string()))
764    }
765
766    fn build_model_properties_probe(
767        &self,
768        efficient_target_name: &str,
769        clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
770    ) -> Option<ModelPropertiesProbe> {
771        let efficient_target = self.targets.get(efficient_target_name)?;
772        let client_config = self.llm_clients.get(&efficient_target.llm_client)?;
773        if !matches!(
774            client_config.context_discovery,
775            Some(ContextDiscovery::LlamaCpp)
776        ) {
777            return None;
778        }
779        let client = clients.get(&efficient_target.llm_client)?;
780        Some(ModelPropertiesProbe {
781            model: efficient_target.id.clone(),
782            format: client_config.format.wire_format(),
783            client: Arc::clone(client),
784        })
785    }
786
787    fn build_anthropic_auxiliary_target(
788        &self,
789        route: &RouteConfig,
790        clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
791    ) -> Option<AuxiliaryTarget> {
792        route
793            .routing_target_names()
794            .into_iter()
795            .enumerate()
796            .filter_map(|(index, name)| {
797                let target = self.build_auxiliary_target(
798                    name,
799                    clients,
800                    AuxiliaryOperation::AnthropicCountTokens,
801                )?;
802                Some((count_tokens_priority(name, &target.model), index, target))
803            })
804            .min_by_key(|(priority, index, _)| (*priority, *index))
805            .map(|(_, _, target)| target)
806    }
807
808    fn build_responses_auxiliary_target(
809        &self,
810        route: &RouteConfig,
811        clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
812    ) -> Option<AuxiliaryTarget> {
813        route.routing_target_names().into_iter().find_map(|name| {
814            self.build_auxiliary_target(name, clients, AuxiliaryOperation::ResponsesInputTokens)
815        })
816    }
817
818    fn build_auxiliary_target(
819        &self,
820        name: &str,
821        clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
822        operation: AuxiliaryOperation,
823    ) -> Option<AuxiliaryTarget> {
824        let target = self.targets.get(name)?;
825        let client = clients.get(&target.llm_client)?;
826        client
827            .supports_auxiliary(&target.id, operation)
828            .then(|| AuxiliaryTarget {
829                model: target.id.clone(),
830                client: client.clone(),
831            })
832    }
833}
834
835fn count_tokens_priority(target_name: &str, model_id: &ModelId) -> usize {
836    let target_name = target_name.to_ascii_lowercase();
837    let model_id = model_id.to_ascii_lowercase();
838    ["opus", "sonnet", "haiku"]
839        .iter()
840        .position(|hint| target_name.contains(hint) || model_id.contains(hint))
841        .unwrap_or(3)
842}
843
844/// A client endpoint, parsed when the config loads rather than checked afterwards.
845///
846/// Holding a `HttpBaseUrl` is proof the value is an absolute HTTP(S) URL, so no
847/// later stage has to re-check it or can forget to.
848#[derive(Clone, Debug)]
849struct HttpBaseUrl(reqwest::Url);
850
851impl HttpBaseUrl {
852    fn as_str(&self) -> &str {
853        self.0.as_str()
854    }
855}
856
857impl<'de> Deserialize<'de> for HttpBaseUrl {
858    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
859    where
860        D: serde::Deserializer<'de>,
861    {
862        let raw = String::deserialize(deserializer)?;
863        let url = reqwest::Url::parse(raw.trim()).map_err(|error| {
864            serde::de::Error::custom(format!("base_url must be an absolute HTTP(S) URL: {error}"))
865        })?;
866        if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
867            return Err(serde::de::Error::custom(
868                "base_url must be an absolute HTTP(S) URL",
869            ));
870        }
871        Ok(Self(url))
872    }
873}
874
875#[derive(Debug, Deserialize)]
876#[serde(deny_unknown_fields)]
877struct LlmClientConfig {
878    format: ClientFormat,
879    base_url: HttpBaseUrl,
880    /// Opt-in context discovery for VGR's efficient target. Unset disables discovery.
881    context_discovery: Option<ContextDiscovery>,
882    api_key_env: Option<String>,
883    #[serde(default)]
884    forward_auth: bool,
885    #[serde(default)]
886    extra_headers: BTreeMap<String, String>,
887    #[serde(default = "default_max_retries")]
888    max_retries: u32,
889    #[serde(default = "default_fallback_on_rate_limit")]
890    fallback_on_rate_limit: bool,
891    /// Cooldown after an exhausted transient completion failure. Defaults to 5 seconds; zero disables it.
892    #[serde(default = "default_failure_cooldown_ms")]
893    failure_cooldown_ms: u64,
894    /// Deadline in milliseconds for all attempts and the complete response. Unset is unbounded.
895    timeout_ms: Option<u64>,
896    responses_reasoning: Option<ResponsesReasoningPolicy>,
897}
898
899#[derive(Debug, Deserialize)]
900#[serde(rename_all = "snake_case")]
901enum ContextDiscovery {
902    LlamaCpp,
903}
904
905#[derive(Debug, Deserialize)]
906#[serde(deny_unknown_fields)]
907struct DecisionClientConfig {
908    format: DecisionFormat,
909    endpoint: HttpBaseUrl,
910    api_key_env: String,
911    timeout_ms: u64,
912}
913
914#[derive(Debug, Deserialize)]
915#[serde(rename_all = "snake_case")]
916enum DecisionFormat {
917    SystemOne,
918    OpenaiDecisions,
919}
920
921#[derive(Debug, Deserialize)]
922#[serde(deny_unknown_fields)]
923struct DecisionTargetConfig {
924    id: ModelId,
925    decision_client: String,
926}
927
928#[derive(Debug, Deserialize)]
929#[serde(deny_unknown_fields)]
930struct TargetConfig {
931    id: ModelId,
932    llm_client: String,
933    #[serde(default)]
934    extra_body: BTreeMap<String, Value>,
935    /// Top-level request fields dropped from the outbound body before `extra_body` is merged.
936    /// For providers that reject an otherwise standard field.
937    #[serde(default)]
938    omit_body_fields: BTreeSet<String>,
939    system_prompt: Option<String>,
940    /// Reasoning effort forced on every request to this target, replacing the caller's value.
941    /// Only meaningful on `openai_chat` and `openai_responses` clients.
942    reasoning_effort: Option<String>,
943}
944
945#[derive(Clone, Copy, Debug, Deserialize)]
946enum ClientFormat {
947    #[serde(rename = "openai_chat")]
948    OpenAiChat,
949    #[serde(rename = "openai_responses")]
950    OpenAiResponses,
951    #[serde(rename = "anthropic_messages")]
952    AnthropicMessages,
953}
954
955impl ClientFormat {
956    const fn wire_format(self) -> WireFormat {
957        match self {
958            Self::OpenAiChat => WireFormat::OpenAiChat,
959            Self::OpenAiResponses => WireFormat::OpenAiResponses,
960            Self::AnthropicMessages => WireFormat::AnthropicMessages,
961        }
962    }
963
964    const fn caller_auth_kind(self) -> CallerAuthKind {
965        match self {
966            Self::AnthropicMessages => CallerAuthKind::Anthropic,
967            Self::OpenAiChat | Self::OpenAiResponses => CallerAuthKind::OpenAi,
968        }
969    }
970}
971
972fn build_backend(
973    client_name: &str,
974    config: &LlmClientConfig,
975    extra_body: &BTreeMap<String, Value>,
976    omit_body_fields: &BTreeSet<String>,
977    reasoning_effort: Option<String>,
978) -> RunnerResult<Backend> {
979    if config.max_retries > MAX_CONFIGURED_RETRIES {
980        return Err(RunnerError::configuration(format!(
981            "llm client {client_name} max_retries must be at most {MAX_CONFIGURED_RETRIES}"
982        )));
983    }
984    if config.timeout_ms == Some(0) {
985        return Err(RunnerError::configuration(format!(
986            "llm client {client_name} timeout_ms must be at least 1"
987        )));
988    }
989    if config.forward_auth && config.api_key_env.is_some() {
990        return Err(RunnerError::configuration(format!(
991            "llm client {client_name} cannot set both forward_auth and api_key_env"
992        )));
993    }
994    let api_key = config
995        .api_key_env
996        .as_deref()
997        .map(|variable| read_api_key(&format!("llm client {client_name}"), variable))
998        .transpose()?;
999    let http = HttpBackendConfig {
1000        base_url: config.base_url.as_str().to_string(),
1001        api_key,
1002        forward_auth: config.forward_auth,
1003        extra_headers: config.extra_headers.clone(),
1004        extra_body: extra_body.clone(),
1005        omit_body_fields: omit_body_fields.clone(),
1006        reasoning_effort,
1007        max_retries: config.max_retries,
1008        failure_cooldown: Duration::from_millis(config.failure_cooldown_ms),
1009        timeout: config.timeout_ms.map(Duration::from_millis),
1010    };
1011    let backend = match config.format {
1012        ClientFormat::OpenAiChat => Backend::OpenAiChat(http),
1013        ClientFormat::OpenAiResponses => Backend::OpenAiResponses(http),
1014        ClientFormat::AnthropicMessages => Backend::Anthropic(http),
1015    };
1016    Ok(backend)
1017}
1018
1019fn read_api_key(client: &str, variable: &str) -> RunnerResult<String> {
1020    if variable.trim().is_empty() {
1021        return Err(RunnerError::configuration(format!(
1022            "{client} api_key_env must not be empty"
1023        )));
1024    }
1025    // A name with `=` is often a pasted `NAME=key` line, so this error omits the name.
1026    if variable.contains(['\0', '=']) {
1027        return Err(RunnerError::configuration(format!(
1028            "{client} api_key_env must not contain '=' or NUL"
1029        )));
1030    }
1031    let api_key = std::env::var(variable).map_err(|error| {
1032        // VarError can contain the raw credential, so errors report only its category.
1033        let reason = match error {
1034            std::env::VarError::NotPresent => "environment variable not found",
1035            std::env::VarError::NotUnicode(_) => "environment variable is not valid Unicode",
1036        };
1037        RunnerError::configuration(format!(
1038            "{client} could not read api_key_env {variable}: {reason}"
1039        ))
1040    })?;
1041    if api_key.trim().is_empty() {
1042        return Err(RunnerError::configuration(format!(
1043            "{client} api_key_env {variable} is empty"
1044        )));
1045    }
1046    Ok(api_key)
1047}
1048
1049// A function so that serde default can use it.
1050const fn default_max_retries() -> u32 {
1051    DEFAULT_MAX_RETRIES
1052}
1053
1054const fn default_fallback_on_rate_limit() -> bool {
1055    true
1056}
1057
1058const fn default_failure_cooldown_ms() -> u64 {
1059    5000
1060}
1061
1062fn validate_value(label: &str, value: &str) -> RunnerResult<()> {
1063    if value.trim().is_empty() || value.trim() != value {
1064        return Err(RunnerError::configuration(format!(
1065            "{label} must be non-empty and have no surrounding whitespace"
1066        )));
1067    }
1068    Ok(())
1069}
1070
1071#[cfg(test)]
1072mod tests {
1073    use super::*;
1074
1075    #[test]
1076    fn route_presentation_fields_are_split_from_the_algorithm() {
1077        let route: RouteConfig = toml::from_str(
1078            r#"
1079type = "random"
1080id = "switchyard/random"
1081context_window = 128000
1082tool_calling = true
1083reasoning = false
1084targets = ["fast", "strong"]
1085weights = [1.0, 2.0]
1086seed = 7
1087"#,
1088        )
1089        .expect("route should deserialize");
1090
1091        assert_eq!(route.id, "switchyard/random");
1092        assert_eq!(route.context_window, Some(128_000));
1093        assert_eq!(route.tool_calling, Some(true));
1094        assert_eq!(route.reasoning, Some(false));
1095        assert_eq!(route.algorithm.routing_target_names(), ["fast", "strong"]);
1096    }
1097
1098    #[test]
1099    fn route_algorithm_unknown_fields_are_rejected() {
1100        let error = toml::from_str::<RouteConfig>(
1101            r#"
1102type = "passthrough"
1103id = "switchyard/fast"
1104target = "fast"
1105bogus = true
1106"#,
1107        )
1108        .expect_err("unknown route keys must be rejected");
1109
1110        assert!(error.to_string().contains("bogus"), "{error}");
1111    }
1112
1113    #[test]
1114    fn deployment_errors_keep_one_path_context() {
1115        let path = Path::new("/definitely/missing/switchyard-routes.toml");
1116        let error = match load_runner(path) {
1117            Ok(_) => panic!("missing config should fail"),
1118            Err(error) => error,
1119        };
1120        let message = error.to_string();
1121
1122        assert_eq!(message.matches(&path.display().to_string()).count(), 1);
1123        assert!(message.starts_with("failed to read server config"));
1124    }
1125}
1126#[cfg(test)]
1127mod deployment_tests {
1128    use super::*;
1129    use serde_json::json;
1130    use switchyard_protocol::Category;
1131
1132    const VALID_CONFIG: &str = r#"
1133schema_version = 1
1134
1135[llm_clients.primary]
1136format = "openai_chat"
1137base_url = "https://example.test/v1"
1138
1139[llm_clients.responses]
1140format = "openai_responses"
1141base_url = "https://example.test/v1"
1142
1143[llm_clients.anthropic]
1144format = "anthropic_messages"
1145base_url = "https://example.test"
1146
1147[targets.classifier]
1148id = "classifier/model"
1149llm_client = "primary"
1150
1151[targets.strong]
1152id = "strong/model"
1153llm_client = "responses"
1154
1155[targets.weak]
1156id = "weak/model"
1157llm_client = "anthropic"
1158
1159[routes.noop]
1160id = "switchyard/noop"
1161type = "noop"
1162
1163[routes.random]
1164id = "switchyard/random"
1165type = "random"
1166targets = ["strong", "weak"]
1167
1168[routes.classifier]
1169id = "switchyard/classifier"
1170type = "llm_classifier"
1171classifier_target = "classifier"
1172strong_target = "strong"
1173weak_target = "weak"
1174base_threshold = 0.5
1175
1176[routes.passthrough]
1177id = "switchyard/passthrough"
1178type = "passthrough"
1179target = "weak"
1180
1181[routes.plan_execute]
1182id = "switchyard/plan-execute"
1183type = "plan_execute"
1184capable_target = "strong"
1185efficient_target = "weak"
1186"#;
1187
1188    #[test]
1189    fn public_runner_from_toml_builds_a_deployment() -> RunnerResult<()> {
1190        let config = format!(
1191            "{VALID_CONFIG}\n[routes.classifier.infra]\n\
1192             max_signal_age_ms = 1000\nmin_waiting_requests = 4\nmax_capable_inflight = 2"
1193        );
1194        let runner = Runner::from_toml(&config)?;
1195        assert!(
1196            Runner::from_toml(
1197                &config.replace("min_waiting_requests = 4", "min_waiting_requests = 0")
1198            )
1199            .is_err()
1200        );
1201
1202        let classifier = runner
1203            .route("switchyard/classifier")
1204            .expect("classifier route should exist");
1205        let models = classifier.models();
1206        assert_eq!(
1207            models.models_for(&Category::Judge),
1208            [ModelId::from("classifier/model")]
1209        );
1210        assert_eq!(
1211            models.models_for(&Category::Efficient),
1212            [ModelId::from("weak/model")]
1213        );
1214        assert_eq!(
1215            models.models_for(&Category::Capable),
1216            [ModelId::from("strong/model")]
1217        );
1218        assert_eq!(
1219            models.models_for(&Category::Any),
1220            [ModelId::from("weak/model"), ModelId::from("strong/model")]
1221        );
1222        let plan_execute = runner
1223            .route("switchyard/plan-execute")
1224            .expect("plan-execute route should exist");
1225        assert_eq!(
1226            plan_execute.models().models_for(&Category::Capable),
1227            [ModelId::from("strong/model")]
1228        );
1229        assert_eq!(
1230            plan_execute.models().models_for(&Category::Efficient),
1231            [ModelId::from("weak/model")]
1232        );
1233        assert!(runner.route("switchyard/passthrough").is_some());
1234        Ok(())
1235    }
1236
1237    #[test]
1238    fn duplicate_route_ids_are_rejected() {
1239        let config = format!(
1240            r#"{VALID_CONFIG}
1241
1242[routes.duplicate]
1243id = "switchyard/passthrough"
1244type = "passthrough"
1245target = "strong"
1246"#
1247        );
1248
1249        let error = error_message(&config);
1250
1251        assert!(
1252            error.contains(
1253                "routes duplicate and passthrough both use id switchyard/passthrough; route ids must be unique"
1254            ),
1255            "{error}"
1256        );
1257    }
1258
1259    #[tokio::test]
1260    async fn plan_execute_applies_custom_tool_semantics() -> RunnerResult<()> {
1261        use switchyard_protocol::{ContentBlock, Message, Request, Role, ToolCall};
1262
1263        let configured = format!(
1264            "{VALID_CONFIG}\n[routes.plan_execute.tool_semantics]\nmutate = [\"persist_source_file\"]"
1265        );
1266        let mut request = Request::default();
1267        request.llm_request.messages.push(Message {
1268            role: Role::Assistant,
1269            content: vec![ContentBlock::ToolCall(ToolCall {
1270                id: "write-1".to_string(),
1271                name: "mcp__files__Persist_Source_File".to_string(),
1272                arguments: json!({"path": "task.py", "content": "print('hello')"}),
1273            })],
1274        });
1275
1276        for (config, expected) in [
1277            (VALID_CONFIG, "strong/model"),
1278            (configured.as_str(), "weak/model"),
1279        ] {
1280            let runner = Runner::from_toml(config)?;
1281            let route = runner.route("switchyard/plan-execute").unwrap();
1282            let outcome = route.decide(request.clone()).await?;
1283            assert_eq!(outcome.selected_model_ids[0], expected);
1284        }
1285        Ok(())
1286    }
1287
1288    fn error_message(toml: &str) -> String {
1289        match runner_from_toml(toml) {
1290            Ok(_) => "configuration unexpectedly succeeded".to_string(),
1291            Err(error) => error.to_string(),
1292        }
1293    }
1294
1295    fn vgr_config() -> String {
1296        format!(
1297            r#"{VALID_CONFIG}
1298
1299[routes.vgr]
1300id = "switchyard/vgr"
1301type = "vgr"
1302efficient_target = "weak"
1303capable_target = "strong"
1304judge_target = "classifier"
1305mode = "active"
1306active_approval = "prospective-validation-and-canary-approved"
1307task_typing = false
1308"#
1309        )
1310    }
1311
1312    #[test]
1313    fn vgr_compaction_is_opt_in_from_toml() -> RunnerResult<()> {
1314        for setting in ["", "compact_handoff = false", "compact_handoff = true"] {
1315            let source = format!("{}\nagentic_handoff = true\n{setting}\n", vgr_config());
1316            let parsed: DeploymentConfig = toml::from_str(&source).expect("valid VGR config");
1317            let AlgorithmSpec::Vgr { config } = &parsed.routes["vgr"].algorithm else {
1318                panic!("expected VGR route");
1319            };
1320            assert_eq!(config.compact_handoff, setting.ends_with("= true"));
1321            assert!(runner_from_toml(&source)?.route("switchyard/vgr").is_some());
1322        }
1323        Ok(())
1324    }
1325
1326    #[test]
1327    fn vgr_route_builds_and_rejects_unsafe_configuration() -> RunnerResult<()> {
1328        let runner = runner_from_toml(&vgr_config())?;
1329        assert!(runner.route("switchyard/vgr").is_some());
1330
1331        let compact = format!("{}\ncompact_handoff = true\n", vgr_config());
1332        assert!(error_message(&compact).contains("compact_handoff requires agentic_handoff"));
1333        let compact = format!("{compact}\nagentic_handoff = true\n");
1334        assert!(
1335            runner_from_toml(&compact)?
1336                .route("switchyard/vgr")
1337                .is_some()
1338        );
1339
1340        let identical_ids = vgr_config().replace(
1341            "efficient_target = \"weak\"",
1342            "efficient_target = \"strong\"",
1343        );
1344        assert!(error_message(&identical_ids).contains("distinct efficient and capable model IDs"));
1345
1346        let bad_approval =
1347            vgr_config().replace("prospective-validation-and-canary-approved", "approved");
1348        assert!(error_message(&bad_approval).contains("approval attestation"));
1349
1350        let missing_target = vgr_config().replace(
1351            "efficient_target = \"weak\"",
1352            "efficient_target = \"missing\"",
1353        );
1354        assert!(error_message(&missing_target).contains("unknown target missing"));
1355
1356        let zero_cooldown = format!("{}breaker_cooldown_seconds = 0\n", vgr_config());
1357        assert!(
1358            error_message(&zero_cooldown).contains("breaker_cooldown_seconds must be a positive")
1359        );
1360        Ok(())
1361    }
1362
1363    #[test]
1364    fn vgr_local_server_example_builds_and_rejects_unknown_discovery() -> RunnerResult<()> {
1365        let example = include_str!("../../../examples/vgr-local-servers.toml");
1366        let runner = runner_from_toml(example)?;
1367        assert!(runner.route("switchyard/vgr").is_some());
1368        assert!(runner.route("switchyard/only-efficient").is_some());
1369        let parsed: DeploymentConfig = toml::from_str(example).expect("valid VGR example");
1370        assert!(matches!(
1371            &parsed.routes["auxiliary"].algorithm,
1372            AlgorithmSpec::Passthrough { target, .. } if target == "efficient"
1373        ));
1374        let invalid = example.replace(
1375            "context_discovery = \"llama_cpp\"",
1376            "context_discovery = \"guess\"",
1377        );
1378        assert!(error_message(&invalid).contains("unknown variant `guess`"));
1379        Ok(())
1380    }
1381
1382    fn with_subagent_llm_classifier(config: &str, route: &str, extra: &str) -> String {
1383        let mut configured = config.to_string();
1384        configured.push_str(&format!("\n[routes.{route}.subagents]\n"));
1385        configured.push_str(
1386            r#"type = "llm_classifier"
1387mode = "custom"
1388models = { judge = ["classifier"], capable = ["strong"], efficient = ["weak"], any = ["strong", "weak"] }
1389default_target = "efficient"
1390prompt = "Select a target for this delegated task."
1391response_schema = '{"type":"object","properties":{"target":{"type":"string","enum":["capable","efficient"]}},"required":["target"],"additionalProperties":false}'
1392policy = { type = "target_selector", selector = "/target" }
1393classify_trigger = "new_session""#,
1394        );
1395        configured.push_str(extra);
1396        configured
1397    }
1398
1399    #[test]
1400    fn subagent_models_stay_separate_from_the_parent_tiers() -> RunnerResult<()> {
1401        // The sub-agent target is also the parent's capable tier. Merged into one group it
1402        // would be indistinguishable from that tier, and delegated work would follow the
1403        // parent's ordering instead of its own configured target.
1404        let runner = runner_from_toml(&with_subagent_passthrough(&stage_config(), "stage"))?;
1405        let models = runner
1406            .route("switchyard/stage")
1407            .expect("stage route should exist")
1408            .models();
1409
1410        assert_eq!(
1411            models.subagent_models_for(&Category::Any),
1412            [ModelId::from("strong/model")]
1413        );
1414        assert_eq!(
1415            models.models_for(&Category::Any),
1416            [ModelId::from("strong/model"), ModelId::from("weak/model")]
1417        );
1418        Ok(())
1419    }
1420
1421    fn with_subagent_passthrough(config: &str, route: &str) -> String {
1422        format!("{config}\n[routes.{route}.subagents]\ntype = \"passthrough\"\ntarget = \"strong\"")
1423    }
1424
1425    fn stage_config() -> String {
1426        format!(
1427            r#"{VALID_CONFIG}
1428[targets.stage_judge]
1429id = "stage-judge/model"
1430llm_client = "primary"
1431
1432[routes.stage]
1433id = "switchyard/stage"
1434type = "stage_router"
1435capable_target = "strong"
1436efficient_target = "weak"
1437picker = "efficient_first"
1438confidence_threshold = 1.0
1439capable_hold_turns = 2
1440
1441[routes.stage.tool_semantics]
1442observe = ["lookup_customer"]
1443mutate = ["send_payment"]
1444plan = ["create_workflow"]
1445new = ["send_message"]
1446
1447[routes.stage.classifier]
1448target = "stage_judge"
1449base_threshold = 0.5
1450"#
1451        )
1452    }
1453
1454    fn composite_config() -> String {
1455        format!(
1456            r#"{VALID_CONFIG}
1457[targets.tier_judge]
1458id = "tier-judge/model"
1459llm_client = "primary"
1460
1461[routes.composed]
1462id = "switchyard/hier"
1463type = "composite"
1464
1465[routes.composed.classifier]
1466target = "tier_judge"
1467base_threshold = 0.5
1468classify_trigger = "user_turn"
1469
1470[routes.composed.stage]
1471capable_target = "strong"
1472efficient_target = "weak"
1473confidence_threshold = 0.5
1474capable_hold_turns = 2
1475
1476[routes.composed.stage.tool_semantics]
1477new = ["send_message"]
1478"#
1479        )
1480    }
1481
1482    #[test]
1483    fn stage_and_composite_validate_hold_turns() {
1484        for source in [
1485            stage_config(),
1486            stage_config().replace("efficient_first", "capable_first"),
1487            composite_config(),
1488        ] {
1489            for field in ["capable_hold_turns", "efficient_hold_turns"] {
1490                for (turns, valid) in [
1491                    (-2_i64, false),
1492                    (-1, true),
1493                    (0, true),
1494                    (3, true),
1495                    (i64::from(u32::MAX), true),
1496                    (i64::from(u32::MAX) + 1, false),
1497                ] {
1498                    let configured =
1499                        source.replace("capable_hold_turns = 2", &format!("{field} = {turns}"));
1500                    let result = runner_from_toml(&configured);
1501                    assert_eq!(result.is_ok(), valid, "{field} = {turns}");
1502                    if let Err(error) = result {
1503                        assert!(error.to_string().contains(field), "{error}");
1504                    }
1505                }
1506            }
1507        }
1508    }
1509
1510    #[test]
1511    fn composite_decision_judge_reuses_capability_validation() {
1512        let source = composite_config().replace(
1513            "[routes.composed.classifier]\ntarget = \"tier_judge\"\nbase_threshold = 0.5",
1514            "[routes.composed.classifier]\ntarget = \"tier_judge\"",
1515        ) + r#"
1516[routes.composed.classifier.decision]
1517cutoff = 0.4
1518candidates = { a = "strong", b = "weak" }
1519evidence = { description = "candidate comparison" }
1520"#;
1521        for (source, valid) in [
1522            (source.clone(), true),
1523            (
1524                source.replace(
1525                    "target = \"tier_judge\"",
1526                    "target = \"tier_judge\"\nbase_threshold = 0.5",
1527                ),
1528                false,
1529            ),
1530            (source.replace("b = \"weak\"", "b = \"strong\""), false),
1531        ] {
1532            let deployment: DeploymentConfig = toml::from_str(&source).unwrap();
1533            let spec = &deployment.routes["composed"].algorithm;
1534            assert_eq!(spec.decision_judge().unwrap().0, "tier_judge");
1535            assert_eq!(
1536                spec.build_with_runtime_models("composed", &deployment.build_targets())
1537                    .is_ok(),
1538                valid
1539            );
1540        }
1541    }
1542
1543    #[test]
1544    fn composite_custom_judge_builds_and_rejects_capability_settings() -> RunnerResult<()> {
1545        let source = composite_config().replace(
1546            "base_threshold = 0.5\nclassify_trigger",
1547            r#"mode = "custom"
1548prompt = 'Select efficient or capable.'
1549response_schema = '{"type":"object","properties":{"decision":{"type":"object","properties":{"target":{"type":"string","enum":["efficient","capable"]}},"required":["target"],"additionalProperties":false}},"required":["decision"],"additionalProperties":false}'
1550policy = { type = "target_selector", selector = "/decision/target" }
1551classify_trigger"#,
1552        );
1553        runner_from_toml(&source)?;
1554        runner_from_toml(
1555            &source.replace("mode = \"custom\"", "mode = \"custom\"\nfail_open = true"),
1556        )?;
1557        for (setting, expected) in [
1558            (
1559                "base_threshold = 0.5",
1560                "composite custom classifier cannot use capability thresholds",
1561            ),
1562            (
1563                "fail_open = false",
1564                "composite custom classifiers always fail open",
1565            ),
1566        ] {
1567            let invalid = source.replace(
1568                "mode = \"custom\"",
1569                &format!("mode = \"custom\"\n{setting}"),
1570            );
1571            assert!(error_message(&invalid).contains(expected));
1572        }
1573        Ok(())
1574    }
1575
1576    #[test]
1577    fn composite_route_builds_and_claims_both_tiers_and_its_judge() -> RunnerResult<()> {
1578        let runner = runner_from_toml(&composite_config())?;
1579        assert!(
1580            runner
1581                .models()
1582                .any(|model| model.id.as_str() == "switchyard/hier")
1583        );
1584        Ok(())
1585    }
1586
1587    #[test]
1588    fn builds_all_supported_algorithm_types() -> RunnerResult<()> {
1589        let state = runner_from_toml(VALID_CONFIG)?;
1590        // The model id array is sorted alphabetically
1591        assert_eq!(
1592            state
1593                .models()
1594                .map(|model| model.id.as_str())
1595                .collect::<Vec<_>>(),
1596            [
1597                "switchyard/classifier",
1598                "switchyard/noop",
1599                "switchyard/passthrough",
1600                "switchyard/plan-execute",
1601                "switchyard/random",
1602            ]
1603        );
1604        Ok(())
1605    }
1606
1607    #[test]
1608    fn stage_rejects_ambiguous_or_non_additive_tool_semantics() {
1609        for (configured, expected) in [
1610            (
1611                stage_config().replace(
1612                    "mutate = [\"send_payment\"]",
1613                    "mutate = [\"LOOKUP_CUSTOMER\"]",
1614                ),
1615                "appears in both tool_semantics.observe and tool_semantics.mutate",
1616            ),
1617            (
1618                stage_config().replace("new = [\"send_message\"]", "new = [\"Read\"]"),
1619                "already has built-in semantics and cannot be reclassified",
1620            ),
1621            (
1622                stage_config().replace("observe = [\"lookup_customer\"]", "observe = [\" \"]"),
1623                "contains an empty tool name",
1624            ),
1625        ] {
1626            let message = error_message(&configured);
1627            assert!(
1628                message.contains(expected),
1629                "expected {expected:?} in error, got: {message}"
1630            );
1631        }
1632    }
1633
1634    #[test]
1635    fn passthrough_and_stage_accept_subagent_routing() -> RunnerResult<()> {
1636        let stage = stage_config();
1637        let stage_with_classifier = with_subagent_llm_classifier(&stage, "stage", "");
1638        let parsed: DeploymentConfig = toml::from_str(&stage_with_classifier).map_err(|error| {
1639            RunnerError::configuration(format!("failed to parse stage config: {error}"))
1640        })?;
1641        let Some(stage_route) = parsed.routes.get("stage") else {
1642            return Err(RunnerError::configuration("stage route is missing"));
1643        };
1644        let callable_targets = stage_route.callable_target_names();
1645        for expected in ["strong", "weak", "stage_judge", "classifier"] {
1646            assert!(callable_targets.contains(&expected));
1647        }
1648
1649        for configured in [
1650            with_subagent_llm_classifier(VALID_CONFIG, "passthrough", ""),
1651            with_subagent_passthrough(VALID_CONFIG, "passthrough"),
1652            stage_with_classifier,
1653            with_subagent_passthrough(&stage, "stage"),
1654        ] {
1655            runner_from_toml(&configured)?;
1656        }
1657        Ok(())
1658    }
1659
1660    #[test]
1661    fn aliased_completion_targets_reject_prompt_conflicts() {
1662        let configured = stage_config()
1663            .replace(
1664                "id = \"strong/model\"\nllm_client = \"responses\"",
1665                "id = \"strong/model\"\nllm_client = \"responses\"\nsystem_prompt = \"capable\"",
1666            )
1667            .replace(
1668                "[routes.stage]",
1669                "[targets.strong_alias]\nid = \"strong/model\"\nllm_client = \"responses\"\n\n[routes.stage]",
1670            )
1671            .replace("efficient_target = \"weak\"", "efficient_target = \"strong_alias\"");
1672        let message = error_message(&configured);
1673        assert!(
1674            message.contains("completion target aliases to model strong/model with different system_prompt values"),
1675            "unexpected error: {message}"
1676        );
1677    }
1678
1679    #[test]
1680    fn prompted_routing_response_cannot_share_a_model_with_a_dependency() {
1681        let configured = VALID_CONFIG
1682            .replace(
1683                "id = \"classifier/model\"\nllm_client = \"primary\"",
1684                "id = \"weak/model\"\nllm_client = \"primary\"",
1685            )
1686            .replace(
1687                "id = \"weak/model\"\nllm_client = \"anthropic\"",
1688                "id = \"weak/model\"\nllm_client = \"anthropic\"\nsystem_prompt = \"answer prompt\"",
1689            )
1690            .replace(
1691                "base_threshold = 0.5",
1692                "base_threshold = 0.5\nescalation = { confirmations = 1 }",
1693            );
1694
1695        let message = error_message(&configured);
1696
1697        assert!(
1698            message.contains("cannot apply system_prompt to target weak: model weak/model is also used by routing-only target classifier"),
1699            "unexpected error: {message}"
1700        );
1701    }
1702
1703    #[test]
1704    fn rejects_invalid_unreferenced_llm_client() {
1705        for (settings, expected) in [
1706            (
1707                "base_url = \"not a url\"",
1708                "base_url must be an absolute HTTP(S) URL",
1709            ),
1710            (
1711                "base_url = \"https://example.test\"\ntimeout_ms = 0",
1712                "timeout_ms must be at least 1",
1713            ),
1714        ] {
1715            let invalid = format!(
1716                "{VALID_CONFIG}\n[llm_clients.unused]\nformat = \"openai_chat\"\n{settings}"
1717            );
1718            let message = error_message(&invalid);
1719            assert!(message.contains(expected), "unexpected error: {message}");
1720        }
1721    }
1722
1723    #[test]
1724    fn an_escalation_table_switches_the_classifier_route_to_escalation() -> RunnerResult<()> {
1725        // Present: the classifier target judges the weak tier's reply each turn instead of
1726        // picking a tier ahead of it. The route builds either way, so the assertion is that
1727        // the knob parses and its settings reach the algorithm's validation.
1728        let escalating = VALID_CONFIG.replace(
1729            "base_threshold = 0.5",
1730            "base_threshold = 0.5\nescalation = { confirmations = 2 }",
1731        );
1732        runner_from_toml(&escalating)?;
1733
1734        let reversible = VALID_CONFIG.replace(
1735            "base_threshold = 0.5",
1736            "base_threshold = 0.5\nescalation = { confirmations = 2, deescalation = { strong_min_calls = 3, confirmations = 2, strong_max_calls = 6, weak_cooldown_calls = 8 } }",
1737        );
1738        runner_from_toml(&reversible)?;
1739
1740        // A setting that would starve the judge is rejected here rather than on the first
1741        // request, the same as any other unusable route configuration.
1742        let starved = VALID_CONFIG.replace(
1743            "base_threshold = 0.5",
1744            "base_threshold = 0.5\nescalation = { confirmations = 0 }",
1745        );
1746        assert!(error_message(&starved).contains("confirmations must be at least 1"));
1747
1748        Ok(())
1749    }
1750
1751    #[test]
1752    fn a_target_reasoning_effort_parses_and_is_rejected_where_unsupported() -> RunnerResult<()> {
1753        let strong = "[targets.strong]\nid = \"strong/model\"\nllm_client = \"responses\"";
1754        let weak = "[targets.weak]\nid = \"weak/model\"\nllm_client = \"anthropic\"";
1755        assert!(VALID_CONFIG.contains(strong) && VALID_CONFIG.contains(weak));
1756
1757        let forced = VALID_CONFIG.replace(strong, &format!("{strong}\nreasoning_effort = \"max\""));
1758        runner_from_toml(&forced)?;
1759
1760        let blank = VALID_CONFIG.replace(strong, &format!("{strong}\nreasoning_effort = \" \""));
1761        assert!(error_message(&blank).contains("reasoning_effort must not be empty"));
1762
1763        let anthropic = VALID_CONFIG.replace(weak, &format!("{weak}\nreasoning_effort = \"high\""));
1764        assert!(
1765            error_message(&anthropic)
1766                .contains("only supported on openai_chat and openai_responses")
1767        );
1768        Ok(())
1769    }
1770
1771    #[test]
1772    fn duplicate_targets_with_conflicting_settings_are_rejected() -> RunnerResult<()> {
1773        let strong = "[targets.strong]\nid = \"strong/model\"\nllm_client = \"responses\"";
1774        assert!(VALID_CONFIG.contains(strong));
1775        // Same model, same client, different effort: the second target could never take effect.
1776        let conflicting = VALID_CONFIG.replace(
1777            strong,
1778            &format!(
1779                "{strong}\n\n[targets.strong_max]\nid = \"strong/model\"\nllm_client = \"responses\"\nreasoning_effort = \"max\""
1780            ),
1781        );
1782        assert!(
1783            error_message(&conflicting)
1784                .contains("different reasoning_effort, extra_body, or omit_body_fields"),
1785            "{}",
1786            error_message(&conflicting)
1787        );
1788        // An alias with identical settings is still allowed (it only warns).
1789        let alias = VALID_CONFIG.replace(
1790            strong,
1791            &format!("{strong}\n\n[targets.strong_alias]\nid = \"strong/model\"\nllm_client = \"responses\""),
1792        );
1793        runner_from_toml(&alias)?;
1794        Ok(())
1795    }
1796
1797    #[test]
1798    fn classifier_judge_completion_caps_are_configurable() -> RunnerResult<()> {
1799        let capability = VALID_CONFIG.replace(
1800            "base_threshold = 0.5",
1801            "base_threshold = 0.5\nmax_output_tokens = 512",
1802        );
1803        runner_from_toml(&capability)?;
1804
1805        let escalation = VALID_CONFIG.replace(
1806            "base_threshold = 0.5",
1807            "base_threshold = 0.5\nmax_output_tokens = 256\nescalation = { confirmations = 2 }",
1808        );
1809        runner_from_toml(&escalation)?;
1810        Ok(())
1811    }
1812
1813    #[test]
1814    fn classifier_prompts_are_configurable_in_both_modes() -> RunnerResult<()> {
1815        let capability = VALID_CONFIG.replace(
1816            "base_threshold = 0.5",
1817            "base_threshold = 0.5\nprompt = \"custom capability rubric\"",
1818        );
1819        runner_from_toml(&capability)?;
1820
1821        let escalation = VALID_CONFIG.replace(
1822            "base_threshold = 0.5",
1823            "base_threshold = 0.5\nprompt = \"custom trajectory rubric\"\nescalation = { confirmations = 2 }",
1824        );
1825        runner_from_toml(&escalation)?;
1826
1827        let empty = VALID_CONFIG.replace(
1828            "base_threshold = 0.5",
1829            "base_threshold = 0.5\nprompt = \"   \"",
1830        );
1831        assert!(error_message(&empty).contains("classifier prompt must not be empty"));
1832
1833        let schema_placeholder = VALID_CONFIG.replace(
1834            "base_threshold = 0.5",
1835            "base_threshold = 0.5\nprompt = \"{{RESPONSE_SCHEMA}}\"",
1836        );
1837        assert!(
1838            error_message(&schema_placeholder)
1839                .contains("Switchyard supplies the schema automatically")
1840        );
1841        Ok(())
1842    }
1843
1844    #[test]
1845    fn mode_custom_rejects_capability_fields() {
1846        let mixed = VALID_CONFIG.replace(
1847            "base_threshold = 0.5",
1848            "mode = \"custom\"\nbase_threshold = 0.5",
1849        );
1850
1851        assert!(
1852            error_message(&mixed)
1853                .contains("mode custom cannot use capability or escalation fields")
1854        );
1855    }
1856
1857    #[test]
1858    fn stage_router_rejects_an_unknown_field() {
1859        let config = stage_config().replace(
1860            "picker = \"efficient_first\"",
1861            "picker = \"efficient_first\"\nmagic = true",
1862        );
1863        assert!(error_message(&config).contains("unknown field"));
1864    }
1865
1866    #[test]
1867    fn auto_route_builds_a_stage_router_with_no_extra_fields() -> RunnerResult<()> {
1868        let config = format!(
1869            r#"{VALID_CONFIG}
1870[routes.auto]
1871id = "switchyard/auto"
1872type = "auto"
1873capable_target = "strong"
1874efficient_target = "weak"
1875"#
1876        );
1877        let runner = runner_from_toml(&config)?;
1878        assert!(runner.route("switchyard/auto").is_some());
1879        Ok(())
1880    }
1881
1882    #[test]
1883    fn composite_stage_block_rejects_an_unknown_field() {
1884        let config = composite_config().replace(
1885            "confidence_threshold = 0.5",
1886            "confidence_threshold = 0.5\nmagic = true",
1887        );
1888        assert!(error_message(&config).contains("unknown field"));
1889    }
1890
1891    #[test]
1892    fn rejects_unknown_fields_and_algorithm_types() {
1893        let unknown_field =
1894            VALID_CONFIG.replace("schema_version = 1", "schema_version = 1\nmagic = true");
1895        assert!(error_message(&unknown_field).contains("unknown field"));
1896
1897        let nested_completion_cap = VALID_CONFIG.replace(
1898            "base_threshold = 0.5",
1899            "base_threshold = 0.5\nescalation = { max_output_tokens = 256 }",
1900        );
1901        assert!(error_message(&nested_completion_cap).contains("unknown field"));
1902
1903        let unknown_classifier_field = VALID_CONFIG.replace(
1904            "base_threshold = 0.5",
1905            "base_threshold = 0.5\nclassifier_magic = true",
1906        );
1907        assert!(error_message(&unknown_classifier_field).contains("unknown field"));
1908
1909        let target_capability = VALID_CONFIG.replace(
1910            "llm_client = \"responses\"",
1911            "llm_client = \"responses\"\ncontext_window = 1000000",
1912        );
1913        assert!(error_message(&target_capability).contains("unknown field `context_window`"));
1914
1915        let unknown_algorithm = VALID_CONFIG.replace("type = \"noop\"", "type = \"imaginary\"");
1916        assert!(error_message(&unknown_algorithm).contains("unknown variant"));
1917    }
1918
1919    #[test]
1920    fn rejects_unknown_stage_classifier_fields() {
1921        // Nested classifier typos must fail instead of silently using a default.
1922        let config = format!(
1923            r#"{VALID_CONFIG}
1924
1925[routes.stage]
1926id = "switchyard/stage"
1927type = "stage_router"
1928capable_target = "strong"
1929efficient_target = "weak"
1930picker = "efficient_first"
1931confidence_threshold = 1.0
1932
1933[routes.stage.classifier]
1934target = "classifier"
1935base_threshold = 0.5
1936classifier_magic = true
1937"#
1938        );
1939
1940        let error = error_message(&config);
1941        assert!(
1942            error.contains("unknown field `classifier_magic`"),
1943            "{error}"
1944        );
1945    }
1946
1947    #[test]
1948    fn rejects_invalid_references_and_parameters() {
1949        let cases = [
1950            (
1951                VALID_CONFIG.replace("llm_client = \"primary\"", "llm_client = \"missing\""),
1952                "unknown llm client missing",
1953            ),
1954            (
1955                VALID_CONFIG.replace(
1956                    "targets = [\"strong\", \"weak\"]",
1957                    "targets = [\"strong\", \"missing\"]",
1958                ),
1959                "unknown target missing",
1960            ),
1961            (
1962                VALID_CONFIG.replace(
1963                    "targets = [\"strong\", \"weak\"]",
1964                    "targets = [\"strong\", \"weak\"]\nweights = [0, 0]",
1965                ),
1966                "at least one weight must be positive",
1967            ),
1968            (
1969                VALID_CONFIG.replace(
1970                    "targets = [\"strong\", \"weak\"]",
1971                    "targets = [\"strong\", \"strong\"]",
1972                ),
1973                "targets must be unique, strong is repeated",
1974            ),
1975            (
1976                VALID_CONFIG.replace(
1977                    "targets = [\"strong\", \"weak\"]",
1978                    "targets = [\"strong\", \"weak\"]\nweights = [1]",
1979                ),
1980                "expected 2 weights, got 1",
1981            ),
1982            (
1983                VALID_CONFIG.replace("base_threshold = 0.5", "base_threshold = 1.5"),
1984                "base_threshold must be between 0 and 1",
1985            ),
1986            (
1987                VALID_CONFIG.replace("classifier_target = \"classifier\"\n", ""),
1988                "route references unknown target",
1989            ),
1990            (
1991                VALID_CONFIG.replace(
1992                    "classifier_target = \"classifier\"",
1993                    "classifier_target = \"\"",
1994                ),
1995                "route references unknown target",
1996            ),
1997            (
1998                VALID_CONFIG.replace(
1999                    "classifier_target = \"classifier\"",
2000                    "classifier_target = \"   \"",
2001                ),
2002                "route references unknown target",
2003            ),
2004            (
2005                VALID_CONFIG.replace(
2006                    "base_threshold = 0.5",
2007                    "base_threshold = 0.5\nthreshold_step = -0.1",
2008                ),
2009                "threshold_step must be finite and greater than or equal to 0",
2010            ),
2011            (
2012                VALID_CONFIG.replace(
2013                    "base_threshold = 0.5",
2014                    "base_threshold = 0.8\nthreshold_step = 0.11",
2015                ),
2016                "base_threshold + 2 * threshold_step must be at most 1",
2017            ),
2018            (
2019                VALID_CONFIG.replace(
2020                    "base_threshold = 0.5",
2021                    "base_threshold = 0.5\nmax_output_tokens = 0\nescalation = { confirmations = 2 }",
2022                ),
2023                "max_output_tokens must be at least 1",
2024            ),
2025            (
2026                VALID_CONFIG.replace(
2027                    "base_threshold = 0.5",
2028                    "base_threshold = 0.5\nmessage_hash_fallback = true",
2029                ),
2030                "message_hash_fallback requires classify_trigger = new_session",
2031            ),
2032            (
2033                with_subagent_llm_classifier(
2034                    VALID_CONFIG,
2035                    "passthrough",
2036                    "\nmessage_hash_fallback = true",
2037                ),
2038                "cannot use message_hash_fallback",
2039            ),
2040            (
2041                with_subagent_llm_classifier(VALID_CONFIG, "passthrough", "")
2042                    .replace("mode = \"custom\"", "mode = \"capability\""),
2043                "mode capability cannot use custom classifier fields",
2044            ),
2045            (
2046                VALID_CONFIG.replace(
2047                    "base_threshold = 0.5",
2048                    "escalation = { confirmations = 2 }\nclassify_trigger = \"user_turn\"",
2049                ),
2050                "mode escalation cannot use classify_trigger",
2051            ),
2052            (
2053                VALID_CONFIG.replace("schema_version = 1", "schema_version = 2"),
2054                "unsupported schema_version 2",
2055            ),
2056            (
2057                VALID_CONFIG.replace("[targets.strong]", "[targets.\" strong \"]"),
2058                "target name must be non-empty and have no surrounding whitespace",
2059            ),
2060            (
2061                VALID_CONFIG.replace(
2062                    "targets = [\"strong\", \"weak\"]",
2063                    "targets = [\"strong\", \"weak\"]\ncontext_window = 0",
2064                ),
2065                "route random context_window must be greater than zero",
2066            ),
2067        ];
2068
2069        for (toml, expected) in cases {
2070            let error = error_message(&toml);
2071            assert!(
2072                error.contains(expected),
2073                "expected error containing {expected}, got {error}"
2074            );
2075        }
2076    }
2077
2078    #[test]
2079    fn accepts_duplicate_target_model_ids_on_one_client() -> RunnerResult<()> {
2080        // Two targets share one model id on one client. The client keeps one and drops the
2081        // other, so the build warns and still succeeds, and both routes resolve. Serving one
2082        // model under two route names this way is allowed; pointing both routes at one target
2083        // is the tidier form.
2084        const SAME_MODEL_TWO_ROUTES: &str = r#"
2085schema_version = 1
2086
2087[llm_clients.primary]
2088format = "openai_chat"
2089base_url = "https://example.test/v1"
2090
2091[targets.fast]
2092id = "gpt-4o"
2093llm_client = "primary"
2094
2095[targets.smart]
2096id = "gpt-4o"
2097llm_client = "primary"
2098
2099[routes.fast]
2100id = "switchyard/fast"
2101type = "passthrough"
2102target = "fast"
2103
2104[routes.smart]
2105id = "switchyard/smart"
2106type = "passthrough"
2107target = "smart"
2108"#;
2109        let state = runner_from_toml(SAME_MODEL_TWO_ROUTES)?;
2110        assert_eq!(
2111            state
2112                .models()
2113                .map(|model| model.id.as_str())
2114                .collect::<Vec<_>>(),
2115            ["switchyard/fast", "switchyard/smart"]
2116        );
2117        Ok(())
2118    }
2119
2120    #[test]
2121    fn accepts_same_model_id_on_different_llm_clients() -> RunnerResult<()> {
2122        // Separate routes may serve the same model through different clients.
2123        const CROSS_PROVIDER: &str = r#"
2124schema_version = 1
2125
2126[llm_clients.openai]
2127format = "openai_chat"
2128base_url = "https://example.test/v1"
2129
2130[llm_clients.azure]
2131format = "openai_chat"
2132base_url = "https://azure.test/v1"
2133
2134[targets.openai]
2135id = "gpt-4o"
2136llm_client = "openai"
2137
2138[targets.azure]
2139id = "gpt-4o"
2140llm_client = "azure"
2141
2142[routes.openai]
2143id = "switchyard/openai-gpt4o"
2144type = "passthrough"
2145target = "openai"
2146
2147[routes.azure]
2148id = "switchyard/azure-gpt4o"
2149type = "passthrough"
2150target = "azure"
2151"#;
2152        runner_from_toml(CROSS_PROVIDER)?;
2153        let shared_route = format!(
2154            r#"{CROSS_PROVIDER}
2155
2156[routes.shared]
2157id = "switchyard/shared"
2158type = "stage_router"
2159capable_target = "openai"
2160efficient_target = "azure"
2161picker = "efficient_first"
2162confidence_threshold = 0.5
2163"#
2164        );
2165        let message = error_message(&shared_route);
2166        assert!(
2167            message.contains("route shared")
2168                && message.contains("openai")
2169                && message.contains("azure")
2170                && message.contains("gpt-4o")
2171                && message.contains("different llm clients"),
2172            "{message}"
2173        );
2174        Ok(())
2175    }
2176
2177    #[test]
2178    fn accepts_relative_weights_and_seed() -> RunnerResult<()> {
2179        let weighted = VALID_CONFIG.replace(
2180            "targets = [\"strong\", \"weak\"]",
2181            "targets = [\"strong\", \"weak\"]\nweights = [1, 3]\nseed = 42",
2182        );
2183        runner_from_toml(&weighted)?;
2184        Ok(())
2185    }
2186
2187    #[test]
2188    fn accepts_new_session_trigger_with_message_hash_fallback() -> RunnerResult<()> {
2189        let configured = VALID_CONFIG.replace(
2190            "base_threshold = 0.5",
2191            "base_threshold = 0.25\nthreshold_step = 0.1\nclassify_trigger = \"new_session\"\nmessage_hash_fallback = true",
2192        );
2193        runner_from_toml(&configured)?;
2194        Ok(())
2195    }
2196
2197    #[test]
2198    fn target_extra_body_is_parsed_and_applied_to_its_backend() -> RunnerResult<()> {
2199        let configured = VALID_CONFIG.replacen(
2200            "llm_client = \"primary\"",
2201            "llm_client = \"primary\"\n\
2202             extra_body = { service_tier = \"priority\", \
2203             chat_template_kwargs = { enable_thinking = false } }",
2204            1,
2205        );
2206        let config: DeploymentConfig = toml::from_str(&configured).map_err(|error| {
2207            RunnerError::configuration(format!("failed to parse config: {error}"))
2208        })?;
2209        let Some(target) = config.targets.get("classifier") else {
2210            return Err(RunnerError::configuration("classifier target is missing"));
2211        };
2212        let Some(client) = config.llm_clients.get("primary") else {
2213            return Err(RunnerError::configuration("primary llm client is missing"));
2214        };
2215        let backend = build_backend(
2216            "primary",
2217            client,
2218            &target.extra_body,
2219            &target.omit_body_fields,
2220            None,
2221        )?;
2222
2223        assert_eq!(
2224            backend.extra_body().get("service_tier"),
2225            Some(&json!("priority"))
2226        );
2227        assert_eq!(
2228            backend
2229                .extra_body()
2230                .get("chat_template_kwargs")
2231                .and_then(|value| value.get("enable_thinking")),
2232            Some(&json!(false))
2233        );
2234        Ok(())
2235    }
2236
2237    #[test]
2238    fn target_omit_body_fields_is_parsed_and_applied_to_its_backend() -> RunnerResult<()> {
2239        let configured = VALID_CONFIG.replacen(
2240            "llm_client = \"primary\"",
2241            "llm_client = \"primary\"\n\
2242             omit_body_fields = [\"max_output_tokens\"]",
2243            1,
2244        );
2245        let config: DeploymentConfig = toml::from_str(&configured).map_err(|error| {
2246            RunnerError::configuration(format!("failed to parse config: {error}"))
2247        })?;
2248        let Some(target) = config.targets.get("classifier") else {
2249            return Err(RunnerError::configuration("classifier target is missing"));
2250        };
2251        let Some(client) = config.llm_clients.get("primary") else {
2252            return Err(RunnerError::configuration("primary llm client is missing"));
2253        };
2254        let backend = build_backend(
2255            "primary",
2256            client,
2257            &target.extra_body,
2258            &target.omit_body_fields,
2259            None,
2260        )?;
2261
2262        assert!(backend.omit_body_fields().contains("max_output_tokens"));
2263        Ok(())
2264    }
2265
2266    #[test]
2267    fn client_deadline_defaults_and_rejects_zero() -> RunnerResult<()> {
2268        for (setting, expected) in [
2269            ("", None),
2270            ("timeout_ms = 1500", Some(1500)),
2271            ("timeout_ms = 0", Some(0)),
2272        ] {
2273            let source = format!(
2274                "format = \"openai_chat\"\nbase_url = \"https://example.test/v1\"\n{setting}"
2275            );
2276            let config: LlmClientConfig = toml::from_str(&source).expect("valid deadline config");
2277            let backend = build_backend("test", &config, &BTreeMap::new(), &BTreeSet::new(), None);
2278            if expected == Some(0) {
2279                assert!(backend.is_err());
2280            } else {
2281                assert_eq!(backend?.timeout(), expected.map(Duration::from_millis));
2282            }
2283        }
2284        Ok(())
2285    }
2286
2287    #[test]
2288    fn retry_budget_defaults_and_accepts_an_override() -> RunnerResult<()> {
2289        let default: DeploymentConfig = toml::from_str(VALID_CONFIG).map_err(|error| {
2290            RunnerError::configuration(format!("failed to parse default config: {error}"))
2291        })?;
2292        let Some(primary) = default.llm_clients.get("primary") else {
2293            return Err(RunnerError::configuration("primary llm client is missing"));
2294        };
2295        assert_eq!(primary.max_retries, DEFAULT_MAX_RETRIES);
2296
2297        let explicit = VALID_CONFIG.replacen(
2298            "base_url = \"https://example.test/v1\"",
2299            "base_url = \"https://example.test/v1\"\nmax_retries = 0",
2300            1,
2301        );
2302        let config: DeploymentConfig = toml::from_str(&explicit).map_err(|error| {
2303            RunnerError::configuration(format!("failed to parse explicit retry config: {error}"))
2304        })?;
2305        let Some(primary) = config.llm_clients.get("primary") else {
2306            return Err(RunnerError::configuration("primary llm client is missing"));
2307        };
2308        assert_eq!(primary.max_retries, 0);
2309
2310        let maximum = VALID_CONFIG.replacen(
2311            "base_url = \"https://example.test/v1\"",
2312            &format!(
2313                "base_url = \"https://example.test/v1\"\nmax_retries = {MAX_CONFIGURED_RETRIES}"
2314            ),
2315            1,
2316        );
2317        let config: DeploymentConfig = toml::from_str(&maximum).map_err(|error| {
2318            RunnerError::configuration(format!("failed to parse maximum retry config: {error}"))
2319        })?;
2320        let Some(primary) = config.llm_clients.get("primary") else {
2321            return Err(RunnerError::configuration("primary llm client is missing"));
2322        };
2323        assert_eq!(primary.max_retries, MAX_CONFIGURED_RETRIES);
2324        Ok(())
2325    }
2326
2327    #[test]
2328    fn responses_reasoning_defaults_and_accepts_drop() -> RunnerResult<()> {
2329        let default: DeploymentConfig = toml::from_str(VALID_CONFIG).map_err(|error| {
2330            RunnerError::configuration(format!("failed to parse config: {error}"))
2331        })?;
2332        let responses = default
2333            .llm_clients
2334            .get("responses")
2335            .ok_or_else(|| RunnerError::configuration("responses llm client is missing"))?;
2336        assert_eq!(responses.responses_reasoning, None);
2337
2338        let configured = VALID_CONFIG.replacen(
2339            "[llm_clients.responses]\nformat = \"openai_responses\"\nbase_url = \"https://example.test/v1\"",
2340            "[llm_clients.responses]\nformat = \"openai_responses\"\nbase_url = \"https://example.test/v1\"\nresponses_reasoning = \"drop\"",
2341            1,
2342        );
2343        let config: DeploymentConfig = toml::from_str(&configured).map_err(|error| {
2344            RunnerError::configuration(format!("failed to parse config: {error}"))
2345        })?;
2346        let responses = config
2347            .llm_clients
2348            .get("responses")
2349            .ok_or_else(|| RunnerError::configuration("responses llm client is missing"))?;
2350        assert_eq!(
2351            responses.responses_reasoning,
2352            Some(ResponsesReasoningPolicy::Drop)
2353        );
2354        runner_from_toml(&configured)?;
2355        Ok(())
2356    }
2357
2358    #[test]
2359    fn responses_reasoning_rejects_other_formats() {
2360        let invalid = VALID_CONFIG.replacen(
2361            "format = \"openai_chat\"",
2362            "format = \"openai_chat\"\nresponses_reasoning = \"drop\"",
2363            1,
2364        );
2365        assert!(
2366            error_message(&invalid)
2367                .contains("responses_reasoning is only valid for openai_responses")
2368        );
2369    }
2370
2371    #[test]
2372    fn responses_reasoning_rejects_unreferenced_other_format() {
2373        let invalid = format!(
2374            "{VALID_CONFIG}\n[llm_clients.unused]\nformat = \"openai_chat\"\nbase_url = \"https://example.test/v1\"\nresponses_reasoning = \"drop\"\n"
2375        );
2376        assert!(
2377            error_message(&invalid)
2378                .contains("responses_reasoning is only valid for openai_responses")
2379        );
2380    }
2381
2382    #[test]
2383    fn rejects_headers_that_switchyard_sets() {
2384        let cases = [
2385            (
2386                "base_url = \"https://example.test/v1\"",
2387                "base_url = \"https://example.test/v1\"\n\
2388                 extra_headers = { AUTHORIZATION = \"Bearer custom-key\" }",
2389                "AUTHORIZATION",
2390            ),
2391            (
2392                "base_url = \"https://example.test\"",
2393                "base_url = \"https://example.test\"\n\
2394                 extra_headers = { \"X-Api-Key\" = \"custom-key\" }",
2395                "X-Api-Key",
2396            ),
2397            (
2398                "base_url = \"https://example.test\"",
2399                "base_url = \"https://example.test\"\n\
2400                 extra_headers = { \"ANTHROPIC-VERSION\" = \"custom-version\" }",
2401                "ANTHROPIC-VERSION",
2402            ),
2403        ];
2404
2405        for (original, replacement, header) in cases {
2406            let configured = VALID_CONFIG.replacen(original, replacement, 1);
2407            let error = error_message(&configured);
2408            assert!(
2409                error.contains(&format!("extra_headers cannot set {header:?}")),
2410                "expected {header} to be rejected, got: {error}"
2411            );
2412        }
2413    }
2414
2415    #[test]
2416    fn accepts_additional_headers() -> RunnerResult<()> {
2417        let configured = VALID_CONFIG.replacen(
2418            "base_url = \"https://example.test/v1\"",
2419            "base_url = \"https://example.test/v1\"\n\
2420             extra_headers = { X-Inference-Priority = \"batch\" }",
2421            1,
2422        );
2423
2424        runner_from_toml(&configured)?;
2425        Ok(())
2426    }
2427
2428    #[test]
2429    fn retry_budget_rejects_negative_values() {
2430        let invalid = VALID_CONFIG.replacen(
2431            "base_url = \"https://example.test/v1\"",
2432            "base_url = \"https://example.test/v1\"\nmax_retries = -1",
2433            1,
2434        );
2435        assert!(error_message(&invalid).contains("max_retries"));
2436    }
2437
2438    #[test]
2439    fn retry_budget_rejects_excessive_values() {
2440        let invalid = VALID_CONFIG.replacen(
2441            "base_url = \"https://example.test/v1\"",
2442            "base_url = \"https://example.test/v1\"\nmax_retries = 11",
2443            1,
2444        );
2445        assert!(
2446            error_message(&invalid).contains("llm client primary max_retries must be at most 10")
2447        );
2448    }
2449
2450    #[test]
2451    fn api_key_environment_reference_is_validated() {
2452        let missing = VALID_CONFIG.replacen(
2453            "base_url = \"https://example.test/v1\"",
2454            "base_url = \"https://example.test/v1\"\napi_key_env = \"SWITCHYARD_CONFIG_TEST_KEY_THAT_IS_NOT_SET\"",
2455            1,
2456        );
2457        assert!(error_message(&missing).contains("SWITCHYARD_CONFIG_TEST_KEY_THAT_IS_NOT_SET"));
2458
2459        const EMPTY_KEY_ENV: &str = "SWITCHYARD_CONFIG_TEST_EMPTY_KEY";
2460        unsafe {
2461            // "unsafe" is for concurrent reads and writes, very rare
2462            std::env::set_var(EMPTY_KEY_ENV, "");
2463        }
2464        let empty = VALID_CONFIG.replacen(
2465            "base_url = \"https://example.test/v1\"",
2466            &format!("base_url = \"https://example.test/v1\"\napi_key_env = \"{EMPTY_KEY_ENV}\""),
2467            1,
2468        );
2469        let message = error_message(&empty);
2470        unsafe {
2471            std::env::remove_var(EMPTY_KEY_ENV);
2472        }
2473        assert!(message.contains("is empty"));
2474
2475        // macOS reads `FOO` for the name `FOO=x`, so the config would use another variable. A
2476        // name with `=` is often a pasted `NAME=key` line, so the error leaves it out.
2477        let with_equals = VALID_CONFIG.replacen(
2478            "base_url = \"https://example.test/v1\"",
2479            "base_url = \"https://example.test/v1\"\napi_key_env = \"NVIDIA_API_KEY=DUMMY_SECRET_DO_NOT_LOG\"",
2480            1,
2481        );
2482        let message = error_message(&with_equals);
2483        assert!(message.contains("llm client primary api_key_env must not contain '=' or NUL"));
2484        assert!(!message.contains("DUMMY_SECRET_DO_NOT_LOG"), "{message}");
2485    }
2486
2487    // `VarError::NotUnicode` holds the variable's value, so printing that error prints the key.
2488    #[cfg(any(unix, windows))]
2489    #[test]
2490    fn invalid_unicode_api_key_is_not_printed() {
2491        const RAW_KEY_ENV: &str = "SWITCHYARD_CONFIG_TEST_RAW_KEY";
2492        #[cfg(unix)]
2493        let raw = {
2494            use std::os::unix::ffi::OsStringExt;
2495            let mut bytes = b"DUMMY_SECRET_DO_NOT_LOG".to_vec();
2496            bytes.push(0xff);
2497            std::ffi::OsString::from_vec(bytes)
2498        };
2499        #[cfg(windows)]
2500        let raw = {
2501            use std::os::windows::ffi::OsStringExt;
2502            let mut words: Vec<_> = "DUMMY_SECRET_DO_NOT_LOG".encode_utf16().collect();
2503            words.push(0xd800);
2504            std::ffi::OsString::from_wide(&words)
2505        };
2506        unsafe {
2507            // "unsafe" is for concurrent reads and writes, very rare
2508            std::env::set_var(RAW_KEY_ENV, raw);
2509        }
2510        let result = read_api_key("llm client fixture", RAW_KEY_ENV);
2511        unsafe {
2512            std::env::remove_var(RAW_KEY_ENV);
2513        }
2514        let error = result.expect_err("invalid Unicode credential").to_string();
2515        assert!(error.contains("not valid Unicode"), "{error}");
2516        assert!(!error.contains("DUMMY_SECRET_DO_NOT_LOG"), "{error}");
2517    }
2518
2519    #[test]
2520    fn forward_auth_rejects_conflicting_credentials() {
2521        let competing_auth = VALID_CONFIG.replacen(
2522            "base_url = \"https://example.test/v1\"",
2523            "base_url = \"https://example.test/v1\"\n\
2524                 forward_auth = true\n\
2525                 api_key_env = \"UNUSED_TEST_KEY\"",
2526            1,
2527        );
2528        assert!(
2529            error_message(&competing_auth).contains("cannot set both forward_auth and api_key_env")
2530        );
2531
2532        let static_auth = VALID_CONFIG.replacen(
2533            "base_url = \"https://example.test\"",
2534            "base_url = \"https://example.test\"\n\
2535                 forward_auth = true\n\
2536                 extra_headers = { Authorization = \"static-value\" }",
2537            1,
2538        );
2539        assert!(error_message(&static_auth).contains("extra_headers cannot set \"Authorization\""));
2540
2541        let static_beta = static_auth.replace("Authorization", "anthropic-beta");
2542        assert!(
2543            error_message(&static_beta).contains("extra_headers cannot set \"anthropic-beta\"")
2544        );
2545
2546        for header in ["chatgpt-account-id", "x-openai-fedramp"] {
2547            let static_context = VALID_CONFIG.replacen(
2548                "base_url = \"https://example.test/v1\"",
2549                &format!(
2550                    "base_url = \"https://example.test/v1\"\n\
2551                     forward_auth = true\n\
2552                     extra_headers = {{ \"{header}\" = \"static-value\" }}"
2553                ),
2554                1,
2555            );
2556            assert!(
2557                error_message(&static_context)
2558                    .contains(&format!("extra_headers cannot set \"{header}\""))
2559            );
2560        }
2561    }
2562
2563    /// Returns a config whose `mixed` route has a GPT target on a Responses client and a Claude
2564    /// target on a Messages client at `messages_url`. Its `claude` route uses Claude only.
2565    fn mixed_forwarding_config(messages_url: &str) -> String {
2566        format!(
2567            r#"
2568schema_version = 1
2569
2570[llm_clients.responses]
2571format = "openai_responses"
2572base_url = "https://gateway.example.test/v1"
2573forward_auth = true
2574
2575[llm_clients.messages]
2576format = "anthropic_messages"
2577base_url = "{messages_url}"
2578forward_auth = true
2579
2580[targets]
2581gpt = {{ id = "gpt/model", llm_client = "responses" }}
2582claude = {{ id = "claude/model", llm_client = "messages" }}
2583
2584[routes.mixed]
2585id = "switchyard/mixed"
2586type = "random"
2587targets = ["gpt", "claude"]
2588
2589[routes.claude]
2590id = "switchyard/claude"
2591type = "passthrough"
2592target = "claude"
2593"#
2594        )
2595    }
2596
2597    /// A forwarding route can mix OpenAI and Anthropic clients only when they all use the same
2598    /// scheme, host, and port.
2599    #[test]
2600    fn forwarding_route_mixes_formats_only_on_one_host() -> RunnerResult<()> {
2601        // Same host, different paths: the mixed route serves OpenAI callers, and the route that
2602        // uses only the Messages client keeps serving Messages callers.
2603        let runner = runner_from_toml(&mixed_forwarding_config("https://gateway.example.test"))?;
2604        let caller_auth = |id| runner.route(id).and_then(Route::caller_auth);
2605        assert_eq!(
2606            caller_auth("switchyard/mixed"),
2607            Some(CallerAuthKind::OpenAi)
2608        );
2609        assert_eq!(
2610            caller_auth("switchyard/claude"),
2611            Some(CallerAuthKind::Anthropic)
2612        );
2613
2614        // A different host, port, or scheme fails, and the error names it.
2615        for other in [
2616            "https://api.anthropic.test",
2617            "https://gateway.example.test:8443",
2618            "http://gateway.example.test",
2619        ] {
2620            let error = error_message(&mixed_forwarding_config(other));
2621            assert!(
2622                error.contains("different origins") && error.contains(other),
2623                "{error}"
2624            );
2625        }
2626        Ok(())
2627    }
2628
2629    const ADVISOR_CONFIG: &str = r#"
2630schema_version = 1
2631
2632[llm_clients.anthropic]
2633format = "anthropic_messages"
2634base_url = "https://example.test"
2635
2636[targets.executor]
2637id = "executor/model"
2638llm_client = "anthropic"
2639
2640[targets.advisor]
2641id = "advisor/model"
2642llm_client = "anthropic"
2643
2644[routes.gated]
2645id = "switchyard/advisor"
2646type = "advisor"
2647executor_target = "executor"
2648advisor_target = "advisor"
2649"#;
2650
2651    #[test]
2652    fn advisor_route_parses_with_defaults_and_builds() -> RunnerResult<()> {
2653        let state = runner_from_toml(ADVISOR_CONFIG)?;
2654        let route = state
2655            .route("switchyard/advisor")
2656            .expect("advisor route should exist");
2657        let models = route.models();
2658        // The gate calls the executor through `efficient`; `any` keeps it in the
2659        // route's last-resort pool.
2660        assert_eq!(
2661            models.models_for(&Category::Efficient),
2662            [ModelId::from("executor/model")]
2663        );
2664        assert_eq!(
2665            models.models_for(&Category::Any),
2666            [ModelId::from("executor/model")]
2667        );
2668        assert_eq!(
2669            models.models_for(&Category::Judge),
2670            [ModelId::from("advisor/model")]
2671        );
2672        assert_eq!(
2673            state
2674                .models()
2675                .map(|model| model.id.as_str())
2676                .collect::<Vec<_>>(),
2677            ["switchyard/advisor"]
2678        );
2679        Ok(())
2680    }
2681
2682    #[test]
2683    fn advisor_route_accepts_every_gate_knob() -> RunnerResult<()> {
2684        let tuned = ADVISOR_CONFIG.replace(
2685            "advisor_target = \"advisor\"",
2686            concat!(
2687                "advisor_target = \"advisor\"\n",
2688                "reviewer_system_prompt = \"review it\"\n",
2689                "redo_feedback_prefix = \"REVIEWER SAYS: \"\n",
2690                "gate_trigger = \"pattern\"\n",
2691                "gate_trigger_pattern = 'task_complete[\"\\s>:]*true'\n",
2692                "max_reviews = 2\n",
2693                "gate_stall_turns = 40\n",
2694                "gate_min_tool_results = 1\n",
2695                "advisor_max_tokens = 1024\n",
2696                "advisor_temperature = 0.0\n",
2697                "transcript_max_chars = 100000\n",
2698                "fail_open = false\n",
2699                "context_window = 200000\n",
2700                "tool_calling = true\n",
2701                "reasoning = true",
2702            ),
2703        );
2704        runner_from_toml(&tuned)?;
2705        Ok(())
2706    }
2707
2708    #[test]
2709    fn advisor_route_rejects_unknown_keys() {
2710        let invalid = ADVISOR_CONFIG.replace(
2711            "advisor_target = \"advisor\"",
2712            "advisor_target = \"advisor\"\nbogus_field = 1",
2713        );
2714        assert!(error_message(&invalid).contains("bogus_field"));
2715    }
2716
2717    #[test]
2718    fn advisor_route_requires_both_targets() {
2719        let missing = ADVISOR_CONFIG.replace("advisor_target = \"advisor\"\n", "");
2720        assert!(error_message(&missing).contains("advisor_target"));
2721    }
2722
2723    #[test]
2724    fn advisor_route_rejects_unknown_target() {
2725        let invalid = ADVISOR_CONFIG.replace(
2726            "advisor_target = \"advisor\"",
2727            "advisor_target = \"missing\"",
2728        );
2729        assert!(error_message(&invalid).contains("missing"));
2730    }
2731
2732    #[test]
2733    fn advisor_route_rejects_invalid_pattern() {
2734        let invalid = ADVISOR_CONFIG.replace(
2735            "advisor_target = \"advisor\"",
2736            "advisor_target = \"advisor\"\ngate_trigger = \"pattern\"\ngate_trigger_pattern = \"(unclosed\"",
2737        );
2738        assert!(error_message(&invalid).contains("not a valid regex"));
2739    }
2740
2741    #[test]
2742    fn advisor_route_rejects_pattern_without_pattern_trigger() {
2743        let invalid = ADVISOR_CONFIG.replace(
2744            "advisor_target = \"advisor\"",
2745            "advisor_target = \"advisor\"\ngate_trigger_pattern = \"done\"",
2746        );
2747        assert!(
2748            error_message(&invalid)
2749                .contains("gate_trigger_pattern requires gate_trigger = \"pattern\"")
2750        );
2751    }
2752
2753    #[test]
2754    fn advisor_route_pattern_trigger_requires_pattern() {
2755        let invalid = ADVISOR_CONFIG.replace(
2756            "advisor_target = \"advisor\"",
2757            "advisor_target = \"advisor\"\ngate_trigger = \"pattern\"",
2758        );
2759        assert!(error_message(&invalid).contains("non-empty gate_trigger_pattern"));
2760    }
2761
2762    #[test]
2763    fn advisor_route_rejects_zero_max_reviews() {
2764        let invalid = ADVISOR_CONFIG.replace(
2765            "advisor_target = \"advisor\"",
2766            "advisor_target = \"advisor\"\nmax_reviews = 0",
2767        );
2768        assert!(error_message(&invalid).contains("max_reviews must be at least 1"));
2769    }
2770}