Skip to main content

switchyard_server/
config.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Typed TOML configuration and explicit construction for the Rust server.
5
6use std::collections::{BTreeMap, HashSet};
7use std::fs;
8use std::path::Path;
9use std::sync::Arc;
10
11use libsy::{
12    Algorithm, ClassifierContractConfig, CustomClassifierConfig, CustomClassifierPolicy,
13    EscalationJudgeConfig, HandoffNoteConfig, LlmClassifierConfig, LlmFallback, LlmTarget,
14    LlmTargetSet, LlmTaskClassifier, Noop, Passthrough, PickerMode, Random, StageRouter,
15    StageRouterConfig, TargetPrompts, TaskClassifierConfig,
16};
17use serde::Deserialize;
18use serde_json::Value;
19use switchyard_llm_client::{
20    Backend, DEFAULT_MAX_RETRIES, HttpBackendConfig, ModelConfig, TranslatingLlmClient,
21};
22use switchyard_protocol::RoutedLlmClient;
23
24use crate::{CountTokensTarget, ModelCapabilities, ServerError, ServerResult, ServerState};
25
26const SUPPORTED_SCHEMA_VERSION: u32 = 1;
27const MAX_CONFIGURED_RETRIES: u32 = 10;
28
29/// Loads a TOML deployment file and constructs the complete server state.
30pub fn load_server_state(path: impl AsRef<Path>) -> ServerResult<ServerState> {
31    let path = path.as_ref();
32    let toml = fs::read_to_string(path).map_err(|error| {
33        ServerError::new(format!(
34            "failed to read server config {}: {error}",
35            path.display()
36        ))
37    })?;
38    server_state_from_toml(&toml).map_err(|error| {
39        ServerError::new(format!("invalid server config {}: {error}", path.display()))
40    })
41}
42
43fn server_state_from_toml(toml: &str) -> ServerResult<ServerState> {
44    let config: ServerConfig = toml::from_str(toml)
45        .map_err(|error| ServerError::new(format!("failed to parse TOML: {error}")))?;
46    config.build()
47}
48
49#[derive(Debug, Deserialize)]
50#[serde(deny_unknown_fields)]
51struct ServerConfig {
52    schema_version: u32,
53    #[serde(default)]
54    llm_clients: BTreeMap<String, LlmClientConfig>,
55    targets: BTreeMap<String, TargetConfig>,
56    routes: BTreeMap<String, RouteConfig>,
57}
58
59impl ServerConfig {
60    fn build(&self) -> ServerResult<ServerState> {
61        if self.schema_version != SUPPORTED_SCHEMA_VERSION {
62            return Err(ServerError::new(format!(
63                "unsupported schema_version {}; expected {SUPPORTED_SCHEMA_VERSION}",
64                self.schema_version
65            )));
66        }
67
68        // An llm client keys its models by id, so two targets that share an id on one
69        // client end up as one entry: the client keeps one target and drops the other. Warn
70        // so the drop is visible at startup instead of silent, and still build both routes.
71        // The set only tells us the pair was already seen, not whether the two targets
72        // differ, so it warns for a harmless duplicate and for one whose extra_body would be
73        // dropped alike. The same id on two different clients never collides: each client
74        // keeps its own model.
75        let mut seen_client_model_ids = HashSet::new();
76        for (target_name, target) in &self.targets {
77            validate_value("target name", target_name)?;
78            validate_value(&format!("target {target_name} id"), &target.id)?;
79            if !seen_client_model_ids.insert((target.llm_client.as_str(), target.id.as_str())) {
80                tracing::warn!(
81                    "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.",
82                    target.id,
83                    target.llm_client
84                );
85            }
86        }
87
88        let clients = self.build_clients()?;
89        let targets = self.build_targets(&clients)?;
90        let mut routes = Vec::with_capacity(self.routes.len());
91        for (route_name, config) in &self.routes {
92            validate_value("route name", route_name)?;
93            validate_value(&format!("route {route_name} id"), config.id())?;
94            let capabilities = config.capabilities();
95            if capabilities.context_window == Some(0) {
96                return Err(ServerError::new(format!(
97                    "route {route_name} context_window must be greater than zero"
98                )));
99            }
100            let algorithm = build_algorithm(route_name, config, &targets)?;
101            let count_tokens_target = self.build_count_tokens_target(config, &clients);
102            routes.push((
103                config.id().to_string(),
104                algorithm,
105                capabilities,
106                count_tokens_target,
107            ));
108        }
109        ServerState::new_with_capabilities(routes)
110    }
111
112    fn build_clients(&self) -> ServerResult<BTreeMap<String, Arc<TranslatingLlmClient>>> {
113        let mut models_by_client = self
114            .llm_clients
115            .keys()
116            .map(|name| (name.clone(), Vec::new()))
117            .collect::<BTreeMap<String, Vec<ModelConfig>>>();
118
119        for name in self.llm_clients.keys() {
120            validate_value("llm client name", name)?;
121        }
122        for (target_name, target) in &self.targets {
123            let client_config = self.llm_clients.get(&target.llm_client).ok_or_else(|| {
124                ServerError::new(format!(
125                    "target {target_name} references unknown llm client {}",
126                    target.llm_client
127                ))
128            })?;
129            let model_configs = models_by_client
130                .get_mut(&target.llm_client)
131                .ok_or_else(|| ServerError::new("validated llm client was not initialized"))?;
132            model_configs.push(ModelConfig::new(
133                &target.id,
134                build_backend(&target.llm_client, client_config, &target.extra_body)?,
135                None,
136            ));
137        }
138
139        let mut clients = BTreeMap::new();
140        for (name, model_configs) in models_by_client {
141            let client = Arc::new(
142                TranslatingLlmClient::new(&model_configs)
143                    .map_err(|error| ServerError::new(error.to_string()))?,
144            );
145            clients.insert(name, client);
146        }
147        Ok(clients)
148    }
149
150    fn build_targets(
151        &self,
152        clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
153    ) -> ServerResult<BTreeMap<String, LlmTarget>> {
154        self.targets
155            .iter()
156            .map(|(name, config)| {
157                let client = clients.get(&config.llm_client).ok_or_else(|| {
158                    ServerError::new(format!("target {name} has no constructed llm client"))
159                })?;
160                let client: Arc<dyn RoutedLlmClient> = client.clone();
161                Ok((
162                    name.clone(),
163                    LlmTarget {
164                        semantic_name: config.id.clone(),
165                        llm_client: Some(client),
166                    },
167                ))
168            })
169            .collect()
170    }
171
172    fn build_count_tokens_target(
173        &self,
174        route_config: &RouteConfig,
175        clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
176    ) -> Option<CountTokensTarget> {
177        route_config
178            .routing_target_names()
179            .into_iter()
180            .enumerate()
181            .filter_map(|(index, name)| {
182                let target = self.targets.get(name)?;
183                let client = clients.get(&target.llm_client)?;
184                client.supports_count_tokens(&target.id).then_some((
185                    count_tokens_priority(name, &target.id),
186                    index,
187                    target,
188                    client,
189                ))
190            })
191            .min_by_key(|(priority, index, _, _)| (*priority, *index))
192            .map(|(_, _, target, client)| CountTokensTarget {
193                model: target.id.clone(),
194                client: client.clone(),
195            })
196    }
197}
198
199// Prefer known Claude families, then preserve the route's target order.
200fn count_tokens_priority(target_name: &str, model_id: &str) -> usize {
201    let target_name = target_name.to_ascii_lowercase();
202    let model_id = model_id.to_ascii_lowercase();
203    ["opus", "sonnet", "haiku"]
204        .iter()
205        .position(|hint| target_name.contains(hint) || model_id.contains(hint))
206        .unwrap_or(3)
207}
208
209#[derive(Debug, Deserialize)]
210#[serde(deny_unknown_fields)]
211struct LlmClientConfig {
212    format: ClientFormat,
213    base_url: String,
214    api_key_env: Option<String>,
215    #[serde(default)]
216    extra_headers: BTreeMap<String, String>,
217    #[serde(default = "default_max_retries")]
218    max_retries: u32,
219}
220
221#[derive(Debug, Deserialize)]
222#[serde(deny_unknown_fields)]
223struct TargetConfig {
224    id: String,
225    llm_client: String,
226    #[serde(default)]
227    extra_body: BTreeMap<String, Value>,
228}
229
230#[derive(Clone, Copy, Debug, Deserialize)]
231enum ClientFormat {
232    #[serde(rename = "openai_chat")]
233    OpenAiChat,
234    #[serde(rename = "openai_responses")]
235    OpenAiResponses,
236    #[serde(rename = "anthropic_messages")]
237    AnthropicMessages,
238}
239
240#[derive(Clone, Debug, Deserialize)]
241#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
242enum ClassifierPolicyConfig {
243    TargetSelector { selector: String },
244}
245
246#[derive(Clone, Copy, Debug, Deserialize)]
247#[serde(rename_all = "snake_case")]
248enum ClassifierMode {
249    Capability,
250    Escalation,
251    Custom,
252}
253
254impl ClassifierPolicyConfig {
255    fn into_libsy(self) -> CustomClassifierPolicy {
256        match self {
257            Self::TargetSelector { selector } => CustomClassifierPolicy::target_selector(selector),
258        }
259    }
260}
261
262#[derive(Debug)]
263enum LlmClassifierModeConfig {
264    Capability(CapabilityClassifierRouteConfig),
265    Escalation(EscalationClassifierRouteConfig),
266    Custom(CustomClassifierRouteConfig),
267}
268
269#[derive(Debug)]
270struct CapabilityClassifierRouteConfig {
271    strong_target: String,
272    weak_target: String,
273    base_threshold: f64,
274    threshold_step: f64,
275    session_affinity: bool,
276    message_hash_fallback: bool,
277    recent_turn_window: Option<usize>,
278    prompt: Option<String>,
279    max_output_tokens: u64,
280}
281
282#[derive(Debug)]
283struct EscalationClassifierRouteConfig {
284    strong_target: String,
285    weak_target: String,
286    prompt: Option<String>,
287    max_output_tokens: u64,
288    judge: EscalationJudgeConfig,
289}
290
291#[derive(Debug)]
292struct CustomClassifierRouteConfig {
293    targets: Vec<String>,
294    default_target: String,
295    prompt: String,
296    response_schema: String,
297    policy: ClassifierPolicyConfig,
298    session_affinity: bool,
299    message_hash_fallback: bool,
300    recent_turn_window: Option<usize>,
301    max_output_tokens: u64,
302}
303
304#[derive(Debug, Deserialize)]
305#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
306enum RouteConfig {
307    Noop {
308        id: String,
309        #[serde(default)]
310        context_window: Option<u32>,
311        #[serde(default)]
312        tool_calling: Option<bool>,
313        #[serde(default)]
314        reasoning: Option<bool>,
315    },
316    Random {
317        id: String,
318        #[serde(default)]
319        context_window: Option<u32>,
320        #[serde(default)]
321        tool_calling: Option<bool>,
322        #[serde(default)]
323        reasoning: Option<bool>,
324        targets: Vec<String>,
325        weights: Option<Vec<f64>>,
326        seed: Option<u64>,
327    },
328    Passthrough {
329        id: String,
330        #[serde(default)]
331        context_window: Option<u32>,
332        #[serde(default)]
333        tool_calling: Option<bool>,
334        #[serde(default)]
335        reasoning: Option<bool>,
336        target: String,
337    },
338    LlmClassifier {
339        id: String,
340        #[serde(default)]
341        context_window: Option<u32>,
342        #[serde(default)]
343        tool_calling: Option<bool>,
344        #[serde(default)]
345        reasoning: Option<bool>,
346        classifier_target: String,
347        #[serde(default)]
348        mode: Option<ClassifierMode>,
349        #[serde(default)]
350        strong_target: Option<String>,
351        #[serde(default)]
352        weak_target: Option<String>,
353        #[serde(default)]
354        base_threshold: Option<f64>,
355        #[serde(default)]
356        threshold_step: Option<f64>,
357        #[serde(default)]
358        session_affinity: bool,
359        #[serde(default)]
360        message_hash_fallback: bool,
361        #[serde(default)]
362        recent_turn_window: Option<usize>,
363        #[serde(default)]
364        prompt: Option<String>,
365        #[serde(default = "default_classifier_max_output_tokens")]
366        max_output_tokens: u64,
367        #[serde(default)]
368        escalation: Option<EscalationJudgeConfig>,
369        #[serde(default)]
370        targets: Option<Vec<String>>,
371        #[serde(default)]
372        default_target: Option<String>,
373        #[serde(default)]
374        response_schema: Option<String>,
375        #[serde(default)]
376        policy: Option<ClassifierPolicyConfig>,
377    },
378    StageRouter {
379        id: String,
380        #[serde(default)]
381        context_window: Option<u32>,
382        #[serde(default)]
383        tool_calling: Option<bool>,
384        #[serde(default)]
385        reasoning: Option<bool>,
386        capable_target: String,
387        efficient_target: String,
388        /// Tier a turn falls back to when the signals are not confident.
389        picker: PickerMode,
390        confidence_threshold: f64,
391        /// Trailing tool results the signals are computed over.
392        #[serde(default)]
393        recent_turn_window: Option<usize>,
394        /// Note handed to the model a signal-driven switch routes to.
395        #[serde(default)]
396        handoff_notes: Option<HandoffNoteConfig>,
397        /// System prompt handed to each tier on every turn it serves.
398        #[serde(default)]
399        capable_system_prompt: Option<String>,
400        #[serde(default)]
401        efficient_system_prompt: Option<String>,
402        /// Capability judge consulted on turns the signals leave undecided.
403        #[serde(default)]
404        classifier: Option<StageClassifierConfig>,
405    },
406}
407
408/// The judge a `stage_router` route falls through to, and how it routes.
409#[derive(Debug, Deserialize)]
410#[serde(deny_unknown_fields)]
411struct StageClassifierConfig {
412    /// Target the judge is called through. Not a routing destination.
413    target: String,
414    base_threshold: f64,
415    #[serde(default)]
416    threshold_step: f64,
417    #[serde(default)]
418    session_affinity: bool,
419    #[serde(default)]
420    message_hash_fallback: bool,
421    #[serde(default)]
422    recent_turn_window: Option<usize>,
423    #[serde(default)]
424    prompt: Option<String>,
425    #[serde(default = "default_classifier_max_output_tokens")]
426    max_output_tokens: u64,
427}
428
429impl StageClassifierConfig {
430    fn task_classifier_config(&self) -> TaskClassifierConfig {
431        TaskClassifierConfig {
432            base_threshold: self.base_threshold,
433            threshold_step: self.threshold_step,
434            session_affinity: self.session_affinity,
435            message_hash_fallback: self.message_hash_fallback,
436            recent_turn_window: self.recent_turn_window,
437            contract: classifier_contract(self.prompt.as_deref()),
438            max_output_tokens: self.max_output_tokens,
439        }
440    }
441}
442
443impl RouteConfig {
444    fn id(&self) -> &str {
445        use RouteConfig::*;
446        match self {
447            Noop { id, .. }
448            | Random { id, .. }
449            | LlmClassifier { id, .. }
450            | Passthrough { id, .. }
451            | StageRouter { id, .. } => id,
452        }
453    }
454
455    // Completion targets in algorithm order; judge-only targets are excluded.
456    fn routing_target_names(&self) -> Vec<&str> {
457        match self {
458            Self::Noop { .. } => Vec::new(),
459            Self::Random { targets, .. } => targets.iter().map(String::as_str).collect(),
460            Self::Passthrough { target, .. } => vec![target],
461            Self::LlmClassifier {
462                mode,
463                strong_target,
464                weak_target,
465                escalation,
466                targets,
467                ..
468            } => match mode.unwrap_or(if escalation.is_some() {
469                ClassifierMode::Escalation
470            } else {
471                ClassifierMode::Capability
472            }) {
473                ClassifierMode::Capability => weak_target
474                    .iter()
475                    .chain(strong_target)
476                    .map(String::as_str)
477                    .collect(),
478                ClassifierMode::Escalation => strong_target
479                    .iter()
480                    .chain(weak_target)
481                    .map(String::as_str)
482                    .collect(),
483                ClassifierMode::Custom => targets.iter().flatten().map(String::as_str).collect(),
484            },
485            Self::StageRouter {
486                capable_target,
487                efficient_target,
488                ..
489            } => vec![capable_target, efficient_target],
490        }
491    }
492
493    fn capabilities(&self) -> ModelCapabilities {
494        use RouteConfig::*;
495        match self {
496            Noop {
497                context_window,
498                tool_calling,
499                reasoning,
500                ..
501            }
502            | Random {
503                context_window,
504                tool_calling,
505                reasoning,
506                ..
507            }
508            | Passthrough {
509                context_window,
510                tool_calling,
511                reasoning,
512                ..
513            }
514            | LlmClassifier {
515                context_window,
516                tool_calling,
517                reasoning,
518                ..
519            }
520            | StageRouter {
521                context_window,
522                tool_calling,
523                reasoning,
524                ..
525            } => ModelCapabilities {
526                context_window: *context_window,
527                tool_calling: *tool_calling,
528                reasoning: *reasoning,
529            },
530        }
531    }
532
533    fn classifier_mode(&self, route_name: &str) -> ServerResult<LlmClassifierModeConfig> {
534        let Self::LlmClassifier {
535            mode,
536            strong_target,
537            weak_target,
538            base_threshold,
539            threshold_step,
540            session_affinity,
541            message_hash_fallback,
542            recent_turn_window,
543            prompt,
544            max_output_tokens,
545            escalation,
546            targets,
547            default_target,
548            response_schema,
549            policy,
550            ..
551        } = self
552        else {
553            return Err(ServerError::new("route is not an llm_classifier"));
554        };
555
556        let selected_mode = match (mode, escalation.is_some()) {
557            (Some(mode), _) => *mode,
558            (None, true) => ClassifierMode::Escalation,
559            (None, false) => ClassifierMode::Capability,
560        };
561
562        match selected_mode {
563            ClassifierMode::Capability => {
564                if escalation.is_some() {
565                    return Err(classifier_field_error(
566                        route_name,
567                        "escalation",
568                        "capability",
569                    ));
570                }
571                reject_custom_fields(
572                    route_name,
573                    "capability",
574                    targets,
575                    default_target,
576                    response_schema,
577                    policy,
578                )?;
579                Ok(LlmClassifierModeConfig::Capability(
580                    CapabilityClassifierRouteConfig {
581                        strong_target: required_classifier_field(
582                            route_name,
583                            "strong_target",
584                            strong_target,
585                        )?,
586                        weak_target: required_classifier_field(
587                            route_name,
588                            "weak_target",
589                            weak_target,
590                        )?,
591                        base_threshold: required_classifier_field(
592                            route_name,
593                            "base_threshold",
594                            base_threshold,
595                        )?,
596                        threshold_step: threshold_step.unwrap_or_default(),
597                        session_affinity: *session_affinity,
598                        message_hash_fallback: *message_hash_fallback,
599                        recent_turn_window: *recent_turn_window,
600                        prompt: prompt.clone(),
601                        max_output_tokens: *max_output_tokens,
602                    },
603                ))
604            }
605            ClassifierMode::Escalation => {
606                reject_custom_fields(
607                    route_name,
608                    "escalation",
609                    targets,
610                    default_target,
611                    response_schema,
612                    policy,
613                )?;
614                if mode.is_some()
615                    && (base_threshold.is_some()
616                        || threshold_step.is_some()
617                        || *session_affinity
618                        || *message_hash_fallback
619                        || recent_turn_window.is_some())
620                {
621                    return Err(ServerError::new(format!(
622                        "llm_classifier route {route_name} mode escalation cannot use capability routing settings"
623                    )));
624                }
625                Ok(LlmClassifierModeConfig::Escalation(
626                    EscalationClassifierRouteConfig {
627                        strong_target: required_classifier_field(
628                            route_name,
629                            "strong_target",
630                            strong_target,
631                        )?,
632                        weak_target: required_classifier_field(
633                            route_name,
634                            "weak_target",
635                            weak_target,
636                        )?,
637                        prompt: prompt.clone(),
638                        max_output_tokens: *max_output_tokens,
639                        judge: required_classifier_field(route_name, "escalation", escalation)?,
640                    },
641                ))
642            }
643            ClassifierMode::Custom => {
644                if strong_target.is_some()
645                    || weak_target.is_some()
646                    || base_threshold.is_some()
647                    || threshold_step.is_some()
648                    || escalation.is_some()
649                {
650                    return Err(ServerError::new(format!(
651                        "llm_classifier route {route_name} mode custom cannot use capability or escalation fields"
652                    )));
653                }
654                Ok(LlmClassifierModeConfig::Custom(
655                    CustomClassifierRouteConfig {
656                        targets: required_classifier_field(route_name, "targets", targets)?,
657                        default_target: required_classifier_field(
658                            route_name,
659                            "default_target",
660                            default_target,
661                        )?,
662                        prompt: required_classifier_field(route_name, "prompt", prompt)?,
663                        response_schema: required_classifier_field(
664                            route_name,
665                            "response_schema",
666                            response_schema,
667                        )?,
668                        policy: required_classifier_field(route_name, "policy", policy)?,
669                        session_affinity: *session_affinity,
670                        message_hash_fallback: *message_hash_fallback,
671                        recent_turn_window: *recent_turn_window,
672                        max_output_tokens: *max_output_tokens,
673                    },
674                ))
675            }
676        }
677    }
678}
679
680fn reject_custom_fields(
681    route_name: &str,
682    mode: &str,
683    targets: &Option<Vec<String>>,
684    default_target: &Option<String>,
685    response_schema: &Option<String>,
686    policy: &Option<ClassifierPolicyConfig>,
687) -> ServerResult<()> {
688    if targets.is_some()
689        || default_target.is_some()
690        || response_schema.is_some()
691        || policy.is_some()
692    {
693        return Err(ServerError::new(format!(
694            "llm_classifier route {route_name} mode {mode} cannot use custom classifier fields"
695        )));
696    }
697    Ok(())
698}
699
700fn classifier_field_error(route_name: &str, field: &str, mode: &str) -> ServerError {
701    ServerError::new(format!(
702        "llm_classifier route {route_name} mode {mode} cannot use {field}"
703    ))
704}
705
706fn required_classifier_field<T: Clone>(
707    route_name: &str,
708    field: &str,
709    value: &Option<T>,
710) -> ServerResult<T> {
711    value.clone().ok_or_else(|| {
712        ServerError::new(format!(
713            "llm_classifier route {route_name} requires {field}"
714        ))
715    })
716}
717
718fn build_backend(
719    client_name: &str,
720    config: &LlmClientConfig,
721    extra_body: &BTreeMap<String, Value>,
722) -> ServerResult<Backend> {
723    let base_url = config.base_url.trim();
724    if base_url.is_empty() {
725        return Err(ServerError::new(format!(
726            "llm client {client_name} base_url must not be empty"
727        )));
728    }
729    if config.max_retries > MAX_CONFIGURED_RETRIES {
730        return Err(ServerError::new(format!(
731            "llm client {client_name} max_retries must be at most {MAX_CONFIGURED_RETRIES}"
732        )));
733    }
734    let api_key = config
735        .api_key_env
736        .as_deref()
737        .map(|variable| {
738            if variable.trim().is_empty() {
739                return Err(ServerError::new(format!(
740                    "llm client {client_name} api_key_env must not be empty"
741                )));
742            }
743            let api_key = std::env::var(variable).map_err(|error| {
744                ServerError::new(format!(
745                    "llm client {client_name} could not read api_key_env {variable}: {error}"
746                ))
747            })?;
748            if api_key.trim().is_empty() {
749                return Err(ServerError::new(format!(
750                    "llm client {client_name} api_key_env {variable} is empty"
751                )));
752            }
753            Ok(api_key)
754        })
755        .transpose()?;
756    let http = HttpBackendConfig {
757        base_url: base_url.to_string(),
758        api_key,
759        extra_headers: config.extra_headers.clone(),
760        extra_body: extra_body.clone(),
761        max_retries: config.max_retries,
762    };
763    Ok(match config.format {
764        ClientFormat::OpenAiChat => Backend::OpenAiChat(http),
765        ClientFormat::OpenAiResponses => Backend::OpenAiResponses(http),
766        ClientFormat::AnthropicMessages => Backend::Anthropic(http),
767    })
768}
769
770const fn default_max_retries() -> u32 {
771    DEFAULT_MAX_RETRIES
772}
773
774fn build_algorithm(
775    route_name: &str,
776    config: &RouteConfig,
777    targets: &BTreeMap<String, LlmTarget>,
778) -> ServerResult<Arc<dyn Algorithm>> {
779    match config {
780        RouteConfig::Noop { .. } => Ok(Arc::new(Noop {})),
781        RouteConfig::Random {
782            targets: names,
783            weights,
784            seed,
785            ..
786        } => {
787            let target_set =
788                resolve_targets(route_name, names.iter().map(String::as_str), targets)?;
789            let algorithm = Random::new(target_set, weights.clone(), *seed)
790                .map_err(|error| ServerError::new(format!("random route {route_name}: {error}")))?;
791            Ok(Arc::new(algorithm))
792        }
793        RouteConfig::Passthrough { target, .. } => {
794            let target = resolve_target(route_name, target, targets)?;
795            Ok(Arc::new(Passthrough::new(target)))
796        }
797        RouteConfig::LlmClassifier {
798            classifier_target, ..
799        } => {
800            let classifier = resolve_target(route_name, classifier_target, targets)?;
801            let mode = config.classifier_mode(route_name)?;
802            let algorithm = match mode {
803                LlmClassifierModeConfig::Capability(config) => {
804                    let strong = resolve_target(route_name, &config.strong_target, targets)?;
805                    let weak = resolve_target(route_name, &config.weak_target, targets)?;
806                    let classifier_config = TaskClassifierConfig {
807                        base_threshold: config.base_threshold,
808                        threshold_step: config.threshold_step,
809                        session_affinity: config.session_affinity,
810                        message_hash_fallback: config.message_hash_fallback,
811                        recent_turn_window: config.recent_turn_window,
812                        contract: classifier_contract(config.prompt.as_deref()),
813                        max_output_tokens: config.max_output_tokens,
814                    };
815                    LlmTaskClassifier::new(LlmClassifierConfig::Capability {
816                        judge_target: classifier,
817                        efficient_target: weak,
818                        capable_target: strong,
819                        config: classifier_config,
820                    })
821                }
822                LlmClassifierModeConfig::Escalation(config) => {
823                    let strong = resolve_target(route_name, &config.strong_target, targets)?;
824                    let weak = resolve_target(route_name, &config.weak_target, targets)?;
825                    LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
826                        judge_target: classifier,
827                        efficient_target: weak,
828                        capable_target: strong,
829                        contract: classifier_contract(config.prompt.as_deref()),
830                        config: config.judge,
831                        max_output_tokens: config.max_output_tokens,
832                    })
833                }
834                LlmClassifierModeConfig::Custom(config) => {
835                    let resolved_targets = config
836                        .targets
837                        .iter()
838                        .map(|name| {
839                            resolve_target(route_name, name, targets)
840                                .map(|target| (name.clone(), target))
841                        })
842                        .collect::<ServerResult<Vec<_>>>()?;
843                    let response_schema = serde_json::from_str(&config.response_schema).map_err(
844                        |error| {
845                            ServerError::new(format!(
846                                "llm_classifier route {route_name}: response_schema is invalid JSON: {error}"
847                            ))
848                        },
849                    )?;
850                    let mut classifier_config = CustomClassifierConfig::new(
851                        config.prompt,
852                        response_schema,
853                        config.policy.into_libsy(),
854                    );
855                    classifier_config.session_affinity = config.session_affinity;
856                    classifier_config.message_hash_fallback = config.message_hash_fallback;
857                    classifier_config.recent_turn_window = config.recent_turn_window;
858                    classifier_config.max_output_tokens = config.max_output_tokens;
859                    LlmTaskClassifier::new(LlmClassifierConfig::Custom {
860                        judge_target: classifier,
861                        targets: resolved_targets,
862                        default_target: config.default_target,
863                        config: classifier_config,
864                    })
865                }
866            }
867            .map_err(|error| {
868                ServerError::new(format!("llm_classifier route {route_name}: {error}"))
869            })?;
870            Ok(Arc::new(algorithm))
871        }
872        RouteConfig::StageRouter {
873            capable_target,
874            efficient_target,
875            picker,
876            confidence_threshold,
877            recent_turn_window,
878            handoff_notes,
879            capable_system_prompt,
880            efficient_system_prompt,
881            classifier,
882            ..
883        } => {
884            let capable = resolve_target(route_name, capable_target, targets)?;
885            let efficient = resolve_target(route_name, efficient_target, targets)?;
886            let mut config = StageRouterConfig::new(*picker, *confidence_threshold);
887            config.recent_window = *recent_turn_window;
888            config.handoff_notes = handoff_notes.clone();
889            config.tier_prompts = tier_prompts(
890                &capable.semantic_name,
891                capable_system_prompt.as_deref(),
892                &efficient.semantic_name,
893                efficient_system_prompt.as_deref(),
894            );
895            // The judge is called through its own target, so it is not a routing
896            // destination and stays out of the tier pair.
897            config.llm_fallback = classifier
898                .as_ref()
899                .map(|classifier| {
900                    resolve_target(route_name, &classifier.target, targets).map(|judge_target| {
901                        LlmFallback {
902                            judge_target,
903                            config: classifier.task_classifier_config(),
904                        }
905                    })
906                })
907                .transpose()?;
908            let algorithm = StageRouter::new(capable, efficient, config).map_err(|error| {
909                ServerError::new(format!("stage_router route {route_name}: {error}"))
910            })?;
911            Ok(Arc::new(algorithm))
912        }
913    }
914}
915
916fn classifier_contract(prompt: Option<&str>) -> ClassifierContractConfig {
917    prompt.map_or_else(ClassifierContractConfig::default, |prompt| {
918        ClassifierContractConfig::default().with_prompt(prompt)
919    })
920}
921
922fn default_classifier_max_output_tokens() -> u64 {
923    TaskClassifierConfig::default().max_output_tokens
924}
925
926/// Keys each configured system prompt by the target it belongs to.
927fn tier_prompts(
928    capable: &str,
929    capable_prompt: Option<&str>,
930    efficient: &str,
931    efficient_prompt: Option<&str>,
932) -> TargetPrompts {
933    let mut prompts = TargetPrompts::default();
934    if let Some(prompt) = capable_prompt {
935        prompts = prompts.with(capable, prompt);
936    }
937    if let Some(prompt) = efficient_prompt {
938        prompts = prompts.with(efficient, prompt);
939    }
940    prompts
941}
942
943fn resolve_targets<'a>(
944    route_name: &str,
945    names: impl IntoIterator<Item = &'a str>,
946    targets: &BTreeMap<String, LlmTarget>,
947) -> ServerResult<LlmTargetSet> {
948    let resolved = names
949        .into_iter()
950        .map(|name| resolve_target(route_name, name, targets))
951        .collect::<ServerResult<Vec<_>>>()?;
952    Ok(LlmTargetSet::new(resolved))
953}
954
955fn resolve_target(
956    route_name: &str,
957    name: &str,
958    targets: &BTreeMap<String, LlmTarget>,
959) -> ServerResult<LlmTarget> {
960    targets.get(name).cloned().ok_or_else(|| {
961        ServerError::new(format!(
962            "route {route_name} references unknown target {name}"
963        ))
964    })
965}
966
967fn validate_value(label: &str, value: &str) -> ServerResult<()> {
968    if value.trim().is_empty() || value.trim() != value {
969        return Err(ServerError::new(format!(
970            "{label} must be non-empty and have no surrounding whitespace"
971        )));
972    }
973    Ok(())
974}
975
976#[cfg(test)]
977mod tests {
978    use super::*;
979    use serde_json::json;
980
981    const VALID_CONFIG: &str = r#"
982schema_version = 1
983
984[llm_clients.primary]
985format = "openai_chat"
986base_url = "https://example.test/v1"
987
988[llm_clients.responses]
989format = "openai_responses"
990base_url = "https://example.test/v1"
991
992[llm_clients.anthropic]
993format = "anthropic_messages"
994base_url = "https://example.test"
995
996[targets.classifier]
997id = "classifier/model"
998llm_client = "primary"
999
1000[targets.strong]
1001id = "strong/model"
1002llm_client = "responses"
1003
1004[targets.weak]
1005id = "weak/model"
1006llm_client = "anthropic"
1007
1008[routes.noop]
1009id = "switchyard/noop"
1010type = "noop"
1011
1012[routes.random]
1013id = "switchyard/random"
1014type = "random"
1015targets = ["strong", "weak"]
1016
1017[routes.classifier]
1018id = "switchyard/classifier"
1019type = "llm_classifier"
1020classifier_target = "classifier"
1021strong_target = "strong"
1022weak_target = "weak"
1023base_threshold = 0.5
1024
1025[routes.passthrough]
1026id = "switchyard/passthrough"
1027type = "passthrough"
1028target = "weak"
1029"#;
1030
1031    fn error_message(toml: &str) -> String {
1032        match server_state_from_toml(toml) {
1033            Ok(_) => "configuration unexpectedly succeeded".to_string(),
1034            Err(error) => error.to_string(),
1035        }
1036    }
1037
1038    #[test]
1039    fn builds_all_supported_algorithm_types() -> ServerResult<()> {
1040        let state = server_state_from_toml(VALID_CONFIG)?;
1041        // The model id array is sorted alphabetically
1042        assert_eq!(
1043            state.models().collect::<Vec<_>>(),
1044            [
1045                "switchyard/classifier",
1046                "switchyard/noop",
1047                "switchyard/passthrough",
1048                "switchyard/random",
1049            ]
1050        );
1051        Ok(())
1052    }
1053
1054    #[test]
1055    fn an_escalation_table_switches_the_classifier_route_to_escalation() -> ServerResult<()> {
1056        // Present: the classifier target judges the weak tier's reply each turn instead of
1057        // picking a tier ahead of it. The route builds either way, so the assertion is that
1058        // the knob parses and its settings reach the algorithm's validation.
1059        let escalating = VALID_CONFIG.replace(
1060            "base_threshold = 0.5",
1061            "base_threshold = 0.5\nescalation = { confirmations = 2 }",
1062        );
1063        server_state_from_toml(&escalating)?;
1064
1065        // A setting that would starve the judge is rejected here rather than on the first
1066        // request, the same as any other unusable route configuration.
1067        let starved = VALID_CONFIG.replace(
1068            "base_threshold = 0.5",
1069            "base_threshold = 0.5\nescalation = { confirmations = 0 }",
1070        );
1071        assert!(error_message(&starved).contains("confirmations must be at least 1"));
1072        Ok(())
1073    }
1074
1075    #[test]
1076    fn classifier_judge_completion_caps_are_configurable() -> ServerResult<()> {
1077        let capability = VALID_CONFIG.replace(
1078            "base_threshold = 0.5",
1079            "base_threshold = 0.5\nmax_output_tokens = 512",
1080        );
1081        server_state_from_toml(&capability)?;
1082
1083        let escalation = VALID_CONFIG.replace(
1084            "base_threshold = 0.5",
1085            "base_threshold = 0.5\nmax_output_tokens = 256\nescalation = { confirmations = 2 }",
1086        );
1087        server_state_from_toml(&escalation)?;
1088        Ok(())
1089    }
1090
1091    #[test]
1092    fn classifier_prompts_are_configurable_in_both_modes() -> ServerResult<()> {
1093        let capability = VALID_CONFIG.replace(
1094            "base_threshold = 0.5",
1095            "base_threshold = 0.5\nprompt = \"custom capability rubric\"",
1096        );
1097        server_state_from_toml(&capability)?;
1098
1099        let escalation = VALID_CONFIG.replace(
1100            "base_threshold = 0.5",
1101            "base_threshold = 0.5\nprompt = \"custom trajectory rubric\"\nescalation = { confirmations = 2 }",
1102        );
1103        server_state_from_toml(&escalation)?;
1104
1105        let empty = VALID_CONFIG.replace(
1106            "base_threshold = 0.5",
1107            "base_threshold = 0.5\nprompt = \"   \"",
1108        );
1109        assert!(error_message(&empty).contains("classifier prompt must not be empty"));
1110
1111        let schema_placeholder = VALID_CONFIG.replace(
1112            "base_threshold = 0.5",
1113            "base_threshold = 0.5\nprompt = \"{{RESPONSE_SCHEMA}}\"",
1114        );
1115        assert!(error_message(&schema_placeholder).contains("schema is sent separately"));
1116        Ok(())
1117    }
1118
1119    #[test]
1120    fn mode_custom_rejects_capability_fields() {
1121        let mixed = VALID_CONFIG.replace(
1122            "base_threshold = 0.5",
1123            "mode = \"custom\"\nbase_threshold = 0.5",
1124        );
1125
1126        assert!(
1127            error_message(&mixed)
1128                .contains("mode custom cannot use capability or escalation fields")
1129        );
1130    }
1131
1132    #[test]
1133    fn rejects_unknown_fields_and_algorithm_types() {
1134        let unknown_field =
1135            VALID_CONFIG.replace("schema_version = 1", "schema_version = 1\nmagic = true");
1136        assert!(error_message(&unknown_field).contains("unknown field"));
1137
1138        let nested_completion_cap = VALID_CONFIG.replace(
1139            "base_threshold = 0.5",
1140            "base_threshold = 0.5\nescalation = { max_output_tokens = 256 }",
1141        );
1142        assert!(error_message(&nested_completion_cap).contains("unknown field"));
1143
1144        let unknown_classifier_field = VALID_CONFIG.replace(
1145            "base_threshold = 0.5",
1146            "base_threshold = 0.5\nclassifier_magic = true",
1147        );
1148        assert!(error_message(&unknown_classifier_field).contains("unknown field"));
1149
1150        let target_capability = VALID_CONFIG.replace(
1151            "llm_client = \"responses\"",
1152            "llm_client = \"responses\"\ncontext_window = 1000000",
1153        );
1154        assert!(error_message(&target_capability).contains("unknown field `context_window`"));
1155
1156        let unknown_algorithm = VALID_CONFIG.replace("type = \"noop\"", "type = \"imaginary\"");
1157        assert!(error_message(&unknown_algorithm).contains("unknown variant"));
1158    }
1159
1160    #[test]
1161    fn rejects_unknown_stage_classifier_fields() {
1162        // Nested classifier typos must fail instead of silently using a default.
1163        let config = format!(
1164            r#"{VALID_CONFIG}
1165
1166[routes.stage]
1167id = "switchyard/stage"
1168type = "stage_router"
1169capable_target = "strong"
1170efficient_target = "weak"
1171picker = "efficient_first"
1172confidence_threshold = 1.0
1173
1174[routes.stage.classifier]
1175target = "classifier"
1176base_threshold = 0.5
1177classifier_magic = true
1178"#
1179        );
1180
1181        let error = error_message(&config);
1182        assert!(
1183            error.contains("unknown field `classifier_magic`"),
1184            "{error}"
1185        );
1186    }
1187
1188    #[test]
1189    fn rejects_invalid_references_and_parameters() {
1190        let cases = [
1191            (
1192                VALID_CONFIG.replace("llm_client = \"primary\"", "llm_client = \"missing\""),
1193                "unknown llm client missing",
1194            ),
1195            (
1196                VALID_CONFIG.replace(
1197                    "targets = [\"strong\", \"weak\"]",
1198                    "targets = [\"strong\", \"missing\"]",
1199                ),
1200                "unknown target missing",
1201            ),
1202            (
1203                VALID_CONFIG.replace(
1204                    "targets = [\"strong\", \"weak\"]",
1205                    "targets = [\"strong\", \"strong\"]",
1206                ),
1207                "random targets must be unique",
1208            ),
1209            (
1210                VALID_CONFIG.replace(
1211                    "targets = [\"strong\", \"weak\"]",
1212                    "targets = [\"strong\", \"weak\"]\nweights = [1]",
1213                ),
1214                "expected 2 weights, got 1",
1215            ),
1216            (
1217                VALID_CONFIG.replace(
1218                    "targets = [\"strong\", \"weak\"]",
1219                    "targets = [\"strong\", \"weak\"]\nweights = [0, 0]",
1220                ),
1221                "at least one weight must be positive",
1222            ),
1223            (
1224                VALID_CONFIG.replace("base_threshold = 0.5", "base_threshold = 1.5"),
1225                "base_threshold must be between 0 and 1",
1226            ),
1227            (
1228                VALID_CONFIG.replace(
1229                    "base_threshold = 0.5",
1230                    "base_threshold = 0.5\nthreshold_step = -0.1",
1231                ),
1232                "threshold_step must be finite and greater than or equal to 0",
1233            ),
1234            (
1235                VALID_CONFIG.replace(
1236                    "base_threshold = 0.5",
1237                    "base_threshold = 0.8\nthreshold_step = 0.11",
1238                ),
1239                "base_threshold + 2 * threshold_step must be at most 1",
1240            ),
1241            (
1242                VALID_CONFIG.replace(
1243                    "base_threshold = 0.5",
1244                    "base_threshold = 0.5\nmax_output_tokens = 0\nescalation = { confirmations = 2 }",
1245                ),
1246                "max_output_tokens must be at least 1",
1247            ),
1248            (
1249                VALID_CONFIG.replace(
1250                    "base_threshold = 0.5",
1251                    "base_threshold = 0.5\nmessage_hash_fallback = true",
1252                ),
1253                "message_hash_fallback requires session_affinity",
1254            ),
1255            (
1256                VALID_CONFIG.replace("schema_version = 1", "schema_version = 2"),
1257                "unsupported schema_version 2",
1258            ),
1259            (
1260                VALID_CONFIG.replace("[targets.strong]", "[targets.\" strong \"]"),
1261                "target name must be non-empty and have no surrounding whitespace",
1262            ),
1263            (
1264                VALID_CONFIG.replace(
1265                    "targets = [\"strong\", \"weak\"]",
1266                    "targets = [\"strong\", \"weak\"]\ncontext_window = 0",
1267                ),
1268                "route random context_window must be greater than zero",
1269            ),
1270        ];
1271
1272        for (toml, expected) in cases {
1273            assert!(
1274                error_message(&toml).contains(expected),
1275                "expected error containing {expected}"
1276            );
1277        }
1278    }
1279
1280    #[test]
1281    fn accepts_duplicate_target_model_ids_on_one_client() -> ServerResult<()> {
1282        // Two targets share one model id on one client. The client keeps one and drops the
1283        // other, so the build warns and still succeeds, and both routes resolve. Serving one
1284        // model under two route names this way is allowed; pointing both routes at one target
1285        // is the tidier form.
1286        const SAME_MODEL_TWO_ROUTES: &str = r#"
1287schema_version = 1
1288
1289[llm_clients.primary]
1290format = "openai_chat"
1291base_url = "https://example.test/v1"
1292
1293[targets.fast]
1294id = "gpt-4o"
1295llm_client = "primary"
1296
1297[targets.smart]
1298id = "gpt-4o"
1299llm_client = "primary"
1300
1301[routes.fast]
1302id = "switchyard/fast"
1303type = "passthrough"
1304target = "fast"
1305
1306[routes.smart]
1307id = "switchyard/smart"
1308type = "passthrough"
1309target = "smart"
1310"#;
1311        let state = server_state_from_toml(SAME_MODEL_TWO_ROUTES)?;
1312        assert_eq!(
1313            state.models().collect::<Vec<_>>(),
1314            ["switchyard/fast", "switchyard/smart"]
1315        );
1316        Ok(())
1317    }
1318
1319    #[test]
1320    fn accepts_same_model_id_on_different_llm_clients() -> ServerResult<()> {
1321        // The same model id served by two llm clients never collides (each client keys its own
1322        // models), so cross-provider A/B builds with no warning; only a repeat within one client
1323        // warns.
1324        const CROSS_PROVIDER: &str = r#"
1325schema_version = 1
1326
1327[llm_clients.openai]
1328format = "openai_chat"
1329base_url = "https://example.test/v1"
1330
1331[llm_clients.azure]
1332format = "openai_chat"
1333base_url = "https://azure.test/v1"
1334
1335[targets.openai]
1336id = "gpt-4o"
1337llm_client = "openai"
1338
1339[targets.azure]
1340id = "gpt-4o"
1341llm_client = "azure"
1342
1343[routes.openai]
1344id = "switchyard/openai-gpt4o"
1345type = "passthrough"
1346target = "openai"
1347
1348[routes.azure]
1349id = "switchyard/azure-gpt4o"
1350type = "passthrough"
1351target = "azure"
1352"#;
1353        server_state_from_toml(CROSS_PROVIDER)?;
1354        Ok(())
1355    }
1356
1357    #[test]
1358    fn accepts_relative_weights_and_seed() -> ServerResult<()> {
1359        let weighted = VALID_CONFIG.replace(
1360            "targets = [\"strong\", \"weak\"]",
1361            "targets = [\"strong\", \"weak\"]\nweights = [1, 3]\nseed = 42",
1362        );
1363        server_state_from_toml(&weighted)?;
1364        Ok(())
1365    }
1366
1367    #[test]
1368    fn accepts_session_affinity_with_message_hash_fallback() -> ServerResult<()> {
1369        let configured = VALID_CONFIG.replace(
1370            "base_threshold = 0.5",
1371            "base_threshold = 0.25\nthreshold_step = 0.1\nsession_affinity = true\nmessage_hash_fallback = true",
1372        );
1373        server_state_from_toml(&configured)?;
1374        Ok(())
1375    }
1376
1377    #[test]
1378    fn target_extra_body_is_parsed_and_applied_to_its_backend() -> ServerResult<()> {
1379        let configured = VALID_CONFIG.replacen(
1380            "llm_client = \"primary\"",
1381            "llm_client = \"primary\"\n\
1382             extra_body = { service_tier = \"priority\", \
1383             chat_template_kwargs = { enable_thinking = false } }",
1384            1,
1385        );
1386        let config: ServerConfig = toml::from_str(&configured)
1387            .map_err(|error| ServerError::new(format!("failed to parse config: {error}")))?;
1388        let Some(target) = config.targets.get("classifier") else {
1389            return Err(ServerError::new("classifier target is missing"));
1390        };
1391        let Some(client) = config.llm_clients.get("primary") else {
1392            return Err(ServerError::new("primary llm client is missing"));
1393        };
1394        let backend = build_backend("primary", client, &target.extra_body)?;
1395
1396        assert_eq!(
1397            backend.extra_body().get("service_tier"),
1398            Some(&json!("priority"))
1399        );
1400        assert_eq!(
1401            backend
1402                .extra_body()
1403                .get("chat_template_kwargs")
1404                .and_then(|value| value.get("enable_thinking")),
1405            Some(&json!(false))
1406        );
1407        Ok(())
1408    }
1409
1410    #[test]
1411    fn retry_budget_defaults_and_accepts_an_override() -> ServerResult<()> {
1412        let default: ServerConfig = toml::from_str(VALID_CONFIG).map_err(|error| {
1413            ServerError::new(format!("failed to parse default config: {error}"))
1414        })?;
1415        let Some(primary) = default.llm_clients.get("primary") else {
1416            return Err(ServerError::new("primary llm client is missing"));
1417        };
1418        assert_eq!(primary.max_retries, DEFAULT_MAX_RETRIES);
1419
1420        let explicit = VALID_CONFIG.replacen(
1421            "base_url = \"https://example.test/v1\"",
1422            "base_url = \"https://example.test/v1\"\nmax_retries = 0",
1423            1,
1424        );
1425        let config: ServerConfig = toml::from_str(&explicit).map_err(|error| {
1426            ServerError::new(format!("failed to parse explicit retry config: {error}"))
1427        })?;
1428        let Some(primary) = config.llm_clients.get("primary") else {
1429            return Err(ServerError::new("primary llm client is missing"));
1430        };
1431        assert_eq!(primary.max_retries, 0);
1432        Ok(())
1433    }
1434
1435    #[test]
1436    fn retry_budget_rejects_negative_values() {
1437        let invalid = VALID_CONFIG.replacen(
1438            "base_url = \"https://example.test/v1\"",
1439            "base_url = \"https://example.test/v1\"\nmax_retries = -1",
1440            1,
1441        );
1442        assert!(error_message(&invalid).contains("max_retries"));
1443    }
1444
1445    #[test]
1446    fn retry_budget_rejects_excessive_values() {
1447        let invalid = VALID_CONFIG.replacen(
1448            "base_url = \"https://example.test/v1\"",
1449            "base_url = \"https://example.test/v1\"\nmax_retries = 11",
1450            1,
1451        );
1452        assert!(
1453            error_message(&invalid).contains("llm client primary max_retries must be at most 10")
1454        );
1455    }
1456
1457    #[test]
1458    fn api_key_environment_reference_is_validated() {
1459        let missing = VALID_CONFIG.replacen(
1460            "base_url = \"https://example.test/v1\"",
1461            "base_url = \"https://example.test/v1\"\napi_key_env = \"SWITCHYARD_CONFIG_TEST_KEY_THAT_IS_NOT_SET\"",
1462            1,
1463        );
1464        assert!(error_message(&missing).contains("SWITCHYARD_CONFIG_TEST_KEY_THAT_IS_NOT_SET"));
1465
1466        const EMPTY_KEY_ENV: &str = "SWITCHYARD_CONFIG_TEST_EMPTY_KEY";
1467        unsafe {
1468            // "unsafe" is for concurrent reads and writes, very rare
1469            std::env::set_var(EMPTY_KEY_ENV, "");
1470        }
1471        let empty = VALID_CONFIG.replacen(
1472            "base_url = \"https://example.test/v1\"",
1473            &format!("base_url = \"https://example.test/v1\"\napi_key_env = \"{EMPTY_KEY_ENV}\""),
1474            1,
1475        );
1476        let message = error_message(&empty);
1477        unsafe {
1478            std::env::remove_var(EMPTY_KEY_ENV);
1479        }
1480        assert!(message.contains("is empty"));
1481    }
1482}