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
4 changes: 3 additions & 1 deletion crates/libsy-llm-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 27 additions & 3 deletions crates/libsy-llm-client/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<String>,
/// 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<String, String>,
/// Default top-level request fields, applied only when the request omits the key.
pub extra_body: BTreeMap<String, Value>,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<String, String> {
&self.config().extra_headers
}
Expand Down
10 changes: 9 additions & 1 deletion crates/libsy-llm-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self> {
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()
Expand Down Expand Up @@ -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);
Expand Down
46 changes: 46 additions & 0 deletions crates/switchyard-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/toml_schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading