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