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