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..feee52ca7 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(|| { @@ -260,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, @@ -911,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}" @@ -950,18 +977,19 @@ 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(), 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), - }) + }; + Ok(backend) } const fn default_max_retries() -> u32 { @@ -1426,6 +1454,21 @@ classify_trigger = "new_session""#, Ok(()) } + #[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("base_url must be an absolute HTTP(S) 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 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 new file mode 100644 index 000000000..f72c45858 --- /dev/null +++ b/crates/switchyard-server/tests/cli.rs @@ -0,0 +1,45 @@ +// 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("base_url must be an absolute HTTP(S) URL"), + "{stderr}" + ); + Ok(()) +}