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
5 changes: 5 additions & 0 deletions crates/libsy-llm-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ fn build_client() -> switchyard_llm_client::Result<TranslatingLlmClient> {
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,
Expand Down Expand Up @@ -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.
Expand Down
30 changes: 26 additions & 4 deletions crates/libsy-llm-client/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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
Expand All @@ -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)
Expand Down Expand Up @@ -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 <key>`; Anthropic uses
/// `x-api-key: <key>` plus the required `anthropic-version` header.
/// `x-api-key: <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 {
Expand All @@ -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<String, String> {
&self.config().extra_headers
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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!(
Expand Down
45 changes: 35 additions & 10 deletions crates/libsy-llm-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand Down Expand Up @@ -287,7 +283,7 @@ impl TranslatingLlmClient {
streaming: bool,
) -> std::result::Result<EncodedResponse, AttemptFailure> {
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);

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<String> {
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::<Vec<_>>().join(",");
(!value.is_empty()).then_some(value)
}

#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions crates/switchyard-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions crates/switchyard-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,8 @@ struct LlmClientConfig {
base_url: String,
api_key_env: Option<String>,
#[serde(default)]
forward_auth: bool,
#[serde(default)]
extra_headers: BTreeMap<String, String>,
#[serde(default = "default_max_retries")]
max_retries: u32,
Expand Down Expand Up @@ -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()
Expand All @@ -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,
Expand Down Expand Up @@ -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"
)
);
}
}
Loading
Loading