diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 544b367b6..056597d3d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -137,6 +137,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-compression" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-executor" version = "1.14.0" @@ -798,6 +810,23 @@ dependencies = [ "memchr", ] +[[package]] +name = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -5988,13 +6017,18 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ + "async-compression", "bitflags 2.11.0", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", "iri-string", "pin-project-lite", + "tokio", + "tokio-util", "tower 0.5.3", "tower-layer", "tower-service", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index eaba857d2..1ffb50388 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -38,7 +38,7 @@ tauri-plugin-deep-link = "2" dirs = "5.0" toml = "0.8" toml_edit = "0.22" -reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] } +reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks", "gzip"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] } futures = "0.3" async-stream = "0.3" diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 946da4934..154495792 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -885,9 +885,11 @@ impl RequestForwarder { } } - // 禁用压缩,避免 gzip 流式响应解析错误 - // 参考 CCH: undici 在连接提前关闭时会对不完整的 gzip 流抛出错误 - request = request.header("accept-encoding", "identity"); + // 流式请求保守禁用压缩,避免上游压缩 SSE 在连接中断时触发解压错误。 + // 非流式请求不显式设置 Accept-Encoding,让 reqwest 自动协商压缩并透明解压。 + if should_force_identity_encoding(effective_endpoint, &filtered_body, headers) { + request = request.header("accept-encoding", "identity"); + } // 使用适配器添加认证头 if let Some(auth) = adapter.extract_auth(provider) { @@ -1092,6 +1094,30 @@ fn extract_json_error_message(body: &Value) -> Option { .find_map(|value| value.as_str().map(ToString::to_string)) } +fn should_force_identity_encoding( + endpoint: &str, + body: &Value, + headers: &axum::http::HeaderMap, +) -> bool { + if body + .get("stream") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + return true; + } + + if endpoint.contains("streamGenerateContent") || endpoint.contains("alt=sse") { + return true; + } + + headers + .get(axum::http::header::ACCEPT) + .and_then(|value| value.to_str().ok()) + .map(|accept| accept.contains("text/event-stream")) + .unwrap_or(false) +} + fn summarize_text_for_log(text: &str, max_chars: usize) -> String { let normalized = text.split_whitespace().collect::>().join(" "); let trimmed = normalized.trim(); @@ -1108,6 +1134,7 @@ fn summarize_text_for_log(text: &str, max_chars: usize) -> String { #[cfg(test)] mod tests { use super::*; + use axum::http::{header::ACCEPT, HeaderMap, HeaderValue}; use serde_json::json; #[test] @@ -1174,4 +1201,49 @@ mod tests { assert_eq!(summary, "line1 line2..."); } + + #[test] + fn force_identity_for_stream_flag_requests() { + let headers = HeaderMap::new(); + + assert!(should_force_identity_encoding( + "/v1/responses", + &json!({ "stream": true }), + &headers + )); + } + + #[test] + fn force_identity_for_gemini_stream_endpoints() { + let headers = HeaderMap::new(); + + assert!(should_force_identity_encoding( + "/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse", + &json!({ "model": "gemini-2.5-pro" }), + &headers + )); + } + + #[test] + fn force_identity_for_sse_accept_header() { + let mut headers = HeaderMap::new(); + headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream")); + + assert!(should_force_identity_encoding( + "/v1/responses", + &json!({ "model": "gpt-5" }), + &headers + )); + } + + #[test] + fn non_streaming_requests_allow_automatic_compression() { + let headers = HeaderMap::new(); + + assert!(!should_force_identity_encoding( + "/v1/responses", + &json!({ "model": "gpt-5" }), + &headers + )); + } }