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
20 changes: 5 additions & 15 deletions crates/openshell-router/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,24 +79,14 @@ async fn send_backend_request(
}
}

// Collect header names we need to strip (auth, host, and any default header
// names that will be set from route defaults).
let strip_headers: Vec<String> = {
let mut s = vec![
"authorization".to_string(),
"x-api-key".to_string(),
"host".to_string(),
];
for (name, _) in &route.default_headers {
s.push(name.to_ascii_lowercase());
}
s
};
// Strip auth and host headers — auth is re-injected above from the route
// config, and host must match the upstream.
let strip_headers: [&str; 3] = ["authorization", "x-api-key", "host"];

// Forward non-sensitive headers (skip auth, host, and any we'll override)
// Forward non-sensitive headers.
for (name, value) in &headers {
let name_lc = name.to_ascii_lowercase();
if strip_headers.contains(&name_lc) {
if strip_headers.contains(&name_lc.as_str()) {
continue;
}
builder = builder.header(name.as_str(), value.as_str());
Expand Down
59 changes: 59 additions & 0 deletions crates/openshell-router/tests/backend_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,65 @@ async fn proxy_anthropic_does_not_send_bearer_auth() {
assert_eq!(response.status, 200);
}

/// Regression test: when the client sends `anthropic-version`, the header must
/// reach the upstream. Previously, the header was added to the strip list
/// (because it appeared in `default_headers`) AND the default injection was
/// skipped (because `already_sent` checked the *original* input), so neither
/// the client's value nor the default reached the backend.
#[tokio::test]
async fn proxy_forwards_client_anthropic_version_header() {
let mock_server = MockServer::start().await;

// The upstream requires anthropic-version — wiremock will reject if missing.
Mock::given(method("POST"))
.and(path("/v1/messages"))
.and(header("x-api-key", "test-anthropic-key"))
.and(header("anthropic-version", "2024-10-22"))
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
.mount(&mock_server)
.await;

let router = Router::new().unwrap();
let candidates = vec![ResolvedRoute {
name: "inference.local".to_string(),
endpoint: mock_server.uri(),
model: "claude-sonnet-4-20250514".to_string(),
api_key: "test-anthropic-key".to_string(),
protocols: vec!["anthropic_messages".to_string()],
auth: AuthHeader::Custom("x-api-key"),
default_headers: vec![("anthropic-version".to_string(), "2023-06-01".to_string())],
}];

let body = serde_json::to_vec(&serde_json::json!({
"model": "claude-sonnet-4-20250514",
"max_tokens": 1,
"messages": [{"role": "user", "content": "hi"}]
}))
.unwrap();

// Client explicitly sends anthropic-version: 2024-10-22 — this value should
// reach the upstream, NOT be silently dropped.
let response = router
.proxy_with_candidates(
"anthropic_messages",
"POST",
"/v1/messages",
vec![
("content-type".to_string(), "application/json".to_string()),
("anthropic-version".to_string(), "2024-10-22".to_string()),
],
bytes::Bytes::from(body),
&candidates,
)
.await
.unwrap();

assert_eq!(
response.status, 200,
"upstream should have received anthropic-version header"
);
}

#[test]
fn config_resolves_routes_with_protocol() {
let config = RouterConfig {
Expand Down
Loading