Skip to content
Open
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
7 changes: 6 additions & 1 deletion crates/libsy-llm-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,12 @@ 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`. Setting `api_key`
together with `Authorization` (OpenAI) or `x-api-key` (Anthropic) creates a conflict:
both settings define the same outgoing authentication header. The client resolves that
conflict in favor of `api_key` and does not send the custom value. Without `api_key`, it
sends the custom authentication header. Anthropic requests always use the client's
required `anthropic-version`. Header names are matched without regard to letter case.
- 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
23 changes: 21 additions & 2 deletions crates/libsy-llm-client/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@ 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.
///
/// Setting `api_key` together with `Authorization` (OpenAI) or `x-api-key`
/// (Anthropic) here creates a conflict. The client sends the value from
/// `api_key` and omits the conflicting custom value. Anthropic requests
/// always use the client's required version.
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 @@ -130,7 +135,21 @@ impl Backend {
builder
}

/// Static per-backend headers to forward on every call.
/// Whether to send `name` from `extra_headers` for this backend.
pub(crate) fn should_send_extra_header(&self, name: &str) -> bool {
let has_api_key = self.config().api_key.is_some();
match self {
Backend::OpenAiChat(_) | Backend::OpenAiResponses(_) => {
!(has_api_key && name.eq_ignore_ascii_case("authorization"))
}
Backend::Anthropic(_) => {
!(name.eq_ignore_ascii_case("anthropic-version")
|| has_api_key && name.eq_ignore_ascii_case("x-api-key"))
}
}
}

/// Custom per-backend headers to forward on every call.
pub fn extra_headers(&self) -> &BTreeMap<String, String> {
&self.config().extra_headers
}
Expand Down
86 changes: 85 additions & 1 deletion crates/libsy-llm-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -643,9 +643,12 @@ fn forward_metadata_headers(
builder
}

// Adds the backend's static per-call headers.
// Adds custom headers unless the backend will set the same header.
fn apply_extra_headers(mut builder: RequestBuilder, backend: &Backend) -> RequestBuilder {
for (name, value) in backend.extra_headers() {
if !backend.should_send_extra_header(name) {
continue;
}
builder = builder.header(name, value);
}
builder
Expand Down Expand Up @@ -1751,6 +1754,87 @@ mod tests {
Ok(())
}

#[tokio::test]
async fn api_key_takes_priority_over_matching_extra_header()
-> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "1", "model": "gpt",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
"usage": {}
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/messages"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "msg_1", "type": "message", "role": "assistant", "model": "claude",
"content": [{"type": "text", "text": "ok"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 1, "output_tokens": 1}
})))
.mount(&server)
.await;

let openai_headers = BTreeMap::from([
("AUTHORIZATION".to_string(), "Bearer extra-key".to_string()),
("X-Inference-Priority".to_string(), "batch".to_string()),
]);
for api_key in [Some("secret"), None] {
let mut backend = config(&format!("{}/v1", server.uri()));
backend.api_key = api_key.map(str::to_string);
backend.extra_headers = openai_headers.clone();
TranslatingLlmClient::new(&[ModelConfig::new(
"gpt",
Backend::OpenAiChat(backend),
None,
)])?
.call_rewrite_model(request_for(Some("gpt"), false), None)
.await?;
}

let anthropic_headers = BTreeMap::from([
("X-Api-Key".to_string(), "extra-key".to_string()),
(
"ANTHROPIC-VERSION".to_string(),
"custom-version".to_string(),
),
]);
for api_key in [Some("secret"), None] {
let mut backend = config(&server.uri());
backend.api_key = api_key.map(str::to_string);
backend.extra_headers = anthropic_headers.clone();
TranslatingLlmClient::new(&[ModelConfig::new(
"claude",
Backend::Anthropic(backend),
None,
)])?
.call_rewrite_model(request_for(Some("claude"), false), None)
.await?;
}

let requests = server
.received_requests()
.await
.ok_or("request recording should be enabled")?;
assert_eq!(requests.len(), 4);
assert_eq!(
requests[0].headers.get_all("authorization").iter().count(),
1
);
assert_eq!(requests[0].headers["authorization"], "Bearer secret");
assert_eq!(requests[0].headers["x-inference-priority"], "batch");
assert_eq!(requests[1].headers["authorization"], "Bearer extra-key");
assert_eq!(requests[2].headers.get_all("x-api-key").iter().count(), 1);
assert_eq!(requests[2].headers["x-api-key"], "secret");
assert_eq!(requests[2].headers["anthropic-version"], "2023-06-01");
assert_eq!(requests[3].headers["x-api-key"], "extra-key");
assert_eq!(requests[3].headers["anthropic-version"], "2023-06-01");
Ok(())
}

#[tokio::test]
async fn forwards_metadata_headers_except_reserved()
-> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
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. Setting `api_key_env` together with `Authorization` (OpenAI) or `x-api-key` (Anthropic) creates a conflict because both settings define the same outgoing authentication header. The client sends the API key loaded from `api_key_env` and does not send the conflicting custom value. Without `api_key_env`, the client sends the custom authentication header. Anthropic requests always use the client's required `anthropic-version`. Header names are matched without regard to letter case. |
| `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