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
10 changes: 8 additions & 2 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 @@ -231,8 +232,13 @@ fn build_multi_format_client(
Anthropic sends `x-api-key: <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.
Expand Down
127 changes: 119 additions & 8 deletions crates/libsy-llm-client/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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<String>,
/// 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<String, String>,
/// Default top-level request fields, applied only when the request omits the key.
pub extra_body: BTreeMap<String, Value>,
Expand All @@ -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()
Expand All @@ -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 {
Expand Down Expand Up @@ -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 <key>`; Anthropic uses
/// `x-api-key: <key>` plus the required `anthropic-version` header.
/// `x-api-key: <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 {
Expand All @@ -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<String, String> {
&self.config().extra_headers
Expand Down Expand Up @@ -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<HeaderValue> {
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(",");
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
Expand Down Expand Up @@ -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,
Expand Down
40 changes: 26 additions & 14 deletions crates/libsy-llm-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -88,6 +86,7 @@ impl ModelConfig {
pub struct TranslatingLlmClient {
model_to_config: HashMap<ModelId, ModelConfig>,
client: reqwest::Client,
forward_auth_client: reqwest::Client,
}

impl TranslatingLlmClient {
Expand All @@ -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()))
Expand All @@ -116,6 +119,7 @@ impl TranslatingLlmClient {
Ok(Self {
model_to_config,
client,
forward_auth_client,
})
}

Expand Down Expand Up @@ -286,8 +290,14 @@ impl TranslatingLlmClient {
model: &ModelId,
streaming: bool,
) -> std::result::Result<EncodedResponse, AttemptFailure> {
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);

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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>,
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 22 additions & 2 deletions crates/switchyard-py/src/server_bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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<String, Option<&'static str>>,
shutdown: Option<oneshot::Sender<()>>,
completion: Option<Receiver<ServerResult<()>>>,
task: Option<JoinHandle<()>>,
Expand All @@ -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<Self> {
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::<ServerResult<HashMap<_, _>>>()
.map_err(server_error)?;
let runtime = pyo3_async_runtimes::tokio::get_runtime();
let server = {
let _guard = runtime.enter();
Expand Down Expand Up @@ -65,6 +76,7 @@ impl PyServer {
});
Ok(Self {
addr,
caller_auth_by_model,
shutdown: Some(shutdown),
completion: Some(completion),
task: Some(task),
Expand All @@ -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<Option<&'static str>> {
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)
}
Expand Down
6 changes: 6 additions & 0 deletions crates/switchyard-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading