diff --git a/crates/libsy-llm-client/README.md b/crates/libsy-llm-client/README.md index 30fef3a4..f2bfb7a9 100644 --- a/crates/libsy-llm-client/README.md +++ b/crates/libsy-llm-client/README.md @@ -233,7 +233,9 @@ 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. -- Per-backend static headers go in `HttpBackendConfig::extra_headers`. +- 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. - Per-target top-level request defaults go in `HttpBackendConfig::extra_body`. The merge is shallow and fields already present in the request take precedence. - `HttpBackendConfig::max_retries` controls additional attempts after retryable diff --git a/crates/libsy-llm-client/src/backend.rs b/crates/libsy-llm-client/src/backend.rs index 95de2341..99323b8f 100644 --- a/crates/libsy-llm-client/src/backend.rs +++ b/crates/libsy-llm-client/src/backend.rs @@ -9,7 +9,7 @@ use reqwest::RequestBuilder; use serde_json::Value; use switchyard_protocol::WireFormat; -use crate::error::is_overflow_body; +use crate::error::{LlmClientError, Result, is_overflow_body}; const ANTHROPIC_VERSION: &str = "2023-06-01"; @@ -42,7 +42,10 @@ pub struct HttpBackendConfig { pub base_url: String, /// API key for the provider, loaded by the caller. `None` sends no auth. pub api_key: Option, - /// Static headers added to every outbound call to this backend. + /// 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. pub extra_headers: BTreeMap, /// Default top-level request fields, applied only when the request omits the key. pub extra_body: BTreeMap, @@ -77,6 +80,27 @@ pub enum Backend { } impl Backend { + // Checks custom headers before the client can send a request. + pub(crate) fn validate_extra_headers(&self, model_name: &str) -> Result<()> { + let invalid_name = self.config().extra_headers.keys().find(|name| match self { + Backend::OpenAiChat(_) | Backend::OpenAiResponses(_) => { + name.eq_ignore_ascii_case("authorization") + } + Backend::Anthropic(_) => { + name.eq_ignore_ascii_case("x-api-key") + || name.eq_ignore_ascii_case("anthropic-version") + } + }); + if let Some(name) = invalid_name { + return Err(LlmClientError::Configuration { + message: format!( + "model {model_name:?} extra_headers cannot set {name:?}; extra_headers is only for additional headers" + ), + }); + } + Ok(()) + } + /// The wire format the request IR is encoded to for this backend. pub fn wire_format(&self) -> WireFormat { match self { @@ -130,7 +154,7 @@ impl Backend { builder } - /// Static per-backend headers to forward on every call. + /// Custom per-backend headers to forward on every call. pub fn extra_headers(&self) -> &BTreeMap { &self.config().extra_headers } diff --git a/crates/libsy-llm-client/src/client.rs b/crates/libsy-llm-client/src/client.rs index fc3b4b3b..61a2bf0a 100644 --- a/crates/libsy-llm-client/src/client.rs +++ b/crates/libsy-llm-client/src/client.rs @@ -94,6 +94,14 @@ impl TranslatingLlmClient { /// Builds a client over the given [`ModelConfig`]s, with a fresh shared HTTP /// client and the built-in translation codecs. pub fn new(model_configs: &[ModelConfig]) -> Result { + for config in model_configs { + config + .default_backend + .validate_extra_headers(&config.model_name)?; + for backend in config.other_backends.iter().flatten() { + backend.validate_extra_headers(&config.model_name)?; + } + } let client = reqwest::Client::builder() .build() @@ -640,7 +648,7 @@ fn forward_metadata_headers( builder } -// Adds the backend's static per-call headers. +// Adds the backend's custom per-call headers. fn apply_extra_headers(mut builder: RequestBuilder, backend: &Backend) -> RequestBuilder { for (name, value) in backend.extra_headers() { builder = builder.header(name, value); diff --git a/crates/switchyard-server/src/config.rs b/crates/switchyard-server/src/config.rs index 83b4f3d9..d2e59fa1 100644 --- a/crates/switchyard-server/src/config.rs +++ b/crates/switchyard-server/src/config.rs @@ -1478,6 +1478,52 @@ target = "azure" Ok(()) } + #[test] + fn rejects_headers_that_switchyard_sets() { + let cases = [ + ( + "base_url = \"https://example.test/v1\"", + "base_url = \"https://example.test/v1\"\n\ + extra_headers = { AUTHORIZATION = \"Bearer custom-key\" }", + "AUTHORIZATION", + ), + ( + "base_url = \"https://example.test\"", + "base_url = \"https://example.test\"\n\ + extra_headers = { \"X-Api-Key\" = \"custom-key\" }", + "X-Api-Key", + ), + ( + "base_url = \"https://example.test\"", + "base_url = \"https://example.test\"\n\ + extra_headers = { \"ANTHROPIC-VERSION\" = \"custom-version\" }", + "ANTHROPIC-VERSION", + ), + ]; + + for (original, replacement, header) in cases { + let configured = VALID_CONFIG.replacen(original, replacement, 1); + let error = error_message(&configured); + assert!( + error.contains(&format!("extra_headers cannot set {header:?}")), + "expected {header} to be rejected, got: {error}" + ); + } + } + + #[test] + fn accepts_additional_headers() -> ServerResult<()> { + let configured = VALID_CONFIG.replacen( + "base_url = \"https://example.test/v1\"", + "base_url = \"https://example.test/v1\"\n\ + extra_headers = { X-Inference-Priority = \"batch\" }", + 1, + ); + + server_state_from_toml(&configured)?; + Ok(()) + } + #[test] fn retry_budget_rejects_negative_values() { let invalid = VALID_CONFIG.replacen( diff --git a/docs/reference/toml_schema.md b/docs/reference/toml_schema.md index 6caa71fd..6db279d8 100644 --- a/docs/reference/toml_schema.md +++ b/docs/reference/toml_schema.md @@ -45,7 +45,7 @@ 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 | `{}` | Extra HTTP headers sent 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