From 451dbcb40b5866211b287ebfcab552acec4f4cbf Mon Sep 17 00:00:00 2001 From: Ting-Hong Shieh <32212900+ting-hong-shieh@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:55:29 +0800 Subject: [PATCH 1/3] fix(server): validate configured base URLs Signed-off-by: Ting-Hong Shieh <32212900+ting-hong-shieh@users.noreply.github.com> --- Cargo.lock | 1 + crates/switchyard-server/Cargo.toml | 1 + crates/switchyard-server/src/config.rs | 39 ++++++++++++++++++++++-- crates/switchyard-server/tests/cli.rs | 42 ++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 crates/switchyard-server/tests/cli.rs diff --git a/Cargo.lock b/Cargo.lock index ff7a91185..4a181b1b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2361,6 +2361,7 @@ dependencies = [ "opentelemetry_sdk", "parking_lot", "prometheus", + "reqwest", "rustls", "serde", "serde_json", diff --git a/crates/switchyard-server/Cargo.toml b/crates/switchyard-server/Cargo.toml index 1b22546a8..5502a0e8c 100644 --- a/crates/switchyard-server/Cargo.toml +++ b/crates/switchyard-server/Cargo.toml @@ -32,6 +32,7 @@ opentelemetry-prometheus = "0.32" opentelemetry_sdk = { version = "0.32", default-features = false, features = ["metrics", "trace"] } parking_lot.workspace = true prometheus = "0.14" +reqwest.workspace = true serde.workspace = true toml = "1.1" switchyard-llm-client.workspace = true diff --git a/crates/switchyard-server/src/config.rs b/crates/switchyard-server/src/config.rs index ebecfa21e..9eb84680a 100644 --- a/crates/switchyard-server/src/config.rs +++ b/crates/switchyard-server/src/config.rs @@ -957,11 +957,30 @@ fn build_backend( extra_body: extra_body.clone(), max_retries: config.max_retries, }; - Ok(match config.format { + let backend = match config.format { ClientFormat::OpenAiChat => Backend::OpenAiChat(http), ClientFormat::OpenAiResponses => Backend::OpenAiResponses(http), ClientFormat::AnthropicMessages => Backend::Anthropic(http), - }) + }; + validate_backend_url(client_name, &backend)?; + Ok(backend) +} + +// Validate the endpoint after the backend applies the same format-specific URL +// joining used for requests, so dry-run and request construction cannot diverge. +fn validate_backend_url(client_name: &str, backend: &Backend) -> ServerResult<()> { + let endpoint = backend.url(); + let url = reqwest::Url::parse(&endpoint).map_err(|error| { + ServerError::new(format!( + "llm client {client_name} base_url must resolve to an absolute HTTP(S) URL: {error}" + )) + })?; + if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { + return Err(ServerError::new(format!( + "llm client {client_name} base_url must resolve to an absolute HTTP(S) URL" + ))); + } + Ok(()) } const fn default_max_retries() -> u32 { @@ -1426,6 +1445,22 @@ classify_trigger = "new_session""#, Ok(()) } + #[test] + fn rejects_non_http_base_urls_during_construction() { + for base_url in ["not a url", "/v1", "ftp://example.test/v1"] { + let invalid = VALID_CONFIG.replacen( + "base_url = \"https://example.test/v1\"", + &format!("base_url = \"{base_url}\""), + 1, + ); + let message = error_message(&invalid); + assert!( + message.contains("llm client primary base_url"), + "unexpected error for {base_url}: {message}" + ); + } + } + #[test] fn an_escalation_table_switches_the_classifier_route_to_escalation() -> ServerResult<()> { // Present: the classifier target judges the weak tier's reply each turn instead of diff --git a/crates/switchyard-server/tests/cli.rs b/crates/switchyard-server/tests/cli.rs new file mode 100644 index 000000000..a94f2e873 --- /dev/null +++ b/crates/switchyard-server/tests/cli.rs @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Process-level regression coverage for the server CLI. + +use std::fs; +use std::process::Command; + +type TestResult = Result>; + +#[test] +fn dry_run_rejects_invalid_base_url() -> TestResult { + let directory = tempfile::tempdir()?; + let config = directory.path().join("routes.toml"); + fs::write( + &config, + r#" +schema_version = 1 + +[llm_clients.invalid] +format = "openai_chat" +base_url = "not a url" + +[targets.invalid] +id = "upstream-model" +llm_client = "invalid" + +[routes.invalid] +id = "test-route" +type = "passthrough" +target = "invalid" +"#, + )?; + + let output = Command::new(env!("CARGO_BIN_EXE_switchyard-server")) + .args(["--config", config.to_string_lossy().as_ref(), "--dry-run"]) + .output()?; + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr)?; + assert!(stderr.contains("llm client invalid base_url"), "{stderr}"); + Ok(()) +} From 956f2cde4f72b5e8c6575c831ae0a27327f92942 Mon Sep 17 00:00:00 2001 From: Ting-Hong Shieh <32212900+ting-hong-shieh@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:58:32 +0800 Subject: [PATCH 2/3] fix(server): validate every configured client Signed-off-by: Ting-Hong Shieh <32212900+ting-hong-shieh@users.noreply.github.com> --- crates/switchyard-server/src/config.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/switchyard-server/src/config.rs b/crates/switchyard-server/src/config.rs index 9eb84680a..f65af42ea 100644 --- a/crates/switchyard-server/src/config.rs +++ b/crates/switchyard-server/src/config.rs @@ -138,8 +138,10 @@ impl ServerConfig { .map(|name| (name.clone(), Vec::new())) .collect::>>(); - for name in self.llm_clients.keys() { + // Validate every declared client even when no target currently references it. + for (name, client_config) in &self.llm_clients { validate_value("llm client name", name)?; + build_backend(name, client_config, &BTreeMap::new())?; } for (target_name, target) in &self.targets { let client_config = self.llm_clients.get(&target.llm_client).ok_or_else(|| { @@ -1461,6 +1463,21 @@ classify_trigger = "new_session""#, } } + #[test] + fn rejects_invalid_unreferenced_llm_client() { + let invalid = format!( + "{VALID_CONFIG}\n\ + [llm_clients.unused]\n\ + format = \"openai_chat\"\n\ + base_url = \"not a url\"\n" + ); + let message = error_message(&invalid); + assert!( + message.contains("llm client unused base_url"), + "unexpected error: {message}" + ); + } + #[test] fn an_escalation_table_switches_the_classifier_route_to_escalation() -> ServerResult<()> { // Present: the classifier target judges the weak tier's reply each turn instead of From 18ecb18cd256551d818ad691be89e37973ee4634 Mon Sep 17 00:00:00 2001 From: Elias Shieh <32212900+ting-hong-shieh@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:28:17 +0800 Subject: [PATCH 3/3] refactor(server): parse base_url into a validated type Validating the endpoint after construction let every later stage assume a property nothing carried. Parse it in `Deserialize` instead, so an invalid endpoint fails when the config loads and holding a `HttpBaseUrl` is the proof. Drops `validate_backend_url` and the empty-string guard, which the type now covers, and the construction-time test whose case the CLI test already exercises end to end. Signed-off-by: Elias Shieh <32212900+ting-hong-shieh@users.noreply.github.com> --- crates/switchyard-server/src/config.rs | 77 ++++++++++++-------------- crates/switchyard-server/src/lib.rs | 2 +- crates/switchyard-server/tests/cli.rs | 5 +- 3 files changed, 39 insertions(+), 45 deletions(-) diff --git a/crates/switchyard-server/src/config.rs b/crates/switchyard-server/src/config.rs index f65af42ea..feee52ca7 100644 --- a/crates/switchyard-server/src/config.rs +++ b/crates/switchyard-server/src/config.rs @@ -262,11 +262,42 @@ fn count_tokens_priority(target_name: &str, model_id: &ModelId) -> usize { .unwrap_or(3) } +/// A client endpoint, parsed when the config loads rather than checked afterwards. +/// +/// Holding a `HttpBaseUrl` is proof the value is an absolute HTTP(S) URL, so no +/// later stage has to re-check it or can forget to. +#[derive(Clone, Debug)] +pub(crate) struct HttpBaseUrl(reqwest::Url); + +impl HttpBaseUrl { + pub(crate) fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl<'de> Deserialize<'de> for HttpBaseUrl { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + let url = reqwest::Url::parse(raw.trim()).map_err(|error| { + serde::de::Error::custom(format!("base_url must be an absolute HTTP(S) URL: {error}")) + })?; + if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { + return Err(serde::de::Error::custom( + "base_url must be an absolute HTTP(S) URL", + )); + } + Ok(Self(url)) + } +} + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct LlmClientConfig { pub(crate) format: ClientFormat, - pub(crate) base_url: String, + pub(crate) base_url: HttpBaseUrl, api_key_env: Option, #[serde(default)] forward_auth: bool, @@ -913,12 +944,6 @@ fn build_backend( config: &LlmClientConfig, extra_body: &BTreeMap, ) -> ServerResult { - let base_url = config.base_url.trim(); - if base_url.is_empty() { - return Err(ServerError::new(format!( - "llm client {client_name} base_url must not be empty" - ))); - } if config.max_retries > MAX_CONFIGURED_RETRIES { return Err(ServerError::new(format!( "llm client {client_name} max_retries must be at most {MAX_CONFIGURED_RETRIES}" @@ -952,7 +977,7 @@ fn build_backend( }) .transpose()?; let http = HttpBackendConfig { - base_url: base_url.to_string(), + base_url: config.base_url.as_str().to_string(), api_key, forward_auth: config.forward_auth, extra_headers: config.extra_headers.clone(), @@ -964,27 +989,9 @@ fn build_backend( ClientFormat::OpenAiResponses => Backend::OpenAiResponses(http), ClientFormat::AnthropicMessages => Backend::Anthropic(http), }; - validate_backend_url(client_name, &backend)?; Ok(backend) } -// Validate the endpoint after the backend applies the same format-specific URL -// joining used for requests, so dry-run and request construction cannot diverge. -fn validate_backend_url(client_name: &str, backend: &Backend) -> ServerResult<()> { - let endpoint = backend.url(); - let url = reqwest::Url::parse(&endpoint).map_err(|error| { - ServerError::new(format!( - "llm client {client_name} base_url must resolve to an absolute HTTP(S) URL: {error}" - )) - })?; - if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { - return Err(ServerError::new(format!( - "llm client {client_name} base_url must resolve to an absolute HTTP(S) URL" - ))); - } - Ok(()) -} - const fn default_max_retries() -> u32 { DEFAULT_MAX_RETRIES } @@ -1447,22 +1454,6 @@ classify_trigger = "new_session""#, Ok(()) } - #[test] - fn rejects_non_http_base_urls_during_construction() { - for base_url in ["not a url", "/v1", "ftp://example.test/v1"] { - let invalid = VALID_CONFIG.replacen( - "base_url = \"https://example.test/v1\"", - &format!("base_url = \"{base_url}\""), - 1, - ); - let message = error_message(&invalid); - assert!( - message.contains("llm client primary base_url"), - "unexpected error for {base_url}: {message}" - ); - } - } - #[test] fn rejects_invalid_unreferenced_llm_client() { let invalid = format!( @@ -1473,7 +1464,7 @@ classify_trigger = "new_session""#, ); let message = error_message(&invalid); assert!( - message.contains("llm client unused base_url"), + message.contains("base_url must be an absolute HTTP(S) URL"), "unexpected error: {message}" ); } diff --git a/crates/switchyard-server/src/lib.rs b/crates/switchyard-server/src/lib.rs index 483c1f6a6..ffb5db06e 100644 --- a/crates/switchyard-server/src/lib.rs +++ b/crates/switchyard-server/src/lib.rs @@ -351,7 +351,7 @@ impl ServerState { model: &target.id, llm_client: DecisionLlmClientResponse { format: client.format.wire_format(), - base_url: &client.base_url, + base_url: client.base_url.as_str(), }, extra_body: &target.extra_body, }) diff --git a/crates/switchyard-server/tests/cli.rs b/crates/switchyard-server/tests/cli.rs index a94f2e873..f72c45858 100644 --- a/crates/switchyard-server/tests/cli.rs +++ b/crates/switchyard-server/tests/cli.rs @@ -37,6 +37,9 @@ target = "invalid" .output()?; assert!(!output.status.success()); let stderr = String::from_utf8(output.stderr)?; - assert!(stderr.contains("llm client invalid base_url"), "{stderr}"); + assert!( + stderr.contains("base_url must be an absolute HTTP(S) URL"), + "{stderr}" + ); Ok(()) }