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