Skip to content

Commit 50ffc2d

Browse files
committed
fix(client): prevent duplicate authentication headers
Signed-off-by: Elyas Mehtabuddin <emehtabuddin@nvidia.com>
1 parent 58f355a commit 50ffc2d

5 files changed

Lines changed: 106 additions & 7 deletions

File tree

crates/libsy-llm-client/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,11 @@ fn build_multi_format_client(
212212
`host`, `content-length`, `connection`, and the backend-owned
213213
`authorization` / `x-api-key` / `anthropic-version` / `content-type`. So a
214214
caller's placeholder credential never overrides the backend's real key.
215-
- Per-backend static headers go in `HttpBackendConfig::extra_headers`.
215+
- Per-backend custom headers go in `HttpBackendConfig::extra_headers`. Set credentials with
216+
`api_key`. For OpenAI requests, the client ignores `Authorization` in `extra_headers`
217+
and sends the key from `api_key` once. For Anthropic requests, it ignores `x-api-key`
218+
and `anthropic-version` in `extra_headers`, then sends the key from `api_key` and the
219+
required Anthropic version. Header names are matched without regard to letter case.
216220
- Per-target top-level request defaults go in `HttpBackendConfig::extra_body`.
217221
The merge is shallow and fields already present in the request take precedence.
218222
- `HttpBackendConfig::max_retries` controls additional attempts after retryable

crates/libsy-llm-client/src/backend.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,11 @@ pub struct HttpBackendConfig {
4242
pub base_url: String,
4343
/// API key for the provider, loaded by the caller. `None` sends no auth.
4444
pub api_key: Option<String>,
45-
/// Static headers added to every outbound call to this backend.
45+
/// Custom headers added to every outbound call to this backend.
46+
///
47+
/// OpenAI requests ignore `Authorization` here. Anthropic requests ignore
48+
/// `x-api-key` and `anthropic-version` here. Use `api_key` for credentials;
49+
/// the client sets Anthropic's required version itself.
4650
pub extra_headers: BTreeMap<String, String>,
4751
/// Default top-level request fields, applied only when the request omits the key.
4852
pub extra_body: BTreeMap<String, Value>,
@@ -130,7 +134,20 @@ impl Backend {
130134
builder
131135
}
132136

133-
/// Static per-backend headers to forward on every call.
137+
/// Whether this backend ignores `name` when reading `extra_headers`.
138+
pub(crate) fn ignores_extra_header(&self, name: &str) -> bool {
139+
match self {
140+
Backend::OpenAiChat(_) | Backend::OpenAiResponses(_) => {
141+
name.eq_ignore_ascii_case("authorization")
142+
}
143+
Backend::Anthropic(_) => {
144+
name.eq_ignore_ascii_case("x-api-key")
145+
|| name.eq_ignore_ascii_case("anthropic-version")
146+
}
147+
}
148+
}
149+
150+
/// Custom per-backend headers to forward on every call.
134151
pub fn extra_headers(&self) -> &BTreeMap<String, String> {
135152
&self.config().extra_headers
136153
}

crates/libsy-llm-client/src/client.rs

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -281,8 +281,7 @@ impl TranslatingLlmClient {
281281
) -> std::result::Result<EncodedResponse, AttemptFailure> {
282282
let builder = self.client.post(url).json(body);
283283
let builder = forward_metadata_headers(builder, metadata);
284-
let builder = apply_extra_headers(builder, backend);
285-
let builder = backend.apply_auth(builder);
284+
let builder = apply_backend_headers(builder, backend);
286285

287286
let response = match builder.send().await {
288287
Ok(response) => response,
@@ -643,14 +642,22 @@ fn forward_metadata_headers(
643642
builder
644643
}
645644

646-
// Adds the backend's static per-call headers.
645+
// Adds custom headers but skips OpenAI auth and Anthropic auth/version headers.
647646
fn apply_extra_headers(mut builder: RequestBuilder, backend: &Backend) -> RequestBuilder {
648647
for (name, value) in backend.extra_headers() {
648+
if backend.ignores_extra_header(name) {
649+
continue;
650+
}
649651
builder = builder.header(name, value);
650652
}
651653
builder
652654
}
653655

656+
// Builds one header set without duplicating OpenAI or Anthropic authentication headers.
657+
fn apply_backend_headers(builder: RequestBuilder, backend: &Backend) -> RequestBuilder {
658+
backend.apply_auth(apply_extra_headers(builder, backend))
659+
}
660+
654661
// Overwrites the outbound body's `model` field with the resolved model id.
655662
fn set_json_model(body: &mut Value, model: &str) {
656663
if let Value::Object(object) = body {
@@ -819,6 +826,14 @@ mod tests {
819826
}
820827
}
821828

829+
fn header_values<'a>(headers: &'a HeaderMap, name: &str) -> Vec<&'a str> {
830+
headers
831+
.get_all(name)
832+
.iter()
833+
.map(|value| value.to_str().expect("header value should be text"))
834+
.collect()
835+
}
836+
822837
fn config_with_retries(base_url: &str, max_retries: u32) -> HttpBackendConfig {
823838
HttpBackendConfig {
824839
max_retries,
@@ -1724,6 +1739,56 @@ mod tests {
17241739
Ok(())
17251740
}
17261741

1742+
#[test]
1743+
fn extra_headers_do_not_duplicate_auth_headers()
1744+
-> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
1745+
let extra_headers = BTreeMap::from([
1746+
("AUTHORIZATION".to_string(), "Bearer injected".to_string()),
1747+
("X-Api-Key".to_string(), "injected".to_string()),
1748+
("ANTHROPIC-VERSION".to_string(), "injected".to_string()),
1749+
("X-Inference-Priority".to_string(), "batch".to_string()),
1750+
]);
1751+
let mut openai_config = config("https://example.test/v1");
1752+
openai_config.extra_headers = extra_headers.clone();
1753+
let openai = Backend::OpenAiChat(openai_config);
1754+
let request =
1755+
apply_backend_headers(reqwest::Client::new().post(openai.url()), &openai).build()?;
1756+
assert_eq!(
1757+
header_values(request.headers(), "authorization"),
1758+
["Bearer secret"]
1759+
);
1760+
assert_eq!(header_values(request.headers(), "x-api-key"), ["injected"]);
1761+
assert_eq!(
1762+
header_values(request.headers(), "anthropic-version"),
1763+
["injected"]
1764+
);
1765+
assert_eq!(
1766+
header_values(request.headers(), "x-inference-priority"),
1767+
["batch"]
1768+
);
1769+
1770+
let mut anthropic_config = config("https://example.test");
1771+
anthropic_config.extra_headers = extra_headers;
1772+
let anthropic = Backend::Anthropic(anthropic_config);
1773+
let request =
1774+
apply_backend_headers(reqwest::Client::new().post(anthropic.url()), &anthropic)
1775+
.build()?;
1776+
assert_eq!(
1777+
header_values(request.headers(), "authorization"),
1778+
["Bearer injected"]
1779+
);
1780+
assert_eq!(header_values(request.headers(), "x-api-key"), ["secret"]);
1781+
assert_eq!(
1782+
header_values(request.headers(), "anthropic-version"),
1783+
["2023-06-01"]
1784+
);
1785+
assert_eq!(
1786+
header_values(request.headers(), "x-inference-priority"),
1787+
["batch"]
1788+
);
1789+
Ok(())
1790+
}
1791+
17271792
#[tokio::test]
17281793
async fn forwards_metadata_headers_except_reserved()
17291794
-> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {

crates/switchyard-server/src/config.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1459,6 +1459,19 @@ target = "azure"
14591459
Ok(())
14601460
}
14611461

1462+
#[test]
1463+
fn auth_extra_headers_do_not_block_config_loading() -> ServerResult<()> {
1464+
let configured = VALID_CONFIG.replacen(
1465+
"base_url = \"https://example.test/v1\"",
1466+
"base_url = \"https://example.test/v1\"\n\
1467+
extra_headers = { Authorization = \"injected\", X-Inference-Priority = \"batch\" }",
1468+
1,
1469+
);
1470+
1471+
server_state_from_toml(&configured)?;
1472+
Ok(())
1473+
}
1474+
14621475
#[test]
14631476
fn retry_budget_defaults_and_accepts_an_override() -> ServerResult<()> {
14641477
let default: ServerConfig = toml::from_str(VALID_CONFIG).map_err(|error| {

docs/reference/toml_schema.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ route reaches no upstream. A file without a `[targets]` table is rejected with
4545
| `format` | Yes || `openai_chat`, `openai_responses`, or `anthropic_messages`. |
4646
| `base_url` | Yes || Upstream base URL. |
4747
| `api_key_env` | No | unset | Name of the environment variable holding the key. Omit to send no authentication. |
48-
| `extra_headers` | No | `{}` | Extra HTTP headers sent upstream. |
48+
| `extra_headers` | No | `{}` | Custom HTTP headers sent to the model server. Set credentials with `api_key_env`. For OpenAI requests, the client ignores `Authorization` here and sends the configured key once. For Anthropic requests, it ignores `x-api-key` and `anthropic-version` here, then sends the configured key and required Anthropic version. Header names are matched without regard to letter case. |
4949
| `max_retries` | No | `2` | Retry budget, `0``10`. |
5050

5151
The TOML never contains the secret itself. `api_key_env` names a variable that

0 commit comments

Comments
 (0)