Skip to content
Open
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
70 changes: 65 additions & 5 deletions crates/libsy-llm-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,11 @@ use crate::error::{LlmClientError, Result};
use crate::metrics;
use crate::raw::RawResponse;

// TODO: Why is this here? What does it do?
// Headers this client owns or that are hop-by-hop; never forwarded from the
// caller's metadata. Auth/version/content-type are set by the backend or the
// JSON body, so a forwarded copy would either be ignored or conflict. Compared
// case-insensitively. Aligns with `_SENSITIVE_HEADERS` in the Python
// `switchyard/lib/request_metadata.py` forwarding logic.
// caller's metadata. Auth, credential, and tenant-selector headers are set by
// the backend (via `apply_auth` or configured `extra_headers`), so a forwarded
// client copy would either be ignored or conflict with, and in some cases
// override, the backend's own value. Compared case-insensitively.
const RESERVED_HEADERS: &[&str] = &[
"host",
"content-length",
Expand All @@ -42,6 +41,13 @@ const RESERVED_HEADERS: &[&str] = &[
"cookie",
"set-cookie",
"x-api-key",
// `api-key` is Azure OpenAI's credential header (the `x-`-less sibling of
// `x-api-key`); `openai-organization` and `openai-project` select the
// billing tenant for the backend's key. A caller must not be able to set
// any of these on the upstream request.
"api-key",
"openai-organization",
"openai-project",
"anthropic-beta",
"anthropic-version",
"content-type",
Expand Down Expand Up @@ -1813,6 +1819,60 @@ mod tests {
Ok(())
}

#[tokio::test]
async fn strips_client_credential_and_tenant_headers()
-> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
let server = MockServer::start().await;
Mock::given(method("POST"))
// The backend bearer is preserved and none of the client's
// credential or tenant-selector headers reach the upstream.
.and(wiremock::matchers::header("authorization", "Bearer secret"))
.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;

let mut headers = http::HeaderMap::new();
headers.insert(
"api-key",
http::HeaderValue::from_static("client-azure-key"),
);
headers.insert(
"openai-organization",
http::HeaderValue::from_static("org-client"),
);
headers.insert(
"openai-project",
http::HeaderValue::from_static("proj-client"),
);
let request = Request {
llm_request: LlmRequest {
model: Some("gpt".to_string()),
..LlmRequest::default()
},
raw_request: None,
metadata: Some(Metadata {
http_headers: Some(headers),
..Default::default()
}),
};

let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
client.call_rewrite_model(request, None).await?;
let received = server
.received_requests()
.await
.ok_or("request recording should be enabled")?;
let received = received.first().ok_or("expected one upstream request")?;
assert!(!received.headers.contains_key("api-key"));
assert!(!received.headers.contains_key("openai-organization"));
assert!(!received.headers.contains_key("openai-project"));
Ok(())
}

// Exercises the `RoutedLlmClient` impl: `call` uses the model already materialized in the
// request and round-trips a buffered response.
#[tokio::test]
Expand Down