diff --git a/crates/libsy-llm-client/README.md b/crates/libsy-llm-client/README.md index f2bfb7a9..a9b872ad 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, @@ -233,6 +234,10 @@ fn build_multi_format_client( `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. +- For an Anthropic backend, `HttpBackendConfig::forward_auth` forwards the caller's + `authorization` or `x-api-key` header instead of using the backend's configured key. + OpenAI backends ignore this setting. It also forwards `oauth-*` markers from + `anthropic-beta` while removing 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 99323b8f..a0191172 100644 --- a/crates/libsy-llm-client/src/backend.rs +++ b/crates/libsy-llm-client/src/backend.rs @@ -40,8 +40,10 @@ 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 an Anthropic backend forwards the caller's auth header instead. + pub forward_auth: bool, /// Custom headers added to every outbound call to this backend. /// /// OpenAI backends reject `Authorization`. Anthropic backends reject @@ -58,6 +60,7 @@ 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("forward_auth", &self.forward_auth) .field("extra_headers", &self.extra_headers) .field("extra_body_keys", &self.extra_body.keys()) .field("max_retries", &self.max_retries) @@ -132,12 +135,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. An Anthropic + /// backend with `forward_auth` uses the caller's auth instead of its configured key. 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 +162,10 @@ impl Backend { builder } + pub(crate) fn is_forwarding_auth(&self) -> bool { + matches!(self, Backend::Anthropic(config) if config.forward_auth) + } + /// Custom per-backend headers to forward on every call. pub fn extra_headers(&self) -> &BTreeMap { &self.config().extra_headers @@ -230,6 +242,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, @@ -306,6 +319,15 @@ mod tests { assert!(!Backend::OpenAiResponses(config("x")).is_anthropic()); } + #[test] + fn only_anthropic_backend_forwards_auth() { + let mut config = config("x"); + config.forward_auth = true; + assert!(Backend::Anthropic(config.clone()).is_forwarding_auth()); + assert!(!Backend::OpenAiChat(config.clone()).is_forwarding_auth()); + assert!(!Backend::OpenAiResponses(config).is_forwarding_auth()); + } + #[test] fn wire_format_matches_variant() { assert_eq!( diff --git a/crates/libsy-llm-client/src/client.rs b/crates/libsy-llm-client/src/client.rs index 61a2bf0a..9832b241 100644 --- a/crates/libsy-llm-client/src/client.rs +++ b/crates/libsy-llm-client/src/client.rs @@ -10,7 +10,7 @@ use std::time::{Duration, SystemTime}; use async_trait::async_trait; use futures_util::StreamExt; use reqwest::RequestBuilder; -use reqwest::header::{HeaderMap, RETRY_AFTER}; +use reqwest::header::{HeaderMap, HeaderValue, RETRY_AFTER}; use serde_json::{Map, Value}; use switchyard_protocol::{ LlmRequest, LlmResponse, Metadata, ModelId, Request, Response, RoutedLlmClient, @@ -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. Explicit auth forwarding +// admits `authorization`, `x-api-key`, and filtered OAuth beta markers only. const RESERVED_HEADERS: &[&str] = &[ "host", "content-length", @@ -287,7 +283,7 @@ impl TranslatingLlmClient { streaming: bool, ) -> std::result::Result { let builder = self.client.post(url).json(body); - let builder = forward_metadata_headers(builder, metadata); + let builder = forward_metadata_headers(builder, metadata, backend.is_forwarding_auth()); let builder = apply_extra_headers(builder, backend); let builder = backend.apply_auth(builder); @@ -631,16 +627,25 @@ fn convert_reqwest_error(error: reqwest::Error) -> LlmClientError { } } -// Forwards caller-supplied metadata headers, skipping the reserved set. +// Forwards caller-supplied metadata headers, including auth only when enabled. fn forward_metadata_headers( mut builder: RequestBuilder, metadata: Option<&Metadata>, + is_forwarding_auth: bool, ) -> RequestBuilder { let Some(headers) = metadata.and_then(|metadata| metadata.http_headers.as_ref()) else { return builder; }; for (name, value) in headers { - if is_reserved_header(name.as_str()) { + if is_forwarding_auth && name.as_str().eq_ignore_ascii_case("anthropic-beta") { + if let Some(oauth_betas) = oauth_beta_header(value) { + builder = builder.header(name, oauth_betas); + } + continue; + } + if is_reserved_header(name.as_str()) + && !(is_forwarding_auth && is_auth_header(name.as_str())) + { continue; } builder = builder.header(name, value); @@ -797,6 +802,25 @@ fn is_reserved_header(name: &str) -> bool { .any(|reserved| name.eq_ignore_ascii_case(reserved)) } +fn is_auth_header(name: &str) -> bool { + name.eq_ignore_ascii_case("authorization") || name.eq_ignore_ascii_case("x-api-key") +} + +// Retains OAuth markers while keeping provider feature betas backend-owned. +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(","); + (!value.is_empty()).then_some(value) +} + #[cfg(test)] mod tests { use std::collections::BTreeMap; @@ -818,6 +842,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-server/README.md b/crates/switchyard-server/README.md index 5e99a4d9..c4695a86 100644 --- a/crates/switchyard-server/README.md +++ b/crates/switchyard-server/README.md @@ -75,6 +75,8 @@ 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. +An `anthropic_messages` client can set `forward_auth = true` instead of `api_key_env` to send the +caller's `authorization` or `x-api-key` header to the configured upstream. 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 d2e59fa1..23f26187 100644 --- a/crates/switchyard-server/src/config.rs +++ b/crates/switchyard-server/src/config.rs @@ -234,6 +234,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, @@ -771,6 +773,25 @@ fn build_backend( "llm client {client_name} max_retries must be at most {MAX_CONFIGURED_RETRIES}" ))); } + if config.forward_auth && !matches!(config.format, ClientFormat::AnthropicMessages) { + return Err(ServerError::new(format!( + "llm client {client_name} forward_auth requires format = \"anthropic_messages\"" + ))); + } + 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" + ))); + } + if config.forward_auth + && config.extra_headers.keys().any(|header| { + header.eq_ignore_ascii_case("authorization") || header.eq_ignore_ascii_case("x-api-key") + }) + { + return Err(ServerError::new(format!( + "llm client {client_name} cannot set forward_auth with authorization or x-api-key in extra_headers" + ))); + } let api_key = config .api_key_env .as_deref() @@ -796,6 +817,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 +1593,53 @@ target = "azure" } assert!(message.contains("is empty")); } + + #[test] + fn forward_auth_rejects_unsupported_configurations() { + let non_anthropic = VALID_CONFIG.replacen( + "base_url = \"https://example.test/v1\"", + "base_url = \"https://example.test/v1\"\nforward_auth = true", + 1, + ); + assert!( + error_message(&non_anthropic) + .contains("forward_auth requires format = \"anthropic_messages\"") + ); + + let competing_auth = VALID_CONFIG + .replacen( + "format = \"openai_chat\"", + "format = \"anthropic_messages\"", + 1, + ) + .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( + "format = \"openai_chat\"", + "format = \"anthropic_messages\"", + 1, + ) + .replacen( + "base_url = \"https://example.test/v1\"", + "base_url = \"https://example.test/v1\"\n\ + forward_auth = true\n\ + extra_headers = { Authorization = \"static-value\" }", + 1, + ); + assert!( + error_message(&static_auth).contains( + "cannot set forward_auth with authorization or x-api-key in extra_headers" + ) + ); + } } diff --git a/crates/switchyard-server/tests/server.rs b/crates/switchyard-server/tests/server.rs index a1241c59..463c4f74 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,10 @@ 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_auth), + ) .route("/v1/messages/count_tokens", post(upstream_count_tokens)) .layer(DefaultBodyLimit::disable()) .with_state(Arc::clone(&calls)); @@ -200,6 +204,48 @@ async fn upstream_chat( .into_response() } +async fn upstream_messages_requires_forwarded_auth( + State(calls): State>>>, + headers: HeaderMap, + Json(body): Json, +) -> HttpResponse { + calls.lock().await.push(body.clone()); + let has_oauth_auth = 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"); + let has_api_key = headers + .get("x-api-key") + .and_then(|value| value.to_str().ok()) + == Some("client-api-key"); + let has_required_version = headers + .get("anthropic-version") + .and_then(|value| value.to_str().ok()) + == Some("2023-06-01"); + if !(has_oauth_auth || has_api_key) || !has_required_version { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": {"message": "missing forwarded Anthropic auth"}})), + ) + .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_count_tokens( State(calls): State>>>, Json(body): Json, @@ -212,6 +258,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 credentials = [ + ( + "authorization", + "Bearer claude-oauth-token", + Some("oauth-2025-04-20,unsupported-beta"), + ), + ("x-api-key", "client-api-key", None), + ]; + for (name, value, beta) in credentials { + let mut headers = vec![(name, value)]; + if let Some(beta) = beta { + headers.push(("anthropic-beta", beta)); + } + let response = send_with_headers( + &app, + "POST", + "/v1/messages", + Some(json!({ + "model": "switchyard/claude", + "max_tokens": 16, + "messages": [{"role": "user", "content": "hello"}] + })), + &headers, + ) + .await?; + assert_eq!(response.status, StatusCode::OK); + } + + 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 083dd7df..1f28d074 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -155,6 +155,8 @@ 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. +An `anthropic_messages` client can set `forward_auth = true` instead of +`api_key_env` to send each caller's Anthropic credential to that upstream. ### Run the server diff --git a/docs/reference/toml_schema.md b/docs/reference/toml_schema.md index 6db279d8..3d72ee39 100644 --- a/docs/reference/toml_schema.md +++ b/docs/reference/toml_schema.md @@ -45,12 +45,30 @@ 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. | +| `forward_auth` | No | `false` | Forward the caller's `authorization` or `x-api-key` header to an `anthropic_messages` upstream. | | `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. | | `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` on an `anthropic_messages` client to use each caller's +Anthropic 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`, `authorization`, or +`x-api-key` in `extra_headers`. Switchyard sends the inbound `authorization` or +`x-api-key` value to `base_url`, so enable this only for an upstream that should +receive caller credentials. For Claude subscription OAuth, Switchyard also +forwards `oauth-*` values from `anthropic-beta` and removes all other inbound beta +values. + ## `[targets.]` | Key | Required | Default | Meaning |