diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 635a8bc32..1919ffe8f 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -939,6 +939,10 @@ impl RequestForwarder { .parse::() .ok() .and_then(|u| u.authority().map(|a| a.to_string())); + let apply_openrouter_codex_header_guard = + should_guard_openrouter_codex_headers(adapter.name(), &base_url, &effective_endpoint); + let connection_header_tokens = + apply_openrouter_codex_header_guard.then(|| collect_connection_header_tokens(headers)); // 预计算 anthropic-beta 值(仅 Claude) let anthropic_beta_value = if adapter.name() == "Claude" { @@ -1016,6 +1020,13 @@ impl RequestForwarder { continue; } + // --- OpenRouter chat/responses 专用:补充剥离 hop-by-hop 请求头 --- + if let Some(connection_header_tokens) = connection_header_tokens.as_ref() { + if should_strip_openrouter_codex_request_header(key_str, connection_header_tokens) { + continue; + } + } + // --- 认证类 — 用 adapter 提供的认证头替换(在原始位置) --- if key_str.eq_ignore_ascii_case("authorization") || key_str.eq_ignore_ascii_case("x-api-key") @@ -1318,6 +1329,61 @@ fn is_bedrock_provider(provider: &Provider) -> bool { .unwrap_or(false) } +const OPENROUTER_CODEX_HOP_BY_HOP_REQUEST_HEADERS: &[&str] = &[ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "trailers", + "upgrade", +]; + +fn should_guard_openrouter_codex_headers( + adapter_name: &str, + base_url: &str, + endpoint: &str, +) -> bool { + if adapter_name != "Codex" || !base_url.contains("openrouter.ai") { + return false; + } + + let path = endpoint + .split_once('?') + .map(|(path, _)| path) + .unwrap_or(endpoint); + + matches!( + path, + "/chat/completions" | "/responses" | "/responses/compact" + ) +} + +fn collect_connection_header_tokens( + headers: &axum::http::HeaderMap, +) -> std::collections::HashSet { + headers + .get_all("connection") + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(|name| name.to_ascii_lowercase()) + .collect() +} + +fn should_strip_openrouter_codex_request_header( + key_str: &str, + connection_header_tokens: &std::collections::HashSet, +) -> bool { + let lower_key = key_str.to_ascii_lowercase(); + OPENROUTER_CODEX_HOP_BY_HOP_REQUEST_HEADERS.contains(&lower_key.as_str()) + || connection_header_tokens.contains(lower_key.as_str()) +} + fn build_retryable_failure_log( provider_name: &str, attempted_providers: usize, @@ -1700,6 +1766,73 @@ mod tests { )); } + #[test] + fn collect_connection_header_tokens_tracks_dynamic_hop_by_hop_headers() { + let mut headers = HeaderMap::new(); + headers.insert( + "connection", + HeaderValue::from_static("keep-alive, x-custom-hop, Upgrade"), + ); + + let tokens = collect_connection_header_tokens(&headers); + + assert!(tokens.contains("keep-alive")); + assert!(tokens.contains("x-custom-hop")); + assert!(tokens.contains("upgrade")); + } + + #[test] + fn should_guard_openrouter_codex_headers_only_for_openrouter_chat_and_responses() { + assert!(should_guard_openrouter_codex_headers( + "Codex", + "https://openrouter.ai/api/v1", + "/chat/completions" + )); + assert!(should_guard_openrouter_codex_headers( + "Codex", + "https://openrouter.ai/api/v1", + "/responses?stream=true" + )); + assert!(should_guard_openrouter_codex_headers( + "Codex", + "https://openrouter.ai/api/v1", + "/responses/compact" + )); + assert!(!should_guard_openrouter_codex_headers( + "Claude", + "https://openrouter.ai/api", + "/v1/messages" + )); + assert!(!should_guard_openrouter_codex_headers( + "Codex", + "https://api.openai.com/v1", + "/responses" + )); + } + + #[test] + fn should_strip_openrouter_codex_request_header_covers_static_and_dynamic_hop_by_hop_headers() { + let mut connection_tokens = std::collections::HashSet::new(); + connection_tokens.insert("x-custom-hop".to_string()); + + assert!(should_strip_openrouter_codex_request_header( + "connection", + &connection_tokens + )); + assert!(should_strip_openrouter_codex_request_header( + "proxy-connection", + &connection_tokens + )); + assert!(should_strip_openrouter_codex_request_header( + "x-custom-hop", + &connection_tokens + )); + assert!(!should_strip_openrouter_codex_request_header( + "anthropic-version", + &connection_tokens + )); + } + // ==================== Copilot 动态 endpoint 路由相关测试 ==================== /// 验证 is_copilot 检测逻辑:通过 provider_type 判断