From 414e2e14963cb0d2e7eeb9136223b05be20bd560 Mon Sep 17 00:00:00 2001 From: nachiketb Date: Tue, 10 Nov 2026 09:21:12 -0800 Subject: [PATCH] fix(client): give configured extra headers precedence Signed-off-by: nachiketb --- crates/libsy-llm-client/README.md | 3 ++- crates/libsy-llm-client/src/backend.rs | 1 + crates/libsy-llm-client/src/client.rs | 25 +++++++++++++++++++------ docs/reference/toml_schema.md | 2 +- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/crates/libsy-llm-client/README.md b/crates/libsy-llm-client/README.md index dee3a8081b..b3a60517f9 100644 --- a/crates/libsy-llm-client/README.md +++ b/crates/libsy-llm-client/README.md @@ -257,7 +257,8 @@ fn build_multi_format_client( Headers owned by other providers are preserved as application 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. + and `anthropic-version`. Header names are case-insensitive. Configured values replace + caller values for the same header name. - Per-target top-level request defaults go in `HttpBackendConfig::extra_body`. The merge is shallow and fields already present in the request take precedence. - Anthropic reconstruction preserves the caller's native `thinking` settings, including diff --git a/crates/libsy-llm-client/src/backend.rs b/crates/libsy-llm-client/src/backend.rs index 7e41b502fc..c346bedb3b 100644 --- a/crates/libsy-llm-client/src/backend.rs +++ b/crates/libsy-llm-client/src/backend.rs @@ -68,6 +68,7 @@ pub struct HttpBackendConfig { /// Provider-owned headers are rejected so a static value cannot replace /// configured or forwarded auth. Names and values must be valid HTTP header bytes; /// header names are case-insensitive. + /// Configured values replace caller values for the same header name. pub extra_headers: BTreeMap, /// Default top-level request fields, applied only when the request omits the key. pub extra_body: BTreeMap, diff --git a/crates/libsy-llm-client/src/client.rs b/crates/libsy-llm-client/src/client.rs index 7962f1ec5b..66cffe0641 100644 --- a/crates/libsy-llm-client/src/client.rs +++ b/crates/libsy-llm-client/src/client.rs @@ -14,7 +14,7 @@ use async_trait::async_trait; use futures_util::{StreamExt, stream}; use http::StatusCode; use reqwest::RequestBuilder; -use reqwest::header::{HeaderMap, RETRY_AFTER}; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue, RETRY_AFTER}; use serde_json::{Map, Value, json}; use switchyard_protocol::{ LlmRequest, LlmResponse, LlmResponseChunk, LlmResponseStream, LlmResponseStreamEvent, Metadata, @@ -1099,12 +1099,16 @@ fn forward_metadata_headers( builder.headers(forwarded) } -// Adds the backend's custom per-call headers. -fn apply_extra_headers(mut builder: RequestBuilder, backend: &Backend) -> RequestBuilder { +// Configured headers replace existing values for the same case-insensitive name. +fn apply_extra_headers(builder: RequestBuilder, backend: &Backend) -> RequestBuilder { + let mut headers = HeaderMap::with_capacity(backend.extra_headers().len()); + // Names and values are validated by TranslatingLlmClient::new. for (name, value) in backend.extra_headers() { - builder = builder.header(name, value); + let name = HeaderName::from_bytes(name.as_bytes()).expect("validated header name"); + let value = HeaderValue::from_bytes(value.as_bytes()).expect("validated header value"); + headers.insert(name, value); } - builder + builder.headers(headers) } // Overwrites the outbound body's `model` field with the resolved model id. @@ -3768,6 +3772,8 @@ mod tests { "x-goog-api-key", http::HeaderValue::from_static("client-google-key"), ); + headers.append("x-custom", http::HeaderValue::from_static("caller-first")); + headers.append("x-custom", http::HeaderValue::from_static("caller-second")); headers.insert("host", http::HeaderValue::from_static("client.example")); headers.insert("content-type", http::HeaderValue::from_static("text/plain")); headers.insert("connection", http::HeaderValue::from_static("x-hop-by-hop")); @@ -3776,7 +3782,11 @@ mod tests { http::HeaderValue::from_static("client-only"), ); let request = request_with_headers("gpt", headers); - let backend_config = forwarding_config(&format!("{}/v1", server.uri())); + let mut backend_config = forwarding_config(&format!("{}/v1", server.uri())); + backend_config.extra_headers = BTreeMap::from([ + ("X-Custom".into(), "configured".into()), + ("X-Configured-Only".into(), "static".into()), + ]); let client = TranslatingLlmClient::new(&[ModelConfig::new( "gpt", Backend::OpenAiChat(backend_config), @@ -3793,6 +3803,9 @@ mod tests { assert_eq!(received.headers["content-type"], "application/json"); assert!(!received.headers.contains_key("connection")); assert!(!received.headers.contains_key("x-hop-by-hop")); + assert_eq!(received.headers.get_all("x-custom").iter().count(), 1); + assert_eq!(received.headers["x-custom"], "configured"); + assert_eq!(received.headers["x-configured-only"], "static"); Ok(()) } diff --git a/docs/reference/toml_schema.md b/docs/reference/toml_schema.md index 1f2772ff9e..39bcc28932 100644 --- a/docs/reference/toml_schema.md +++ b/docs/reference/toml_schema.md @@ -53,7 +53,7 @@ route reaches no upstream. A file without a `[targets]` table is rejected with | `context_discovery` | No | unset | Set to `"llama_cpp"` to enable `/props` context discovery for VGR efficient targets. Disabled when unset. An explicit route `context_window` takes precedence. | | `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 provider credential and application headers. A route's forwarding clients must use one credential family unless they all use the same scheme, host, and port, such as one LLM gateway. | -| `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. | +| `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. Configured values replace caller values for the same name. | | `responses_reasoning` | No | `"auto"` | Responses reasoning replay: `"auto"`, `"preserve_encrypted"`, `"preserve_plaintext"`, or `"drop"`. Only valid for `openai_responses`. | | `fallback_on_rate_limit` | No | `true` | Try another completion candidate after an upstream HTTP 429 exhausts retries. Set to `false` to return the 429 instead. | | `max_retries` | No | `2` | Retry budget, `0`–`10`. |