Skip to content

Commit 37ccc8d

Browse files
committed
fix(config): reject backend-owned extra headers
Signed-off-by: Elyas Mehtabuddin <emehtabuddin@nvidia.com>
1 parent 58f355a commit 37ccc8d

5 files changed

Lines changed: 104 additions & 4 deletions

File tree

crates/libsy-llm-client/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,9 @@ 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`; the client rejects `Authorization`, `x-api-key`, and `anthropic-version`
217+
case-insensitively because the backend sets those headers.
216218
- Per-target top-level request defaults go in `HttpBackendConfig::extra_body`.
217219
The merge is shallow and fields already present in the request take precedence.
218220
- `HttpBackendConfig::max_retries` controls additional attempts after retryable

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

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ use reqwest::RequestBuilder;
99
use serde_json::Value;
1010
use switchyard_protocol::WireFormat;
1111

12-
use crate::error::is_overflow_body;
12+
use crate::error::{LlmClientError, Result, is_overflow_body};
1313

1414
const ANTHROPIC_VERSION: &str = "2023-06-01";
15+
const RESERVED_EXTRA_HEADERS: &[&str] = &["authorization", "x-api-key", "anthropic-version"];
1516

1617
/// Default number of retries for server-configured upstream calls.
1718
pub const DEFAULT_MAX_RETRIES: u32 = 2;
@@ -42,7 +43,10 @@ pub struct HttpBackendConfig {
4243
pub base_url: String,
4344
/// API key for the provider, loaded by the caller. `None` sends no auth.
4445
pub api_key: Option<String>,
45-
/// Static headers added to every outbound call to this backend.
46+
/// Custom headers added to every outbound call to this backend.
47+
///
48+
/// Authentication and protocol headers are set by the backend and are
49+
/// rejected when the client is built.
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>,
@@ -77,6 +81,22 @@ pub enum Backend {
7781
}
7882

7983
impl Backend {
84+
// Rejects backend-owned headers before building any request.
85+
pub(crate) fn validate_extra_headers(&self, model_name: &str) -> Result<()> {
86+
if let Some(name) = self.config().extra_headers.keys().find(|name| {
87+
RESERVED_EXTRA_HEADERS
88+
.iter()
89+
.any(|reserved| name.eq_ignore_ascii_case(reserved))
90+
}) {
91+
return Err(LlmClientError::Configuration {
92+
message: format!(
93+
"model {model_name:?} extra_headers cannot set reserved header {name:?}; the backend sets authentication and protocol headers"
94+
),
95+
});
96+
}
97+
Ok(())
98+
}
99+
80100
/// The wire format the request IR is encoded to for this backend.
81101
pub fn wire_format(&self) -> WireFormat {
82102
match self {

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,14 @@ impl TranslatingLlmClient {
9494
/// Builds a client over the given [`ModelConfig`]s, with a fresh shared HTTP
9595
/// client and the built-in translation codecs.
9696
pub fn new(model_configs: &[ModelConfig]) -> Result<Self> {
97+
for config in model_configs {
98+
config
99+
.default_backend
100+
.validate_extra_headers(&config.model_name)?;
101+
for backend in config.other_backends.iter().flatten() {
102+
backend.validate_extra_headers(&config.model_name)?;
103+
}
104+
}
97105
let client =
98106
reqwest::Client::builder()
99107
.build()
@@ -956,6 +964,30 @@ mod tests {
956964
request
957965
}
958966

967+
#[test]
968+
fn client_rejects_reserved_headers_on_alternate_backends() {
969+
for header in ["Authorization", "X-Api-Key", "ANTHROPIC-VERSION"] {
970+
let mut alternate = config("https://example.test");
971+
alternate
972+
.extra_headers
973+
.insert(header.to_string(), "injected".to_string());
974+
let models = [ModelConfig::new(
975+
"gpt",
976+
Backend::OpenAiChat(config("https://example.test/v1")),
977+
Some(vec![Backend::Anthropic(alternate)]),
978+
)];
979+
980+
let Err(error) = TranslatingLlmClient::new(&models) else {
981+
panic!("expected {header} to be rejected");
982+
};
983+
assert!(matches!(
984+
error,
985+
LlmClientError::Configuration { message }
986+
if message.contains(&format!("reserved header {header:?}"))
987+
));
988+
}
989+
}
990+
959991
#[tokio::test]
960992
async fn missing_model_errors()
961993
-> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {

crates/switchyard-server/src/config.rs

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

1462+
#[test]
1463+
fn backend_owned_extra_headers_are_rejected_case_insensitively() {
1464+
let cases = [
1465+
(
1466+
"base_url = \"https://example.test/v1\"",
1467+
"base_url = \"https://example.test/v1\"\n\
1468+
extra_headers = { Authorization = \"injected\" }",
1469+
"Authorization",
1470+
),
1471+
(
1472+
"base_url = \"https://example.test\"",
1473+
"base_url = \"https://example.test\"\n\
1474+
extra_headers = { \"X-Api-Key\" = \"injected\" }",
1475+
"X-Api-Key",
1476+
),
1477+
(
1478+
"base_url = \"https://example.test\"",
1479+
"base_url = \"https://example.test\"\n\
1480+
extra_headers = { \"ANTHROPIC-VERSION\" = \"injected\" }",
1481+
"ANTHROPIC-VERSION",
1482+
),
1483+
];
1484+
1485+
for (original, replacement, header) in cases {
1486+
let configured = VALID_CONFIG.replacen(original, replacement, 1);
1487+
let error = error_message(&configured);
1488+
assert!(
1489+
error.contains(&format!("reserved header {header:?}")),
1490+
"expected {header} to be rejected, got: {error}"
1491+
);
1492+
}
1493+
}
1494+
1495+
#[test]
1496+
fn custom_extra_headers_remain_valid() -> ServerResult<()> {
1497+
let configured = VALID_CONFIG.replacen(
1498+
"base_url = \"https://example.test/v1\"",
1499+
"base_url = \"https://example.test/v1\"\n\
1500+
extra_headers = { X-Inference-Priority = \"batch\" }",
1501+
1,
1502+
);
1503+
1504+
server_state_from_toml(&configured)?;
1505+
Ok(())
1506+
}
1507+
14621508
#[test]
14631509
fn retry_budget_defaults_and_accepts_an_override() -> ServerResult<()> {
14641510
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 upstream. Set credentials with `api_key_env`; `Authorization`, `x-api-key`, and `anthropic-version` are rejected case-insensitively because the backend sets them. |
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)