diff --git a/crates/libsy-llm-client/README.md b/crates/libsy-llm-client/README.md index f2bfb7a9e..207ea787b 100644 --- a/crates/libsy-llm-client/README.md +++ b/crates/libsy-llm-client/README.md @@ -65,6 +65,7 @@ fn build_client() -> switchyard_llm_client::Result { let openai = HttpBackendConfig { base_url: "https://api.openai.com/v1".to_string(), api_key: std::env::var("OPENAI_API_KEY").ok(), + forward_auth: false, extra_headers: BTreeMap::new(), extra_body: BTreeMap::new(), max_retries: 2, @@ -231,8 +232,13 @@ fn build_multi_format_client( Anthropic sends `x-api-key: ` plus `anthropic-version`. - `request.metadata.http_headers` are forwarded upstream, **except** reserved ones: `host`, `content-length`, `connection`, and the backend-owned - `authorization` / `x-api-key` / `anthropic-version` / `content-type`. So a - caller's placeholder credential never overrides the backend's real key. + login, API-key, version, and content headers. So a caller's placeholder + credential never overrides the backend's real key. +- `HttpBackendConfig::forward_auth` uses the caller's credential instead of the + backend's configured key. OpenAI backends forward `authorization`, + `chatgpt-account-id`, and `x-openai-fedramp`. Anthropic backends forward + `authorization` or `x-api-key`; they also keep `oauth-*` values from + `anthropic-beta` and remove other caller-supplied beta values. - Per-backend custom headers go in `HttpBackendConfig::extra_headers`. Set credentials with `api_key`. OpenAI backends reject `Authorization`; Anthropic backends reject `x-api-key` and `anthropic-version`. Header names are case-insensitive. diff --git a/crates/libsy-llm-client/src/backend.rs b/crates/libsy-llm-client/src/backend.rs index 99323b8f0..4e1020ac0 100644 --- a/crates/libsy-llm-client/src/backend.rs +++ b/crates/libsy-llm-client/src/backend.rs @@ -6,8 +6,9 @@ use std::{collections::BTreeMap, fmt}; use reqwest::RequestBuilder; +use reqwest::header::HeaderValue; use serde_json::Value; -use switchyard_protocol::WireFormat; +use switchyard_protocol::{Metadata, WireFormat}; use crate::error::{LlmClientError, Result, is_overflow_body}; @@ -40,12 +41,14 @@ const ANTHROPIC_OVERFLOW_PHRASES: &[&str] = &[ pub struct HttpBackendConfig { /// Base URL of the provider API (e.g. `https://api.openai.com/v1`). pub base_url: String, - /// API key for the provider, loaded by the caller. `None` sends no auth. + /// API key for the provider, loaded by the caller. `None` sends no configured auth. pub api_key: Option, + /// Whether this backend forwards the caller's provider credential instead. + pub forward_auth: bool, /// Custom headers added to every outbound call to this backend. /// - /// OpenAI backends reject `Authorization`. Anthropic backends reject - /// `x-api-key` and `anthropic-version`. Header names are case-insensitive. + /// Provider-owned headers are rejected so a static value cannot replace + /// configured or forwarded auth. Header names are case-insensitive. pub extra_headers: BTreeMap, /// Default top-level request fields, applied only when the request omits the key. pub extra_body: BTreeMap, @@ -58,7 +61,8 @@ impl fmt::Debug for HttpBackendConfig { f.debug_struct("HttpBackendConfig") .field("base_url", &self.base_url) .field("api_key", &self.api_key.as_ref().map(|_| "[REDACTED]")) - .field("extra_headers", &self.extra_headers) + .field("forward_auth", &self.forward_auth) + .field("extra_header_names", &self.extra_headers.keys()) .field("extra_body_keys", &self.extra_body.keys()) .field("max_retries", &self.max_retries) .finish() @@ -85,10 +89,16 @@ impl Backend { let invalid_name = self.config().extra_headers.keys().find(|name| match self { Backend::OpenAiChat(_) | Backend::OpenAiResponses(_) => { name.eq_ignore_ascii_case("authorization") + || (self.is_forwarding_auth() + && (name.eq_ignore_ascii_case("chatgpt-account-id") + || name.eq_ignore_ascii_case("x-openai-fedramp"))) } Backend::Anthropic(_) => { name.eq_ignore_ascii_case("x-api-key") || name.eq_ignore_ascii_case("anthropic-version") + || (self.is_forwarding_auth() + && (name.eq_ignore_ascii_case("authorization") + || name.eq_ignore_ascii_case("anthropic-beta"))) } }); if let Some(name) = invalid_name { @@ -132,12 +142,17 @@ impl Backend { } } - /// Applies this backend's auth and version headers to a request builder. + /// Applies this backend's configured auth and version headers to a request builder. /// /// OpenAI variants use `Authorization: Bearer `; Anthropic uses - /// `x-api-key: ` plus the required `anthropic-version` header. + /// `x-api-key: ` plus the required `anthropic-version` header. A backend + /// with `forward_auth` uses the caller's provider credential instead. pub fn apply_auth(&self, mut builder: RequestBuilder) -> RequestBuilder { - let api_key = self.config().api_key.as_deref(); + let api_key = if self.is_forwarding_auth() { + None + } else { + self.config().api_key.as_deref() + }; match self { Backend::OpenAiChat(_) | Backend::OpenAiResponses(_) => { if let Some(api_key) = api_key { @@ -154,6 +169,75 @@ impl Backend { builder } + pub(crate) fn is_forwarding_auth(&self) -> bool { + self.config().forward_auth + } + + /// Applies only the caller credential accepted by this provider. + pub(crate) fn apply_forwarded_auth( + &self, + mut builder: RequestBuilder, + metadata: Option<&Metadata>, + ) -> RequestBuilder { + if !self.is_forwarding_auth() { + return builder; + } + let Some(headers) = metadata.and_then(|metadata| metadata.http_headers.as_ref()) else { + return builder; + }; + match self { + Backend::OpenAiChat(_) | Backend::OpenAiResponses(_) => { + for name in ["authorization", "chatgpt-account-id", "x-openai-fedramp"] { + if let Some(value) = headers.get(name) { + builder = builder.header(name, sensitive_header(value)); + } + } + } + Backend::Anthropic(_) => { + for name in ["authorization", "x-api-key"] { + if let Some(value) = headers.get(name) { + builder = builder.header(name, sensitive_header(value)); + } + } + if let Some(value) = headers.get("anthropic-beta") + && let Some(value) = oauth_beta_header(value) + { + builder = builder.header("anthropic-beta", value); + } + } + } + builder + } + + /// Removes an echoed caller credential before an upstream error is returned or logged. + pub(crate) fn redact_forwarded_auth( + &self, + mut body: String, + metadata: Option<&Metadata>, + ) -> String { + if !self.is_forwarding_auth() { + return body; + } + let Some(headers) = metadata.and_then(|metadata| metadata.http_headers.as_ref()) else { + return body; + }; + let secret_headers: &[&str] = match self { + Backend::OpenAiChat(_) | Backend::OpenAiResponses(_) => { + &["authorization", "chatgpt-account-id"] + } + Backend::Anthropic(_) => &["authorization", "x-api-key"], + }; + for name in secret_headers { + let Some(value) = headers.get(*name).and_then(|value| value.to_str().ok()) else { + continue; + }; + if !value.is_empty() { + body = body.replace(value, "[REDACTED]"); + } + } + body + } + /// Custom per-backend headers to forward on every call. pub fn extra_headers(&self) -> &BTreeMap { &self.config().extra_headers @@ -202,6 +286,32 @@ impl Backend { } } +// Retains OAuth markers while keeping provider feature betas backend-owned. +fn sensitive_header(value: &HeaderValue) -> HeaderValue { + let mut value = value.clone(); + value.set_sensitive(true); + value +} + +fn oauth_beta_header(value: &HeaderValue) -> Option { + let oauth_betas = value + .to_str() + .ok()? + .split(',') + .map(str::trim) + .filter(|beta| { + beta.get(..6) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("oauth-")) + }); + let value = oauth_betas.collect::>().join(","); + if value.is_empty() { + return None; + } + let mut value = HeaderValue::from_str(&value).ok()?; + value.set_sensitive(true); + Some(value) +} + // Accept either a root `/v1` URL or an already-specific OpenAI endpoint URL. fn openai_url(base_url: &str, suffix: &str) -> String { let base_root = base_url @@ -230,6 +340,7 @@ mod tests { HttpBackendConfig { base_url: base_url.to_string(), api_key: Some("secret".to_string()), + forward_auth: false, extra_headers: BTreeMap::new(), extra_body: BTreeMap::new(), max_retries: 0, diff --git a/crates/libsy-llm-client/src/client.rs b/crates/libsy-llm-client/src/client.rs index 61a2bf0a8..2751ccfd4 100644 --- a/crates/libsy-llm-client/src/client.rs +++ b/crates/libsy-llm-client/src/client.rs @@ -26,12 +26,8 @@ use crate::error::{LlmClientError, Result}; use crate::metrics; use crate::raw::RawResponse; -// TODO: Why is this here? What does it do? -// Headers this client owns or that are hop-by-hop; never forwarded from the -// caller's metadata. Auth/version/content-type are set by the backend or the -// JSON body, so a forwarded copy would either be ignored or conflict. Compared -// case-insensitively. Aligns with `_SENSITIVE_HEADERS` in the Python -// `switchyard/lib/request_metadata.py` forwarding logic. +// Headers this client owns or that are hop-by-hop. Backends apply an explicitly +// enabled caller credential after generic metadata forwarding skips these. const RESERVED_HEADERS: &[&str] = &[ "host", "content-length", @@ -42,6 +38,8 @@ const RESERVED_HEADERS: &[&str] = &[ "cookie", "set-cookie", "x-api-key", + "chatgpt-account-id", + "x-openai-fedramp", "anthropic-beta", "anthropic-version", "content-type", @@ -88,6 +86,7 @@ impl ModelConfig { pub struct TranslatingLlmClient { model_to_config: HashMap, client: reqwest::Client, + forward_auth_client: reqwest::Client, } impl TranslatingLlmClient { @@ -102,12 +101,16 @@ impl TranslatingLlmClient { backend.validate_extra_headers(&config.model_name)?; } } - let client = - reqwest::Client::builder() - .build() - .map_err(|error| LlmClientError::Transport { - source: Box::new(error), - })?; + let build_client = |builder: reqwest::ClientBuilder| { + builder.build().map_err(|error| LlmClientError::Transport { + source: Box::new(error), + }) + }; + let client = build_client(reqwest::Client::builder())?; + // A redirect could move provider-specific headers to another origin. + // Forwarded credentials are sent only to the configured URL. + let forward_auth_client = + build_client(reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()))?; let model_to_config = model_configs .iter() .map(|config| (config.model_name.clone(), config.clone())) @@ -116,6 +119,7 @@ impl TranslatingLlmClient { Ok(Self { model_to_config, client, + forward_auth_client, }) } @@ -286,8 +290,14 @@ impl TranslatingLlmClient { model: &ModelId, streaming: bool, ) -> std::result::Result { - let builder = self.client.post(url).json(body); + let client = if backend.is_forwarding_auth() { + &self.forward_auth_client + } else { + &self.client + }; + let builder = client.post(url).json(body); let builder = forward_metadata_headers(builder, metadata); + let builder = backend.apply_forwarded_auth(builder, metadata); let builder = apply_extra_headers(builder, backend); let builder = backend.apply_auth(builder); @@ -339,6 +349,7 @@ impl TranslatingLlmClient { }); } }; + let body = backend.redact_forwarded_auth(body, metadata); metrics::record_upstream_attempt(Some(status.as_u16())); let error = if status == reqwest::StatusCode::BAD_REQUEST && backend.is_context_overflow(&body) { @@ -631,7 +642,7 @@ fn convert_reqwest_error(error: reqwest::Error) -> LlmClientError { } } -// Forwards caller-supplied metadata headers, skipping the reserved set. +// Forwards caller-supplied metadata headers except credentials and client-owned headers. fn forward_metadata_headers( mut builder: RequestBuilder, metadata: Option<&Metadata>, @@ -818,6 +829,7 @@ mod tests { HttpBackendConfig { base_url: base_url.to_string(), api_key: Some("secret".to_string()), + forward_auth: false, extra_headers: BTreeMap::new(), extra_body: BTreeMap::new(), max_retries: 0, diff --git a/crates/switchyard-py/src/server_bindings.rs b/crates/switchyard-py/src/server_bindings.rs index 6ea78c005..862eea2ad 100644 --- a/crates/switchyard-py/src/server_bindings.rs +++ b/crates/switchyard-py/src/server_bindings.rs @@ -3,6 +3,7 @@ //! Self-contained Python host for the Rust Switchyard server. +use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::path::PathBuf; use std::sync::mpsc::{Receiver, RecvTimeoutError, sync_channel}; @@ -24,6 +25,7 @@ const DEFAULT_SHUTDOWN_TIMEOUT_SECS: f64 = 2.0; #[pyclass(name = "Server", module = "switchyard_rust.server", unsendable)] struct PyServer { addr: SocketAddr, + caller_auth_by_model: HashMap>, shutdown: Option>, completion: Option>>, task: Option>, @@ -33,10 +35,19 @@ struct PyServer { impl PyServer { /// Loads a TOML deployment and starts serving it on loopback. #[new] - #[pyo3(signature = (config, *, port=0))] + #[pyo3(signature = (config, port=0))] fn new(config: PathBuf, port: u16) -> PyResult { initialize_observability().map_err(server_error)?; let state = load_server_state(config).map_err(server_error)?; + let caller_auth_by_model = state + .models() + .map(|model| { + state + .caller_auth_kind(model) + .map(|kind| (model.to_string(), kind)) + }) + .collect::>>() + .map_err(server_error)?; let runtime = pyo3_async_runtimes::tokio::get_runtime(); let server = { let _guard = runtime.enter(); @@ -65,6 +76,7 @@ impl PyServer { }); Ok(Self { addr, + caller_auth_by_model, shutdown: Some(shutdown), completion: Some(completion), task: Some(task), @@ -83,8 +95,16 @@ impl PyServer { format!("http://{}", self.addr) } + /// Returns which caller credential the route forwards, if any. + fn caller_auth_kind(&self, model: &str) -> PyResult> { + self.caller_auth_by_model + .get(model) + .copied() + .ok_or_else(|| PyValueError::new_err(format!("unknown route model {model:?}"))) + } + /// Gracefully stops the server and flushes pending telemetry. - #[pyo3(signature = (*, timeout_secs=DEFAULT_SHUTDOWN_TIMEOUT_SECS))] + #[pyo3(signature = (timeout_secs=DEFAULT_SHUTDOWN_TIMEOUT_SECS))] fn close(&mut self, py: Python<'_>, timeout_secs: f64) -> PyResult<()> { self.close_inner(py, timeout_secs) } diff --git a/crates/switchyard-server/README.md b/crates/switchyard-server/README.md index 5e99a4d9b..0d403ada8 100644 --- a/crates/switchyard-server/README.md +++ b/crates/switchyard-server/README.md @@ -75,6 +75,12 @@ Each target references an entry under `llm_clients`. All configured clients use `anthropic_messages`. Supported algorithms are `noop`, `random`, `passthrough`, `llm_classifier`, and `stage_router`. An `api_key_env` value names an environment variable; the TOML never contains the secret itself. If omitted, the client sends no authentication. +A client can set `forward_auth = true` instead of `api_key_env` to send the +caller's credential to the configured upstream. OpenAI clients forward +`authorization`, `chatgpt-account-id`, and `x-openai-fedramp`. Anthropic clients +forward `authorization` or `x-api-key`. Enable this only when every forwarding +client's `base_url` should receive the caller's login. A forwarding route must +be called through the matching provider API. Target-level `extra_body` values are shallow-merged into the upstream request when the request does not already contain that key. `max_retries` defaults to `2` and applies to transport failures, timeouts, HTTP 408/429, and 5xx diff --git a/crates/switchyard-server/src/config.rs b/crates/switchyard-server/src/config.rs index d2e59fa18..c69e4374a 100644 --- a/crates/switchyard-server/src/config.rs +++ b/crates/switchyard-server/src/config.rs @@ -3,7 +3,7 @@ //! Typed TOML configuration and explicit construction for the Rust server. -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs; use std::path::Path; use std::sync::Arc; @@ -22,7 +22,9 @@ use switchyard_llm_client::{ }; use switchyard_protocol::{ModelId, RoutedLlmClient}; -use crate::{CountTokensTarget, ModelCapabilities, ServerError, ServerResult, ServerState}; +use crate::{ + CallerAuthKind, CountTokensTarget, ModelCapabilities, ServerError, ServerResult, ServerState, +}; const SUPPORTED_SCHEMA_VERSION: u32 = 1; const MAX_CONFIGURED_RETRIES: u32 = 10; @@ -99,12 +101,13 @@ impl ServerConfig { ))); } let algorithm = build_algorithm(route_name, config, &targets)?; - let client = self.build_client_router(config, &clients)?; + let (client, caller_auth) = self.build_route_clients(route_name, config, &clients)?; let count_tokens_target = self.build_count_tokens_target(config, &clients); routes.push(( config.id().clone(), algorithm, client, + caller_auth, capabilities, count_tokens_target, )); @@ -168,26 +171,40 @@ impl ServerConfig { /// The map is built per route because targets are only reachable through the routes that /// name them, so the same model id may resolve to different clients in two routes without /// colliding. - fn build_client_router( + fn build_route_clients( &self, + route_name: &str, route: &RouteConfig, clients: &BTreeMap>, - ) -> ServerResult { - let by_model = route - .callable_target_names() - .into_iter() - .map(|name| { - let target = self.targets.get(name).ok_or_else(|| { - ServerError::new(format!("route references unknown target {name}")) - })?; - let client = clients.get(&target.llm_client).ok_or_else(|| { - ServerError::new(format!("target {name} has no constructed llm client")) - })?; - let client: Arc = client.clone(); - Ok((target.id.clone(), client)) - }) - .collect::>()?; - Ok(ClientRouter::new(by_model)) + ) -> ServerResult<(ClientRouter, Option)> { + let mut by_model = HashMap::new(); + let mut caller_auth = None; + for name in route.callable_target_names() { + let target = self.targets.get(name).ok_or_else(|| { + ServerError::new(format!("route references unknown target {name}")) + })?; + let client = clients.get(&target.llm_client).ok_or_else(|| { + ServerError::new(format!("target {name} has no constructed llm client")) + })?; + let client_config = self.llm_clients.get(&target.llm_client).ok_or_else(|| { + ServerError::new(format!( + "target {name} references unknown llm client {}", + target.llm_client + )) + })?; + if client_config.forward_auth { + let target_auth = client_config.format.caller_auth_kind(); + if caller_auth.is_some_and(|kind| kind != target_auth) { + return Err(ServerError::new(format!( + "route {route_name} cannot forward both Anthropic and OpenAI caller credentials" + ))); + } + caller_auth = Some(target_auth); + } + let client: Arc = client.clone(); + by_model.insert(target.id.clone(), client); + } + Ok((ClientRouter::new(by_model), caller_auth)) } fn build_count_tokens_target( @@ -234,6 +251,8 @@ struct LlmClientConfig { base_url: String, api_key_env: Option, #[serde(default)] + forward_auth: bool, + #[serde(default)] extra_headers: BTreeMap, #[serde(default = "default_max_retries")] max_retries: u32, @@ -258,6 +277,15 @@ enum ClientFormat { AnthropicMessages, } +impl ClientFormat { + const fn caller_auth_kind(self) -> CallerAuthKind { + match self { + Self::AnthropicMessages => CallerAuthKind::Anthropic, + Self::OpenAiChat | Self::OpenAiResponses => CallerAuthKind::OpenAi, + } + } +} + #[derive(Clone, Debug, Deserialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] enum ClassifierPolicyConfig { @@ -771,6 +799,11 @@ fn build_backend( "llm client {client_name} max_retries must be at most {MAX_CONFIGURED_RETRIES}" ))); } + if config.forward_auth && config.api_key_env.is_some() { + return Err(ServerError::new(format!( + "llm client {client_name} cannot set both forward_auth and api_key_env" + ))); + } let api_key = config .api_key_env .as_deref() @@ -796,6 +829,7 @@ fn build_backend( let http = HttpBackendConfig { base_url: base_url.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, @@ -1571,4 +1605,48 @@ target = "azure" } assert!(message.contains("is empty")); } + + #[test] + fn forward_auth_rejects_conflicting_credentials() { + let competing_auth = VALID_CONFIG.replacen( + "base_url = \"https://example.test/v1\"", + "base_url = \"https://example.test/v1\"\n\ + forward_auth = true\n\ + api_key_env = \"UNUSED_TEST_KEY\"", + 1, + ); + assert!( + error_message(&competing_auth).contains("cannot set both forward_auth and api_key_env") + ); + + let static_auth = VALID_CONFIG.replacen( + "base_url = \"https://example.test\"", + "base_url = \"https://example.test\"\n\ + forward_auth = true\n\ + extra_headers = { Authorization = \"static-value\" }", + 1, + ); + assert!(error_message(&static_auth).contains("extra_headers cannot set \"Authorization\"")); + + let static_beta = static_auth.replace("Authorization", "anthropic-beta"); + assert!( + error_message(&static_beta).contains("extra_headers cannot set \"anthropic-beta\"") + ); + + for header in ["chatgpt-account-id", "x-openai-fedramp"] { + let static_context = VALID_CONFIG.replacen( + "base_url = \"https://example.test/v1\"", + &format!( + "base_url = \"https://example.test/v1\"\n\ + forward_auth = true\n\ + extra_headers = {{ \"{header}\" = \"static-value\" }}" + ), + 1, + ); + assert!( + error_message(&static_context) + .contains(&format!("extra_headers cannot set \"{header}\"")) + ); + } + } } diff --git a/crates/switchyard-server/src/lib.rs b/crates/switchyard-server/src/lib.rs index 81ee7d6ae..bad1b9c5c 100644 --- a/crates/switchyard-server/src/lib.rs +++ b/crates/switchyard-server/src/lib.rs @@ -109,10 +109,38 @@ struct RouteEntry { /// selected. A route is a synthetic model with no upstream of its own, so this is a /// per-target lookup, never one client serving the whole route. target_clients: ClientRouter, + caller_auth: Option, capabilities: ModelCapabilities, count_tokens_target: Option, } +/// Caller credential family required by forwarded-auth backends. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum CallerAuthKind { + Anthropic, + OpenAi, +} + +impl CallerAuthKind { + const fn as_str(self) -> &'static str { + match self { + Self::Anthropic => "anthropic", + Self::OpenAi => "openai", + } + } + + const fn accepts(self, wire_format: WireFormat) -> bool { + matches!( + (self, wire_format), + (Self::Anthropic, WireFormat::AnthropicMessages) + | ( + Self::OpenAi, + WireFormat::OpenAiChat | WireFormat::OpenAiResponses + ) + ) + } +} + /// Exact upstream model used by the server's Anthropic token-count endpoint. #[derive(Clone)] struct CountTokensTarget { @@ -181,6 +209,7 @@ impl ServerState { model, algorithm, clients, + None, ModelCapabilities::default(), None, ) @@ -193,13 +222,16 @@ impl ServerState { ModelId, Arc, ClientRouter, + Option, ModelCapabilities, Option, ), >, ) -> ServerResult { let mut entries = BTreeMap::new(); - for (model, algorithm, target_clients, capabilities, count_tokens_target) in routes { + for (model, algorithm, target_clients, caller_auth, capabilities, count_tokens_target) in + routes + { let model = ModelId::from(model.trim()); if model.is_empty() { return Err(ServerError::new("route model must not be empty")); @@ -207,6 +239,7 @@ impl ServerState { let entry = RouteEntry { algorithm, target_clients, + caller_auth, capabilities, count_tokens_target, }; @@ -242,6 +275,13 @@ impl ServerState { self.routes.keys().map(ModelId::as_str) } + /// Returns the caller credential family used by `model`, if any. + pub fn caller_auth_kind(&self, model: &str) -> ServerResult> { + self.route_for_model(model) + .map(|entry| entry.caller_auth.map(CallerAuthKind::as_str)) + .ok_or_else(|| ServerError::new(format!("unknown route model {model:?}"))) + } + fn route_for_model(&self, model: &str) -> Option<&RouteEntry> { self.routes.get(model) } @@ -669,6 +709,22 @@ fn resolve_route( "model_not_found", ) })?; + if let Some(caller_auth) = route.caller_auth + && !caller_auth.accepts(wire_format) + { + let (provider, expected_endpoint) = match caller_auth { + CallerAuthKind::Anthropic => ("Anthropic", "/v1/messages"), + CallerAuthKind::OpenAi => ("OpenAI", "/v1/chat/completions or /v1/responses"), + }; + return Err(error_response( + StatusCode::BAD_REQUEST, + format!( + "route {requested_model} forwards an {provider} login; call it through {expected_endpoint}", + ), + "invalid_request_error", + "invalid_request_error", + )); + } let request = Request { llm_request, raw_request: Some(body), diff --git a/crates/switchyard-server/tests/server.rs b/crates/switchyard-server/tests/server.rs index 10f58b65c..456ed9192 100644 --- a/crates/switchyard-server/tests/server.rs +++ b/crates/switchyard-server/tests/server.rs @@ -11,7 +11,7 @@ use std::sync::Arc; use axum::body::{Body, Bytes}; use axum::extract::{DefaultBodyLimit, State}; -use axum::http::{Request as HttpRequest, StatusCode}; +use axum::http::{HeaderMap, Request as HttpRequest, StatusCode}; use axum::response::sse::{Event, Sse}; use axum::response::{IntoResponse, Response as HttpResponse}; use axum::routing::post; @@ -48,6 +48,15 @@ impl MockUpstream { let calls = Arc::new(Mutex::new(Vec::new())); let app = Router::new() .route("/v1/chat/completions", post(upstream_chat)) + .route( + "/v1/messages", + post(upstream_messages_requires_forwarded_oauth), + ) + .route( + "/v1/responses", + post(upstream_responses_requires_forwarded_auth), + ) + .route("/capture", post(upstream_redirect_capture)) .route("/v1/messages/count_tokens", post(upstream_count_tokens)) .layer(DefaultBodyLimit::disable()) .with_state(Arc::clone(&calls)); @@ -200,6 +209,113 @@ async fn upstream_chat( .into_response() } +async fn upstream_messages_requires_forwarded_oauth( + State(calls): State>>>, + headers: HeaderMap, + Json(body): Json, +) -> HttpResponse { + calls.lock().await.push(body.clone()); + let has_expected_headers = headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + == Some("Bearer claude-oauth-token") + && headers + .get("anthropic-beta") + .and_then(|value| value.to_str().ok()) + == Some("oauth-2025-04-20") + && headers + .get("anthropic-version") + .and_then(|value| value.to_str().ok()) + == Some("2023-06-01") + && !headers.contains_key("chatgpt-account-id") + && !headers.contains_key("x-openai-fedramp"); + if !has_expected_headers { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": {"message": "missing forwarded Anthropic OAuth headers"}})), + ) + .into_response(); + } + Json(json!({ + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": body["model"], + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": {"input_tokens": 1, "output_tokens": 1} + })) + .into_response() +} + +async fn upstream_responses_requires_forwarded_auth( + State(calls): State>>>, + headers: HeaderMap, + Json(body): Json, +) -> HttpResponse { + calls.lock().await.push(body.clone()); + if headers.contains_key("x-test-redirect") { + return (StatusCode::TEMPORARY_REDIRECT, [("location", "/capture")]).into_response(); + } + if headers.contains_key("x-test-echo-auth") { + let authorization = headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": {"message": authorization}})), + ) + .into_response(); + } + let has_expected_headers = headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + == Some("Bearer codex-login-token") + && headers + .get("chatgpt-account-id") + .and_then(|value| value.to_str().ok()) + == Some("account-123") + && headers + .get("x-openai-fedramp") + .and_then(|value| value.to_str().ok()) + == Some("true") + && !headers.contains_key("x-api-key") + && !headers.contains_key("anthropic-beta"); + if !has_expected_headers { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": {"message": "missing forwarded OpenAI login"}})), + ) + .into_response(); + } + Json(json!({ + "id": "resp_test", + "object": "response", + "model": body["model"], + "status": "completed", + "output": [{ + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "ok"}] + }], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2} + })) + .into_response() +} + +async fn upstream_redirect_capture( + State(calls): State>>>, + headers: HeaderMap, +) -> HttpResponse { + calls.lock().await.push(json!({ + "redirected": true, + "has_authorization": headers.contains_key("authorization") + })); + StatusCode::OK.into_response() +} + async fn upstream_count_tokens( State(calls): State>>>, Json(body): Json, @@ -212,6 +328,7 @@ fn random_state(base_url: &str, routes: &[(&str, &[&str])]) -> TestResult TestResult { + let upstream = MockUpstream::start().await?; + let state = load_test_config(&format!( + r#" +schema_version = 1 + +[llm_clients.claude] +format = "anthropic_messages" +base_url = "{base_url}" +forward_auth = true +max_retries = 0 + +[targets.claude] +id = "claude-opus" +llm_client = "claude" + +[routes.claude] +id = "switchyard/claude" +type = "passthrough" +target = "claude" +"#, + base_url = upstream.base_url + ))?; + let app = build_switchyard_router(state); + + let response = send_with_headers( + &app, + "POST", + "/v1/messages", + Some(json!({ + "model": "switchyard/claude", + "max_tokens": 16, + "messages": [{"role": "user", "content": "hello"}] + })), + &[ + ("authorization", "Bearer claude-oauth-token"), + ("anthropic-beta", "oauth-2025-04-20,unsupported-beta"), + ("chatgpt-account-id", "must-not-cross-providers"), + ("x-openai-fedramp", "must-not-cross-providers"), + ], + ) + .await?; + assert_eq!(response.status, StatusCode::OK); + + let wrong_api = send_with_headers( + &app, + "POST", + "/v1/responses", + Some(json!({"model": "switchyard/claude", "input": "hello"})), + &[("authorization", "Bearer codex-login-token")], + ) + .await?; + assert_eq!(wrong_api.status, StatusCode::BAD_REQUEST); + assert_eq!(upstream.calls.lock().await.len(), 1); + + Ok(()) +} + +#[tokio::test] +async fn responses_client_forwards_openai_login_when_configured() -> TestResult { + let upstream = MockUpstream::start().await?; + let state = load_test_config(&format!( + r#" +schema_version = 1 + +[llm_clients.openai] +format = "openai_responses" +base_url = "{base_url}" +forward_auth = true +max_retries = 0 + +[targets.openai] +id = "gpt-codex" +llm_client = "openai" + +[routes.openai] +id = "switchyard/codex" +type = "passthrough" +target = "openai" +"#, + base_url = upstream.base_url + ))?; + let app = build_switchyard_router(state); + + let response = send_with_headers( + &app, + "POST", + "/v1/responses", + Some(json!({"model": "switchyard/codex", "input": "hello"})), + &[ + ("authorization", "Bearer codex-login-token"), + ("chatgpt-account-id", "account-123"), + ("x-openai-fedramp", "true"), + ("x-api-key", "must-not-cross-providers"), + ("anthropic-beta", "oauth-must-not-cross-providers"), + ], + ) + .await?; + assert_eq!(response.status, StatusCode::OK); + + let redirect = send_with_headers( + &app, + "POST", + "/v1/responses", + Some(json!({"model": "switchyard/codex", "input": "hello"})), + &[ + ("authorization", "Bearer codex-login-token"), + ("chatgpt-account-id", "account-123"), + ("x-openai-fedramp", "true"), + ("x-test-redirect", "1"), + ], + ) + .await?; + assert_eq!(redirect.status, StatusCode::TEMPORARY_REDIRECT); + assert_eq!(upstream.calls.lock().await.len(), 2); + + let echoed_auth = send_with_headers( + &app, + "POST", + "/v1/responses", + Some(json!({"model": "switchyard/codex", "input": "hello"})), + &[ + ("authorization", "Bearer codex-login-token"), + ("x-test-echo-auth", "1"), + ], + ) + .await?; + assert_eq!(echoed_auth.status, StatusCode::UNAUTHORIZED); + let error = echoed_auth.text()?; + assert!(error.contains("[REDACTED]")); + assert!(!error.contains("codex-login-token")); + + Ok(()) +} + #[tokio::test] async fn count_tokens_without_anthropic_target_returns_bad_request() -> TestResult { let upstream = MockUpstream::start().await?; diff --git a/docs/getting_started.md b/docs/getting_started.md index 083dd7dfc..c6428e970 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -155,6 +155,12 @@ base_threshold = 0.5 `format` selects the upstream protocol and must be `openai_chat`, `openai_responses`, or `anthropic_messages`. `api_key_env` names the environment variable the server reads; the secret does not belong in the TOML file. +A client can set `forward_auth = true` instead of `api_key_env` to send each +caller's credential to that upstream. OpenAI clients forward `authorization`, +`chatgpt-account-id`, and `x-openai-fedramp`. Anthropic clients forward +`authorization` or `x-api-key`. Enable this only for an upstream that should +receive the caller's login. The server rejects a forwarding route called +through the other provider's API. ### Run the server diff --git a/docs/reference/toml_schema.md b/docs/reference/toml_schema.md index 6db279d80..6b7a8f9ef 100644 --- a/docs/reference/toml_schema.md +++ b/docs/reference/toml_schema.md @@ -45,12 +45,37 @@ route reaches no upstream. A file without a `[targets]` table is rejected with | `format` | Yes | — | `openai_chat`, `openai_responses`, or `anthropic_messages`. | | `base_url` | Yes | — | Upstream base URL. | | `api_key_env` | No | unset | Name of the environment variable holding the key. Omit to send no authentication. | -| `extra_headers` | No | `{}` | Custom HTTP headers sent to the model server. Set credentials with `api_key_env`. The server rejects `Authorization` for OpenAI clients and `x-api-key` or `anthropic-version` for Anthropic clients when it loads the config. Header names are case-insensitive. | +| `forward_auth` | No | `false` | Forward the caller's provider credential to this upstream. | +| `extra_headers` | No | `{}` | Custom HTTP headers sent to the model server. Set credentials with `api_key_env` or `forward_auth`; the server rejects headers owned by the selected auth mode. Header names are case-insensitive. | | `max_retries` | No | `2` | Retry budget, `0`–`10`. | The TOML never contains the secret itself. `api_key_env` names a variable that must exist and be non-empty when the server loads. +Set `forward_auth = true` to use each caller's credential instead of a +server-owned key: + +```toml +[llm_clients.claude] +format = "anthropic_messages" +base_url = "https://api.anthropic.com" +forward_auth = true +``` + +`forward_auth` cannot be combined with `api_key_env`. OpenAI clients forward +`authorization`, `chatgpt-account-id`, and `x-openai-fedramp`. Anthropic clients +forward `authorization` or `x-api-key`; for Claude subscription OAuth, they also +forward `oauth-*` values from `anthropic-beta` and remove all other inbound beta +values. + +This setting gives `base_url` the caller's login. Enable it only when that +upstream should receive the credential, and use HTTPS unless the upstream runs +on loopback. Forwarding clients do not follow HTTP redirects. Check every +forwarding client used by a route, including classifier and judge targets. The +server rejects an Anthropic forwarding route called through an OpenAI endpoint, +or an OpenAI forwarding route called through an Anthropic endpoint, before it +calls an upstream. + ## `[targets.]` | Key | Required | Default | Meaning | diff --git a/switchyard/cli/launchers/claude_code_launcher.py b/switchyard/cli/launchers/claude_code_launcher.py index 0623c7518..af7a0d98e 100644 --- a/switchyard/cli/launchers/claude_code_launcher.py +++ b/switchyard/cli/launchers/claude_code_launcher.py @@ -60,11 +60,16 @@ def _wait_ready(port: int, timeout_s: float = _READY_TIMEOUT_S) -> bool: return wait_for_proxy_ready(port, timeout_s=timeout_s) -def _claude_env(port: int, model: str) -> dict[str, str]: +def _claude_env( + port: int, + model: str, + use_anthropic_auth: bool = False, +) -> dict[str, str]: """Build the env-var overrides that route Claude Code through our proxy. * ``ANTHROPIC_BASE_URL`` — our proxy URL. - * ``ANTHROPIC_AUTH_TOKEN`` — opaque token; skips Console OAuth. + * ``ANTHROPIC_AUTH_TOKEN`` — an opaque local token unless the deployment + forwards the caller's Anthropic login. * ``ANTHROPIC_API_KEY=""`` — silences the auth-conflict warning. * ``ANTHROPIC_MODEL`` — initial active model for the session. * ``ANTHROPIC_SMALL_FAST_MODEL`` — existing background-model override, @@ -75,22 +80,23 @@ def _claude_env(port: int, model: str) -> dict[str, str]: * ``CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY`` — tells Claude Code to populate the picker from ``GET /v1/models``. """ - return { + env = { "ANTHROPIC_BASE_URL": f"http://127.0.0.1:{port}", - "ANTHROPIC_AUTH_TOKEN": "switchyard", "ANTHROPIC_API_KEY": "", "ANTHROPIC_MODEL": model, "ANTHROPIC_SMALL_FAST_MODEL": os.environ.get("ANTHROPIC_SMALL_FAST_MODEL", model), "ANTHROPIC_CUSTOM_MODEL_OPTION": model, "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "1", } + if not use_anthropic_auth: + env["ANTHROPIC_AUTH_TOKEN"] = "switchyard" + return env def _supervise_claude_plain( claude_bin: str, claude_args: list[str], - port: int, - model: str, + env_overrides: dict[str, str], ) -> int: """Run ``claude`` via plain subprocess (non-TTY / headless fallback). @@ -98,7 +104,7 @@ def _supervise_claude_plain( ``KeyboardInterrupt`` is translated to exit code 130. """ env = os.environ.copy() - env.update(_claude_env(port, model)) + env.update(env_overrides) try: result = subprocess.run([claude_bin, *claude_args], env=env, check=False) return result.returncode @@ -134,6 +140,21 @@ def _run_claude_with_switchyard( strategy_summary = f"config → {config.name}" try: + try: + caller_auth = server.caller_auth_kind(display_model) + except ValueError as error: + logger.error("%s", error) + return 1 + if caller_auth == "openai": + logger.error( + "this route forwards an OpenAI login; run it with `switchyard launch codex`" + ) + return 1 + env_overrides = _claude_env( + resolved_port, + display_model, + use_anthropic_auth=caller_auth == "anthropic", + ) if not _wait_ready(resolved_port): print_startup_failure( port=resolved_port, @@ -155,7 +176,6 @@ def _run_claude_with_switchyard( banner_pause() health = ProxyHealthMonitor(resolved_port) - env_overrides = _claude_env(resolved_port, display_model) logger.debug( "claude env ANTHROPIC_BASE_URL=%s ANTHROPIC_MODEL=%s " "ANTHROPIC_CUSTOM_MODEL_OPTION=%s GATEWAY_DISCOVERY=%s", @@ -177,7 +197,7 @@ def _run_claude_with_switchyard( ) return tui.run() return _supervise_claude_plain( - claude_bin, claude_args, resolved_port, display_model, + claude_bin, claude_args, env_overrides, ) finally: print_session_summary(stats) diff --git a/switchyard/cli/launchers/codex_cli_launcher.py b/switchyard/cli/launchers/codex_cli_launcher.py index 78c85f659..d3b1aced7 100644 --- a/switchyard/cli/launchers/codex_cli_launcher.py +++ b/switchyard/cli/launchers/codex_cli_launcher.py @@ -61,7 +61,7 @@ def _wait_ready(port: int, timeout_s: float = _READY_TIMEOUT_S) -> bool: def _provider_overrides( port: int, - *, + use_openai_auth: bool = False, model_catalog_json: str | None = None, ) -> list[str]: """Build transient Codex provider overrides for the local proxy.""" @@ -75,20 +75,30 @@ def _provider_overrides( f'model_providers.{_PROVIDER_ID}.base_url="{base_url}"', "-c", f'model_providers.{_PROVIDER_ID}.wire_api="responses"', - "-c", - f'model_providers.{_PROVIDER_ID}.env_key="OPENAI_API_KEY"', - "-c", - f"model_providers.{_PROVIDER_ID}.requires_openai_auth=false", ] + if use_openai_auth: + overrides.extend( + ["-c", f"model_providers.{_PROVIDER_ID}.requires_openai_auth=true"] + ) + else: + overrides.extend( + [ + "-c", + f'model_providers.{_PROVIDER_ID}.env_key="OPENAI_API_KEY"', + "-c", + f"model_providers.{_PROVIDER_ID}.requires_openai_auth=false", + ] + ) if model_catalog_json is not None: overrides.extend(["-c", f"model_catalog_json={json.dumps(model_catalog_json)}"]) return overrides -def _codex_env() -> dict[str, str]: +def _codex_env(use_openai_auth: bool = False) -> dict[str, str]: """Return the environment required by the transient provider.""" env = os.environ.copy() - env["OPENAI_API_KEY"] = "switchyard" + if not use_openai_auth: + env["OPENAI_API_KEY"] = "switchyard" return env @@ -97,12 +107,17 @@ def _codex_command( codex_args: list[str], port: int, model: str, + use_openai_auth: bool = False, model_catalog_json: str | None = None, ) -> list[str]: """Build the exact Codex command for the local proxy.""" return [ codex_bin, - *_provider_overrides(port, model_catalog_json=model_catalog_json), + *_provider_overrides( + port, + use_openai_auth=use_openai_auth, + model_catalog_json=model_catalog_json, + ), "-m", model, *codex_args, @@ -110,25 +125,12 @@ def _codex_command( def _supervise_codex( - codex_bin: str, - codex_args: list[str], - port: int, - model: str, - model_catalog_json: str | None = None, + command: list[str], + env: dict[str, str], ) -> int: """Run Codex and return its exit code.""" try: - result = subprocess.run( - _codex_command( - codex_bin, - codex_args, - port, - model, - model_catalog_json=model_catalog_json, - ), - env=_codex_env(), - check=False, - ) + result = subprocess.run(command, env=env, check=False) return result.returncode except KeyboardInterrupt: return _EXIT_SIGINT @@ -163,7 +165,27 @@ def _run_codex_with_switchyard( strategy_summary = f"config → {config.name}" try: + try: + caller_auth = server.caller_auth_kind(display_model) + except ValueError as error: + logger.error("%s", error) + return 1 + if caller_auth == "anthropic": + logger.error( + "this route forwards an Anthropic login; run it with `switchyard launch claude`" + ) + return 1 + use_openai_auth = caller_auth == "openai" model_catalog_json = _write_codex_model_catalog(codex_bin, codex_model_catalog) + command = _codex_command( + codex_bin, + codex_args, + resolved_port, + display_model, + use_openai_auth=use_openai_auth, + model_catalog_json=model_catalog_json, + ) + env = _codex_env(use_openai_auth) if not _wait_ready(resolved_port): print_startup_failure( port=resolved_port, @@ -192,25 +214,13 @@ def _run_codex_with_switchyard( strategy_label="config", ) return ShellTUI( - command=_codex_command( - codex_bin, - codex_args, - resolved_port, - display_model, - model_catalog_json=model_catalog_json, - ), + command=command, footer_fn=footer.as_footer_fn(), footer_height=lambda: footer.height, - env=_codex_env(), + env=env, ).run() - return _supervise_codex( - codex_bin, - codex_args, - resolved_port, - display_model, - model_catalog_json=model_catalog_json, - ) + return _supervise_codex(command, env) finally: print_session_summary(stats) server.close() diff --git a/switchyard/cli/launchers/native_server.py b/switchyard/cli/launchers/native_server.py index ad38adf80..d33e987c5 100644 --- a/switchyard/cli/launchers/native_server.py +++ b/switchyard/cli/launchers/native_server.py @@ -3,8 +3,6 @@ """Native Rust server lifecycle for coding-agent launchers.""" -from __future__ import annotations - import json import logging import urllib.request @@ -48,6 +46,10 @@ def __init__(self, config: Path) -> None: self.base_url: str = self._server.base_url self.stats: StatsSource = HttpStatsSource(self.base_url) + def caller_auth_kind(self, model: str) -> str | None: + """Return which caller credential the route forwards, if any.""" + return self._server.caller_auth_kind(model) + def close(self) -> None: """Gracefully stop the native server.""" self._server.close() diff --git a/switchyard_rust/server.py b/switchyard_rust/server.py index 193aef353..a30577eca 100644 --- a/switchyard_rust/server.py +++ b/switchyard_rust/server.py @@ -3,11 +3,11 @@ """Native Rust Switchyard server host.""" -from __future__ import annotations - from os import PathLike from typing import TYPE_CHECKING, Any, final +from typing_extensions import Self + from switchyard_rust._native import load_native if TYPE_CHECKING: @@ -16,7 +16,7 @@ class Server: """Running loopback instance of the native Switchyard server.""" - def __init__(self, config: str | PathLike[str], *, port: int = 0) -> None: ... + def __init__(self, config: str | PathLike[str], port: int = 0) -> None: ... @property def port(self) -> int: ... @@ -24,9 +24,11 @@ def port(self) -> int: ... @property def base_url(self) -> str: ... - def close(self, *, timeout_secs: float = 2.0) -> None: ... + def caller_auth_kind(self, model: str) -> str | None: ... + + def close(self, timeout_secs: float = 2.0) -> None: ... - def __enter__(self) -> Server: ... + def __enter__(self) -> Self: ... def __exit__( self, diff --git a/tests/test_launchers.py b/tests/test_launchers.py index 4e15e2754..6873c7b75 100644 --- a/tests/test_launchers.py +++ b/tests/test_launchers.py @@ -10,6 +10,7 @@ from switchyard.cli.launch_command import _config_path from switchyard.cli.launchers.claude_code_launcher import _claude_env +from switchyard.cli.launchers.codex_cli_launcher import _codex_env, _provider_overrides from switchyard.cli.launchers.native_server import NativeServer from switchyard.cli.switchyard_cli import _build_parser @@ -62,6 +63,26 @@ def test_claude_env_preserves_small_fast_model_override( assert env["ANTHROPIC_SMALL_FAST_MODEL"] == "background-route" +def test_forward_auth_does_not_replace_the_agent_login( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "inherited-claude-token") + monkeypatch.setenv("OPENAI_API_KEY", "inherited-openai-key") + + claude_auth = _claude_env(4321, "agent-route", use_anthropic_auth=True) + claude_local = _claude_env(4321, "agent-route") + codex_auth = _codex_env(use_openai_auth=True) + codex_local = _codex_env() + codex_auth_config = " ".join(_provider_overrides(4321, use_openai_auth=True)) + + assert "ANTHROPIC_AUTH_TOKEN" not in claude_auth + assert claude_local["ANTHROPIC_AUTH_TOKEN"] == "switchyard" + assert codex_auth["OPENAI_API_KEY"] == "inherited-openai-key" + assert codex_local["OPENAI_API_KEY"] == "switchyard" + assert "requires_openai_auth=true" in codex_auth_config + assert "env_key" not in codex_auth_config + + def test_native_server_passes_config_directly_to_binding( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -74,20 +95,30 @@ class FakeServer: port = 4321 base_url = "http://127.0.0.1:4321" - def __init__(self, path: Path, *, port: int) -> None: + def __init__(self, path: Path, port: int) -> None: captured["path"] = path captured["port"] = port def close(self) -> None: captured["closed"] = True + def caller_auth_kind(self, model: str) -> str | None: + captured["model"] = model + return "anthropic" + import switchyard_rust.server monkeypatch.setattr(switchyard_rust.server, "Server", FakeServer) server = NativeServer(config) + assert server.caller_auth_kind("switchyard/route") == "anthropic" server.close() - assert captured == {"path": config, "port": 0, "closed": True} + assert captured == { + "path": config, + "port": 0, + "model": "switchyard/route", + "closed": True, + } assert server.port == 4321 assert config.exists()