Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/switchyard-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 55 additions & 3 deletions crates/switchyard-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,10 @@ impl ServerConfig {
.map(|name| (name.clone(), Vec::new()))
.collect::<BTreeMap<String, Vec<ModelConfig>>>();

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(|| {
Expand Down Expand Up @@ -800,11 +802,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)
Comment thread
ting-hong-shieh marked this conversation as resolved.
}

// 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 {
Expand Down Expand Up @@ -1097,6 +1118,37 @@ target = "weak"
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!(
"{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
Expand Down
42 changes: 42 additions & 0 deletions crates/switchyard-server/tests/cli.rs
Original file line number Diff line number Diff line change
@@ -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<T = ()> = Result<T, Box<dyn std::error::Error>>;

#[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(())
}