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
3 changes: 2 additions & 1 deletion crates/libsy-llm-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/libsy-llm-client/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>,
/// Default top-level request fields, applied only when the request omits the key.
pub extra_body: BTreeMap<String, Value>,
Expand Down
25 changes: 19 additions & 6 deletions crates/libsy-llm-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"));
Expand All @@ -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),
Expand All @@ -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(())
}

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 @@ -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`. |
Expand Down
Loading