Skip to content
Merged
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
65 changes: 54 additions & 11 deletions crates/switchyard-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,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 @@ -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<D>(deserializer: D) -> Result<Self, D::Error>
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<String>,
#[serde(default)]
forward_auth: bool,
Expand Down Expand Up @@ -911,12 +944,6 @@ fn build_backend(
config: &LlmClientConfig,
extra_body: &BTreeMap<String, Value>,
) -> ServerResult<Backend> {
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}"
Expand Down Expand Up @@ -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)
Comment thread
ting-hong-shieh marked this conversation as resolved.
}

const fn default_max_retries() -> u32 {
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/switchyard-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down
45 changes: 45 additions & 0 deletions crates/switchyard-server/tests/cli.rs
Original file line number Diff line number Diff line change
@@ -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<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("base_url must be an absolute HTTP(S) URL"),
"{stderr}"
);
Ok(())
}
Loading