diff --git a/crates/openshell-router/src/backend.rs b/crates/openshell-router/src/backend.rs index a9fe579a..a060d3f9 100644 --- a/crates/openshell-router/src/backend.rs +++ b/crates/openshell-router/src/backend.rs @@ -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 = { - 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()); diff --git a/crates/openshell-router/tests/backend_integration.rs b/crates/openshell-router/tests/backend_integration.rs index 972aad7b..4861bd6d 100644 --- a/crates/openshell-router/tests/backend_integration.rs +++ b/crates/openshell-router/tests/backend_integration.rs @@ -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 {