fix(proxy): enable gzip compression for non-streaming proxy requests

Non-streaming requests were forced to use `Accept-Encoding: identity`,
preventing upstream response compression and increasing bandwidth usage.

Now only streaming requests conservatively keep `identity` to avoid
decompression errors on interrupted SSE streams. Non-streaming requests
let reqwest auto-negotiate gzip and transparently decompress responses.
This commit is contained in:
Jason
2026-03-15 22:01:57 +08:00
parent 81897ac17e
commit 28afbea917
3 changed files with 110 additions and 4 deletions

34
src-tauri/Cargo.lock generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -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<String> {
.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::<Vec<_>>().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
));
}
}