mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 16:56:16 +08:00
Merge remote-tracking branch 'origin/main' into feature/managed-oauth-account-selector
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
//! HTTP content-encoding 工具。
|
||||
//!
|
||||
//! reqwest 的自动解压已禁用(为了透传 accept-encoding),需要手动解压。
|
||||
//! 请求侧(如 Codex Desktop 在登录态发压缩请求体)与响应侧(上游压缩响应体)
|
||||
//! 共用同一套解压逻辑。
|
||||
|
||||
use axum::http::header::HeaderMap;
|
||||
use std::io::Read;
|
||||
|
||||
/// 把 content-encoding 值拆成有序 coding 列表(去掉 identity 与空值)。
|
||||
///
|
||||
/// HTTP 允许堆叠编码(如 `gzip, zstd`),各 coding 以逗号分隔;亦允许重复
|
||||
/// content-encoding 头,语义等同逗号拼接(见 [`get_content_encoding`])。
|
||||
fn split_codings(content_encoding: &str) -> Vec<&str> {
|
||||
content_encoding
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|c| !c.is_empty() && *c != "identity")
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 单个 coding 是否可被解压。
|
||||
fn is_single_supported(coding: &str) -> bool {
|
||||
matches!(
|
||||
coding,
|
||||
"gzip" | "x-gzip" | "deflate" | "br" | "zstd" | "zst"
|
||||
)
|
||||
}
|
||||
|
||||
/// 解压单个 content-coding。未知编码返回 `Ok(None)`。
|
||||
fn decompress_single(coding: &str, body: &[u8]) -> Result<Option<Vec<u8>>, std::io::Error> {
|
||||
match coding {
|
||||
"gzip" | "x-gzip" => {
|
||||
let mut decoder = flate2::read::GzDecoder::new(body);
|
||||
let mut decompressed = Vec::new();
|
||||
decoder.read_to_end(&mut decompressed)?;
|
||||
Ok(Some(decompressed))
|
||||
}
|
||||
"deflate" => {
|
||||
// RFC 9110: deflate 指 zlib 包裹格式;但部分上游 / 客户端发 raw deflate 流。
|
||||
// 先按规范尝试 zlib,失败再回退 raw —— 否则合规来源必然解压失败,
|
||||
// 原始压缩字节会被 fail-open 透传给 JSON 解析(#2234 形态 C 之一)。
|
||||
let mut decompressed = Vec::new();
|
||||
let mut zlib = flate2::read::ZlibDecoder::new(body);
|
||||
match zlib.read_to_end(&mut decompressed) {
|
||||
Ok(_) => Ok(Some(decompressed)),
|
||||
Err(zlib_err) => {
|
||||
log::debug!("deflate 按 zlib 解压失败({zlib_err}),回退 raw deflate");
|
||||
let mut decompressed = Vec::new();
|
||||
let mut raw = flate2::read::DeflateDecoder::new(body);
|
||||
raw.read_to_end(&mut decompressed)?;
|
||||
Ok(Some(decompressed))
|
||||
}
|
||||
}
|
||||
}
|
||||
"br" => {
|
||||
let mut decompressed = Vec::new();
|
||||
brotli::BrotliDecompress(&mut std::io::Cursor::new(body), &mut decompressed)?;
|
||||
Ok(Some(decompressed))
|
||||
}
|
||||
"zstd" | "zst" => {
|
||||
// Codex 登录态对请求体启用 zstd(Compression::Zstd);上游也可能 zstd 压缩响应。
|
||||
let decompressed = zstd::stream::decode_all(std::io::Cursor::new(body))?;
|
||||
Ok(Some(decompressed))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据 content-encoding 解压 body 字节,支持堆叠编码(如 `gzip, zstd`)。
|
||||
///
|
||||
/// RFC 9110 §8.4:codings 按**应用顺序**列出,故解压须**反向**(最后应用的先解)。
|
||||
/// 返回 `Ok(None)` 表示存在不受支持的编码、原样透传——此时调用方必须保留
|
||||
/// content-encoding 头,否则下游(诊断 / 客户端)会把压缩字节误当明文。
|
||||
pub(crate) fn decompress_body(
|
||||
content_encoding: &str,
|
||||
body: &[u8],
|
||||
) -> Result<Option<Vec<u8>>, std::io::Error> {
|
||||
let codings = split_codings(content_encoding);
|
||||
if codings.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
// 任一 coding 不支持就整体放弃解压、保头透传,避免半解码的脏数据。
|
||||
if !codings.iter().all(|c| is_single_supported(c)) {
|
||||
log::warn!("不支持的 content-encoding: {content_encoding},跳过解压");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// 反向解码:列表末尾是最后应用的编码,须最先解。
|
||||
let mut data: Option<Vec<u8>> = None;
|
||||
for coding in codings.iter().rev() {
|
||||
let input = data.as_deref().unwrap_or(body);
|
||||
match decompress_single(coding, input)? {
|
||||
Some(decompressed) => data = Some(decompressed),
|
||||
// 上面 is_single_supported 已校验,理论不会发生;防御性兜底。
|
||||
None => return Ok(None),
|
||||
}
|
||||
}
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// 该 content-encoding(含堆叠,如 `gzip, zstd`)是否全部可被解压。
|
||||
///
|
||||
/// 请求侧用它做闸门:无法解压的压缩体不能透传给 JSON 解析,需直接拒绝。
|
||||
pub(crate) fn is_supported_content_encoding(content_encoding: &str) -> bool {
|
||||
let codings = split_codings(content_encoding);
|
||||
!codings.is_empty() && codings.iter().all(|c| is_single_supported(c))
|
||||
}
|
||||
|
||||
/// 从 header 提取 content-encoding(合并重复头,忽略 identity 与空值)。
|
||||
///
|
||||
/// HTTP 允许重复 content-encoding 头,语义等同逗号拼接,故用 `get_all` 合并;
|
||||
/// 返回值可能含多个逗号分隔的 coding,交由 [`decompress_body`] 反向解码。
|
||||
pub(crate) fn get_content_encoding(headers: &HeaderMap) -> Option<String> {
|
||||
let combined = headers
|
||||
.get_all("content-encoding")
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
.to_lowercase();
|
||||
if split_codings(&combined).is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(combined)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::HeaderValue;
|
||||
|
||||
#[test]
|
||||
fn decompress_body_deflate_handles_zlib_wrapped_per_rfc9110() {
|
||||
// RFC 9110 规范的 deflate = zlib 包裹格式(合规来源发的就是这个)
|
||||
let payload = br#"{"ok":true}"#;
|
||||
let mut encoder =
|
||||
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
std::io::Write::write_all(&mut encoder, payload).unwrap();
|
||||
let compressed = encoder.finish().unwrap();
|
||||
|
||||
let decompressed = decompress_body("deflate", &compressed).unwrap().unwrap();
|
||||
assert_eq!(decompressed, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompress_body_deflate_falls_back_to_raw_stream() {
|
||||
// 部分来源违规发 raw deflate 流,保持兼容
|
||||
let payload = br#"{"ok":true}"#;
|
||||
let mut encoder =
|
||||
flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
std::io::Write::write_all(&mut encoder, payload).unwrap();
|
||||
let compressed = encoder.finish().unwrap();
|
||||
|
||||
let decompressed = decompress_body("deflate", &compressed).unwrap().unwrap();
|
||||
assert_eq!(decompressed, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompress_body_zstd_roundtrip() {
|
||||
// Codex 登录态发的就是 zstd 压缩请求体
|
||||
let payload = br#"{"hello":"world","n":42}"#;
|
||||
let compressed = zstd::stream::encode_all(std::io::Cursor::new(&payload[..]), 0).unwrap();
|
||||
let decompressed = decompress_body("zstd", &compressed).unwrap().unwrap();
|
||||
assert_eq!(decompressed, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompress_body_stacked_gzip_then_zstd_decodes_in_reverse() {
|
||||
// Content-Encoding: gzip, zstd 表示先 gzip 后 zstd,解压须反向(先 zstd 后 gzip)
|
||||
let payload = br#"{"stacked":true}"#;
|
||||
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
std::io::Write::write_all(&mut gz, payload).unwrap();
|
||||
let gzipped = gz.finish().unwrap();
|
||||
let stacked = zstd::stream::encode_all(std::io::Cursor::new(&gzipped[..]), 0).unwrap();
|
||||
|
||||
let decompressed = decompress_body("gzip, zstd", &stacked).unwrap().unwrap();
|
||||
assert_eq!(decompressed, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompress_body_stacked_with_unsupported_returns_none() {
|
||||
// 堆叠里只要有一个不支持,就整体保头透传
|
||||
let result = decompress_body("snappy, zstd", b"\x00\x01\x02\x03").unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompress_body_unknown_encoding_returns_none_to_keep_headers() {
|
||||
// 未知编码必须返回 None(而非伪装成"已解码"),否则 content-encoding
|
||||
// 头被剥掉,下游诊断会把压缩字节误报成明文
|
||||
let result = decompress_body("snappy", b"\x00\x01\x02\x03").unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_supported_content_encoding_matches_decompressable() {
|
||||
for enc in [
|
||||
"gzip",
|
||||
"x-gzip",
|
||||
"deflate",
|
||||
"br",
|
||||
"zstd",
|
||||
"zst",
|
||||
"gzip, zstd",
|
||||
] {
|
||||
assert!(is_supported_content_encoding(enc), "{enc} 应受支持");
|
||||
}
|
||||
for enc in ["identity", "snappy", "compress", "", "gzip, snappy"] {
|
||||
assert!(!is_supported_content_encoding(enc), "{enc} 不应受支持");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_content_encoding_combines_repeated_headers() {
|
||||
// 重复的 content-encoding 头等同逗号拼接,须用 get_all 合并
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append("content-encoding", HeaderValue::from_static("gzip"));
|
||||
headers.append("content-encoding", HeaderValue::from_static("zstd"));
|
||||
assert_eq!(
|
||||
get_content_encoding(&headers).as_deref(),
|
||||
Some("gzip, zstd")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_content_encoding_ignores_identity_only() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append("content-encoding", HeaderValue::from_static("identity"));
|
||||
assert_eq!(get_content_encoding(&headers), None);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
use super::hyper_client::ProxyResponse;
|
||||
use super::{
|
||||
body_filter::filter_private_params_with_whitelist,
|
||||
content_encoding::{decompress_body, get_content_encoding},
|
||||
error::*,
|
||||
failover_switch::FailoverSwitchManager,
|
||||
json_canonical::{canonicalize_value, short_value_hash},
|
||||
@@ -24,7 +25,10 @@ use super::{
|
||||
use crate::commands::{CodexOAuthState, CopilotAuthState};
|
||||
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
|
||||
use crate::proxy::providers::copilot_auth::CopilotAuthManager;
|
||||
use crate::{app_config::AppType, provider::Provider};
|
||||
use crate::{
|
||||
app_config::AppType,
|
||||
provider::{LocalProxyRequestOverrides, Provider},
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use http::Extensions;
|
||||
use serde_json::Value;
|
||||
@@ -1386,7 +1390,18 @@ impl RequestForwarder {
|
||||
|
||||
// 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游
|
||||
// 默认使用空白名单,过滤所有 _ 前缀字段
|
||||
let filtered_body = prepare_upstream_request_body(request_body);
|
||||
let mut filtered_body = prepare_upstream_request_body(request_body);
|
||||
if !is_copilot {
|
||||
if let Some(overrides) = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.local_proxy_request_overrides.as_ref())
|
||||
{
|
||||
if apply_local_proxy_body_overrides(&mut filtered_body, overrides) {
|
||||
filtered_body = prepare_upstream_request_body(filtered_body);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 出站 body 定稿后刷新真值(覆盖 Codex chat 上游模型覆写、转换层模型改写)
|
||||
if let Some(m) = filtered_body
|
||||
.get("model")
|
||||
@@ -1833,6 +1848,15 @@ impl RequestForwarder {
|
||||
);
|
||||
}
|
||||
|
||||
apply_local_proxy_header_overrides(
|
||||
&mut ordered_headers,
|
||||
provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.local_proxy_request_overrides.as_ref()),
|
||||
is_copilot,
|
||||
);
|
||||
|
||||
reject_proxy_placeholder_for_managed_account_upstream(&url, &ordered_headers)?;
|
||||
|
||||
// 输出请求信息日志
|
||||
@@ -1942,7 +1966,20 @@ impl RequestForwarder {
|
||||
Ok((response, resolved_claude_api_format, outbound_model))
|
||||
} else {
|
||||
let status_code = status.as_u16();
|
||||
let body_text = String::from_utf8(response.bytes().await?.to_vec()).ok();
|
||||
// 错误响应同样可能被上游压缩(content-encoding)。reqwest 未启用任何
|
||||
// 自动解压 feature,这里拿到的是原始字节;不解压的话,压缩过的错误体会
|
||||
// 在 from_utf8 处变成非 UTF-8 而被丢弃,隐藏掉上游的限流/鉴权等详情。
|
||||
let encoding = get_content_encoding(response.headers());
|
||||
let raw = response.bytes().await?;
|
||||
let decoded = match encoding {
|
||||
Some(encoding) => match decompress_body(&encoding, &raw) {
|
||||
Ok(Some(decompressed)) => decompressed,
|
||||
// 不支持的编码 / 解压失败:退回原始字节,尽量保留可读信息
|
||||
_ => raw.to_vec(),
|
||||
},
|
||||
None => raw.to_vec(),
|
||||
};
|
||||
let body_text = String::from_utf8(decoded).ok();
|
||||
|
||||
Err(ProxyError::UpstreamError {
|
||||
status: status_code,
|
||||
@@ -2544,6 +2581,154 @@ fn summarize_text_for_log(text: &str, max_chars: usize) -> String {
|
||||
format!("{truncated}...")
|
||||
}
|
||||
|
||||
fn apply_local_proxy_body_overrides(
|
||||
body: &mut Value,
|
||||
overrides: &LocalProxyRequestOverrides,
|
||||
) -> bool {
|
||||
let Some(override_body) = overrides.body.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if !override_body.is_object() {
|
||||
log::warn!("[LocalProxyOverrides] Ignoring body override because it is not an object");
|
||||
return false;
|
||||
}
|
||||
|
||||
merge_json_override(body, override_body)
|
||||
}
|
||||
|
||||
fn merge_json_override(target: &mut Value, patch: &Value) -> bool {
|
||||
merge_json_override_inner(target, patch, true)
|
||||
}
|
||||
|
||||
fn merge_json_override_inner(target: &mut Value, patch: &Value, is_top_level: bool) -> bool {
|
||||
match (target, patch) {
|
||||
(Value::Object(target_map), Value::Object(patch_map)) => {
|
||||
let mut changed = false;
|
||||
for (key, patch_value) in patch_map {
|
||||
if is_top_level && key == "stream" {
|
||||
log::warn!(
|
||||
"[LocalProxyOverrides] Ignoring body override for protected field: stream"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
match target_map.get_mut(key) {
|
||||
Some(target_value) => {
|
||||
changed |= merge_json_override_inner(target_value, patch_value, false);
|
||||
}
|
||||
None => {
|
||||
target_map.insert(key.clone(), patch_value.clone());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
(target_value, patch_value) => {
|
||||
if target_value == patch_value {
|
||||
false
|
||||
} else {
|
||||
*target_value = patch_value.clone();
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_local_proxy_header_overrides(
|
||||
headers: &mut http::HeaderMap,
|
||||
overrides: Option<&LocalProxyRequestOverrides>,
|
||||
is_copilot: bool,
|
||||
) {
|
||||
if is_copilot {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(header_overrides) = overrides.map(|overrides| &overrides.headers) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (raw_name, raw_value) in header_overrides {
|
||||
let header_name = raw_name.trim().to_ascii_lowercase();
|
||||
if header_name.is_empty() {
|
||||
log::warn!("[LocalProxyOverrides] Ignoring header override with empty name");
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(name) = http::HeaderName::from_bytes(header_name.as_bytes()) else {
|
||||
log::warn!("[LocalProxyOverrides] Ignoring invalid header override name: {raw_name}");
|
||||
continue;
|
||||
};
|
||||
|
||||
if is_protected_local_proxy_override_header(&name) {
|
||||
log::debug!(
|
||||
"[LocalProxyOverrides] Ignoring protected header override: {}",
|
||||
name.as_str()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(value) = http::HeaderValue::from_str(raw_value) else {
|
||||
log::warn!(
|
||||
"[LocalProxyOverrides] Ignoring invalid header override value for {}",
|
||||
name.as_str()
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
headers.insert(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_protected_local_proxy_override_header(name: &http::HeaderName) -> bool {
|
||||
matches!(
|
||||
name.as_str(),
|
||||
"host"
|
||||
| "content-length"
|
||||
| "transfer-encoding"
|
||||
| "connection"
|
||||
| "proxy-authorization"
|
||||
| "proxy-authenticate"
|
||||
| "te"
|
||||
| "trailer"
|
||||
| "upgrade"
|
||||
| "accept-encoding"
|
||||
| "content-type"
|
||||
| "authorization"
|
||||
| "x-api-key"
|
||||
| "x-goog-api-key"
|
||||
| "chatgpt-account-id"
|
||||
| "session_id"
|
||||
| "x-client-request-id"
|
||||
| "x-codex-window-id"
|
||||
| "x-forwarded-host"
|
||||
| "x-forwarded-port"
|
||||
| "x-forwarded-proto"
|
||||
| "forwarded"
|
||||
| "cf-connecting-ip"
|
||||
| "cf-ipcountry"
|
||||
| "cf-ray"
|
||||
| "cf-visitor"
|
||||
| "true-client-ip"
|
||||
| "fastly-client-ip"
|
||||
| "x-azure-clientip"
|
||||
| "x-azure-fdid"
|
||||
| "x-azure-ref"
|
||||
| "akamai-origin-hop"
|
||||
| "x-akamai-config-log-detail"
|
||||
| "x-request-id"
|
||||
| "x-correlation-id"
|
||||
| "x-trace-id"
|
||||
| "x-amzn-trace-id"
|
||||
| "x-b3-traceid"
|
||||
| "x-b3-spanid"
|
||||
| "x-b3-parentspanid"
|
||||
| "x-b3-sampled"
|
||||
| "traceparent"
|
||||
| "tracestate"
|
||||
)
|
||||
}
|
||||
|
||||
fn prepare_upstream_request_body(request_body: Value) -> Value {
|
||||
canonicalize_value(filter_private_params_with_whitelist(request_body, &[]))
|
||||
}
|
||||
@@ -2607,6 +2792,7 @@ fn value_for_log(value: &Value) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::Database;
|
||||
use crate::provider::LocalProxyRequestOverrides;
|
||||
use axum::http::header::{HeaderValue, ACCEPT};
|
||||
use axum::http::HeaderMap;
|
||||
use bytes::Bytes;
|
||||
@@ -2806,6 +2992,116 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_proxy_body_overrides_deep_merge_final_body_without_stream() {
|
||||
let mut body = json!({
|
||||
"model": "before",
|
||||
"stream": false,
|
||||
"metadata": {
|
||||
"keep": true,
|
||||
"temperature": 1
|
||||
},
|
||||
"messages": [{ "role": "user", "content": "hello" }]
|
||||
});
|
||||
let overrides = LocalProxyRequestOverrides {
|
||||
headers: HashMap::new(),
|
||||
body: Some(json!({
|
||||
"model": "after",
|
||||
"stream": true,
|
||||
"metadata": {
|
||||
"temperature": 0.2,
|
||||
"top_p": 0.9
|
||||
},
|
||||
"messages": []
|
||||
})),
|
||||
};
|
||||
|
||||
assert!(apply_local_proxy_body_overrides(&mut body, &overrides));
|
||||
|
||||
assert_eq!(body["model"], "after");
|
||||
assert_eq!(body["stream"], false);
|
||||
assert_eq!(body["metadata"]["keep"], true);
|
||||
assert_eq!(body["metadata"]["temperature"], 0.2);
|
||||
assert_eq!(body["metadata"]["top_p"], 0.9);
|
||||
assert_eq!(body["messages"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_proxy_header_overrides_replace_allowed_headers_only() {
|
||||
let mut headers = http::HeaderMap::new();
|
||||
headers.insert(
|
||||
http::header::USER_AGENT,
|
||||
http::HeaderValue::from_static("original"),
|
||||
);
|
||||
headers.insert(
|
||||
http::header::AUTHORIZATION,
|
||||
http::HeaderValue::from_static("Bearer good"),
|
||||
);
|
||||
headers.insert(
|
||||
http::header::CONTENT_TYPE,
|
||||
http::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
|
||||
let overrides = LocalProxyRequestOverrides {
|
||||
headers: HashMap::from([
|
||||
("User-Agent".to_string(), "custom".to_string()),
|
||||
("X-Test".to_string(), "ok".to_string()),
|
||||
("Authorization".to_string(), "Bearer bad".to_string()),
|
||||
("Content-Type".to_string(), "text/plain".to_string()),
|
||||
("X-Bad".to_string(), "bad\nvalue".to_string()),
|
||||
]),
|
||||
body: None,
|
||||
};
|
||||
|
||||
apply_local_proxy_header_overrides(&mut headers, Some(&overrides), false);
|
||||
|
||||
assert_eq!(
|
||||
headers
|
||||
.get(http::header::USER_AGENT)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("custom")
|
||||
);
|
||||
assert_eq!(
|
||||
headers
|
||||
.get(http::header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("Bearer good")
|
||||
);
|
||||
assert_eq!(
|
||||
headers
|
||||
.get(http::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("application/json")
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("x-test").and_then(|value| value.to_str().ok()),
|
||||
Some("ok")
|
||||
);
|
||||
assert!(headers.get("x-bad").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_proxy_header_overrides_are_skipped_for_copilot() {
|
||||
let mut headers = http::HeaderMap::new();
|
||||
headers.insert(
|
||||
http::header::USER_AGENT,
|
||||
http::HeaderValue::from_static("copilot"),
|
||||
);
|
||||
let overrides = LocalProxyRequestOverrides {
|
||||
headers: HashMap::from([("User-Agent".to_string(), "custom".to_string())]),
|
||||
body: None,
|
||||
};
|
||||
|
||||
apply_local_proxy_header_overrides(&mut headers, Some(&overrides), true);
|
||||
|
||||
assert_eq!(
|
||||
headers
|
||||
.get(http::header::USER_AGENT)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("copilot")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_streaming_success_is_buffered_before_marking_provider_successful() {
|
||||
let forwarder = test_forwarder(Duration::from_secs(1), Duration::from_secs(1));
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
//! - Claude 的格式转换逻辑保留在此文件(用于 OpenRouter 旧接口回退)
|
||||
|
||||
use super::{
|
||||
content_encoding::{decompress_body, get_content_encoding, is_supported_content_encoding},
|
||||
error_mapper::{get_error_message, map_proxy_error_to_status},
|
||||
forwarder::ActiveConnectionGuard,
|
||||
handler_config::{
|
||||
@@ -568,6 +569,49 @@ fn endpoint_with_query(uri: &axum::http::Uri, endpoint: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Codex 客户端(尤其 Desktop 登录态)可能对请求体启用 zstd 压缩,使得后续
|
||||
/// `serde_json::from_slice` 直接解析失败。这里在解析前解压,并剥掉已失真的实体头
|
||||
/// (content-encoding / content-length / transfer-encoding)——转发层会基于解压后的
|
||||
/// 明文 JSON 重新生成正确的头。
|
||||
fn decode_codex_request_body(
|
||||
headers: &mut axum::http::HeaderMap,
|
||||
body_bytes: Bytes,
|
||||
) -> Result<Bytes, ProxyError> {
|
||||
let Some(encoding) = get_content_encoding(headers) else {
|
||||
return Ok(body_bytes);
|
||||
};
|
||||
|
||||
if !is_supported_content_encoding(&encoding) {
|
||||
return Err(ProxyError::InvalidRequest(format!(
|
||||
"Unsupported request content-encoding: {encoding}"
|
||||
)));
|
||||
}
|
||||
|
||||
log::debug!("[Codex] 解压请求体: content-encoding={encoding}");
|
||||
let decompressed = match decompress_body(&encoding, &body_bytes) {
|
||||
Ok(Some(decompressed)) => decompressed,
|
||||
// is_supported_content_encoding 已确保编码受支持,正常不会返回 None;
|
||||
// 防御性兜底:宁可报错,也不能把压缩字节当 JSON 透传下去。
|
||||
Ok(None) => {
|
||||
return Err(ProxyError::InvalidRequest(format!(
|
||||
"Unsupported request content-encoding: {encoding}"
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[Codex] 请求体解压失败 ({encoding}): {e}");
|
||||
return Err(ProxyError::InvalidRequest(format!(
|
||||
"Failed to decompress request body ({encoding}): {e}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
headers.remove(axum::http::header::CONTENT_ENCODING);
|
||||
headers.remove(axum::http::header::CONTENT_LENGTH);
|
||||
headers.remove(axum::http::header::TRANSFER_ENCODING);
|
||||
|
||||
Ok(Bytes::from(decompressed))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Codex API 处理器
|
||||
// ============================================================================
|
||||
@@ -580,13 +624,14 @@ pub async fn handle_chat_completions(
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let method = parts.method.clone();
|
||||
let uri = parts.uri;
|
||||
let headers = parts.headers;
|
||||
let mut headers = parts.headers;
|
||||
let extensions = parts.extensions;
|
||||
let body_bytes = req_body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
|
||||
.to_bytes();
|
||||
let body_bytes = decode_codex_request_body(&mut headers, body_bytes)?;
|
||||
let body: Value = serde_json::from_slice(&body_bytes)
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
@@ -645,13 +690,14 @@ pub async fn handle_responses(
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let method = parts.method.clone();
|
||||
let uri = parts.uri;
|
||||
let headers = parts.headers;
|
||||
let mut headers = parts.headers;
|
||||
let extensions = parts.extensions;
|
||||
let body_bytes = req_body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
|
||||
.to_bytes();
|
||||
let body_bytes = decode_codex_request_body(&mut headers, body_bytes)?;
|
||||
let body: Value = serde_json::from_slice(&body_bytes)
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
@@ -723,13 +769,14 @@ pub async fn handle_responses_compact(
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let method = parts.method.clone();
|
||||
let uri = parts.uri;
|
||||
let headers = parts.headers;
|
||||
let mut headers = parts.headers;
|
||||
let extensions = parts.extensions;
|
||||
let body_bytes = req_body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
|
||||
.to_bytes();
|
||||
let body_bytes = decode_codex_request_body(&mut headers, body_bytes)?;
|
||||
let body: Value = serde_json::from_slice(&body_bytes)
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
|
||||
@@ -223,7 +223,8 @@ fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
|
||||
// 响应解压由 response_processor 根据 content-encoding 手动处理。
|
||||
.no_gzip()
|
||||
.no_brotli()
|
||||
.no_deflate();
|
||||
.no_deflate()
|
||||
.no_zstd();
|
||||
|
||||
// 有代理地址则使用代理,否则跟随系统代理
|
||||
if let Some(url) = proxy_url {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
pub mod body_filter;
|
||||
pub mod cache_injector;
|
||||
pub mod circuit_breaker;
|
||||
pub(crate) mod content_encoding;
|
||||
pub mod copilot_optimizer;
|
||||
pub mod error;
|
||||
pub mod error_mapper;
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
//! - 通过 JWT id_token 提取 chatgpt_account_id 作为账号唯一标识
|
||||
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use reqwest::Client;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
@@ -236,7 +236,6 @@ pub struct CodexOAuthManager {
|
||||
/// 进行中的 Device Code 流程:device_auth_id -> {user_code, expires_at_ms}
|
||||
/// 过期条目会在 start_device_flow 时被清理,防止放弃的登录流程导致无界增长
|
||||
pending_device_codes: Arc<RwLock<HashMap<String, PendingDeviceCode>>>,
|
||||
http_client: Client,
|
||||
storage_path: PathBuf,
|
||||
}
|
||||
|
||||
@@ -250,7 +249,6 @@ impl CodexOAuthManager {
|
||||
access_tokens: Arc::new(RwLock::new(HashMap::new())),
|
||||
refresh_locks: Arc::new(RwLock::new(HashMap::new())),
|
||||
pending_device_codes: Arc::new(RwLock::new(HashMap::new())),
|
||||
http_client: Client::new(),
|
||||
storage_path,
|
||||
};
|
||||
|
||||
@@ -272,8 +270,7 @@ impl CodexOAuthManager {
|
||||
pub async fn start_device_flow(&self) -> Result<GitHubDeviceCodeResponse, CodexOAuthError> {
|
||||
log::info!("[CodexOAuth] 启动 Device Code 流程");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
let response = crate::proxy::http_client::get()
|
||||
.post(DEVICE_AUTH_USERCODE_URL)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("User-Agent", CODEX_USER_AGENT)
|
||||
@@ -355,8 +352,7 @@ impl CodexOAuthManager {
|
||||
|
||||
log::debug!("[CodexOAuth] 轮询 Device Code");
|
||||
|
||||
let poll_response = self
|
||||
.http_client
|
||||
let poll_response = crate::proxy::http_client::get()
|
||||
.post(DEVICE_AUTH_TOKEN_URL)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("User-Agent", CODEX_USER_AGENT)
|
||||
@@ -443,8 +439,7 @@ impl CodexOAuthManager {
|
||||
code: &str,
|
||||
code_verifier: &str,
|
||||
) -> Result<OAuthTokenResponse, CodexOAuthError> {
|
||||
let response = self
|
||||
.http_client
|
||||
let response = crate::proxy::http_client::get()
|
||||
.post(OAUTH_TOKEN_URL)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("User-Agent", CODEX_USER_AGENT)
|
||||
@@ -477,8 +472,7 @@ impl CodexOAuthManager {
|
||||
&self,
|
||||
refresh_token: &str,
|
||||
) -> Result<OAuthTokenResponse, CodexOAuthError> {
|
||||
let response = self
|
||||
.http_client
|
||||
let response = crate::proxy::http_client::get()
|
||||
.post(OAUTH_TOKEN_URL)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("User-Agent", CODEX_USER_AGENT)
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
//! - Provider 通过 meta.authBinding 关联账号
|
||||
//! - 自动迁移 v1 单账号格式到 v3 多账号 + 默认账号格式
|
||||
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
@@ -429,8 +428,6 @@ pub struct CopilotAuthManager {
|
||||
api_endpoints: Arc<RwLock<HashMap<String, String>>>,
|
||||
/// 每个账号的端点拉取锁,避免并发拉取重复打 GitHub API
|
||||
endpoint_locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
|
||||
/// HTTP 客户端
|
||||
http_client: Client,
|
||||
/// 存储路径
|
||||
storage_path: PathBuf,
|
||||
/// 待迁移的旧格式 token
|
||||
@@ -452,7 +449,6 @@ impl CopilotAuthManager {
|
||||
copilot_models: Arc::new(RwLock::new(HashMap::new())),
|
||||
api_endpoints: Arc::new(RwLock::new(HashMap::new())),
|
||||
endpoint_locks: Arc::new(RwLock::new(HashMap::new())),
|
||||
http_client: Client::new(),
|
||||
storage_path,
|
||||
pending_migration: Arc::new(RwLock::new(None)),
|
||||
migration_error: Arc::new(RwLock::new(None)),
|
||||
@@ -608,8 +604,7 @@ impl CopilotAuthManager {
|
||||
};
|
||||
log::info!("[CopilotAuth] 启动设备码流程 (domain: {domain})");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
let response = crate::proxy::http_client::get()
|
||||
.post(github_device_code_url(&domain))
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", COPILOT_USER_AGENT)
|
||||
@@ -653,8 +648,7 @@ impl CopilotAuthManager {
|
||||
};
|
||||
log::debug!("[CopilotAuth] 轮询 OAuth Token (domain: {domain})");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
let response = crate::proxy::http_client::get()
|
||||
.post(github_oauth_token_url(&domain))
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", COPILOT_USER_AGENT)
|
||||
@@ -837,8 +831,7 @@ impl CopilotAuthManager {
|
||||
|
||||
log::info!("[CopilotAuth] 获取账号 {account_id} 的 Copilot 可用模型");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
let response = crate::proxy::http_client::get()
|
||||
.get(&models_url)
|
||||
.header("Authorization", format!("Bearer {copilot_token}"))
|
||||
.header("Content-Type", "application/json")
|
||||
@@ -925,8 +918,7 @@ impl CopilotAuthManager {
|
||||
|
||||
log::info!("[CopilotAuth] 获取账号 {account_id} 的 Copilot 使用量");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
let response = crate::proxy::http_client::get()
|
||||
.get(copilot_usage_url(&domain))
|
||||
.header("Authorization", format!("token {github_token}"))
|
||||
.header("Content-Type", "application/json")
|
||||
@@ -1040,8 +1032,7 @@ impl CopilotAuthManager {
|
||||
|
||||
log::debug!("[CopilotAuth] 为账号 {account_id} 惰性拉取动态 API 端点");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
let response = crate::proxy::http_client::get()
|
||||
.get(copilot_usage_url(&domain))
|
||||
.header("Authorization", format!("token {github_token}"))
|
||||
.header("Content-Type", "application/json")
|
||||
@@ -1318,8 +1309,7 @@ impl CopilotAuthManager {
|
||||
github_token: &str,
|
||||
domain: &str,
|
||||
) -> Result<GitHubUser, CopilotAuthError> {
|
||||
let response = self
|
||||
.http_client
|
||||
let response = crate::proxy::http_client::get()
|
||||
.get(github_user_url(domain))
|
||||
.header("Authorization", format!("token {github_token}"))
|
||||
.header("User-Agent", COPILOT_USER_AGENT)
|
||||
@@ -1351,8 +1341,7 @@ impl CopilotAuthManager {
|
||||
) -> Result<(), CopilotAuthError> {
|
||||
log::debug!("[CopilotAuth] 获取账号 {account_id} 的 Copilot Token (domain: {domain})");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
let response = crate::proxy::http_client::get()
|
||||
.get(copilot_token_url(domain))
|
||||
.header("Authorization", format!("token {github_token}"))
|
||||
.header("User-Agent", COPILOT_USER_AGENT)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! 统一处理流式和非流式 API 响应
|
||||
|
||||
use super::{
|
||||
content_encoding::{decompress_body, get_content_encoding},
|
||||
forwarder::ActiveConnectionGuard,
|
||||
handler_config::{StreamUsageEventFilter, UsageParserConfig},
|
||||
handler_context::{RequestContext, StreamingTimeoutConfig},
|
||||
@@ -19,7 +20,6 @@ use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
io::Read,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
@@ -29,60 +29,9 @@ use std::{
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
// ============================================================================
|
||||
// 响应解压
|
||||
// 响应头处理
|
||||
// ============================================================================
|
||||
|
||||
/// 根据 content-encoding 解压响应体字节
|
||||
///
|
||||
/// reqwest 自动解压已禁用(为了透传 accept-encoding),需要手动解压。
|
||||
/// 返回 `Ok(None)` 表示编码不受支持、原样透传——此时调用方必须保留
|
||||
/// content-encoding 头,否则下游(诊断/客户端)会把压缩字节误当明文。
|
||||
fn decompress_body(content_encoding: &str, body: &[u8]) -> Result<Option<Vec<u8>>, std::io::Error> {
|
||||
match content_encoding {
|
||||
"gzip" | "x-gzip" => {
|
||||
let mut decoder = flate2::read::GzDecoder::new(body);
|
||||
let mut decompressed = Vec::new();
|
||||
decoder.read_to_end(&mut decompressed)?;
|
||||
Ok(Some(decompressed))
|
||||
}
|
||||
"deflate" => {
|
||||
// RFC 9110: deflate 指 zlib 包裹格式;但部分上游发 raw deflate 流。
|
||||
// 先按规范尝试 zlib,失败再回退 raw —— 否则合规上游必然解压失败,
|
||||
// 原始压缩字节会被 fail-open 透传给 JSON 解析(#2234 形态 C 之一)。
|
||||
let mut decompressed = Vec::new();
|
||||
let mut zlib = flate2::read::ZlibDecoder::new(body);
|
||||
match zlib.read_to_end(&mut decompressed) {
|
||||
Ok(_) => Ok(Some(decompressed)),
|
||||
Err(zlib_err) => {
|
||||
log::debug!("deflate 按 zlib 解压失败({zlib_err}),回退 raw deflate");
|
||||
let mut decompressed = Vec::new();
|
||||
let mut raw = flate2::read::DeflateDecoder::new(body);
|
||||
raw.read_to_end(&mut decompressed)?;
|
||||
Ok(Some(decompressed))
|
||||
}
|
||||
}
|
||||
}
|
||||
"br" => {
|
||||
let mut decompressed = Vec::new();
|
||||
brotli::BrotliDecompress(&mut std::io::Cursor::new(body), &mut decompressed)?;
|
||||
Ok(Some(decompressed))
|
||||
}
|
||||
_ => {
|
||||
log::warn!("未知的 content-encoding: {content_encoding},跳过解压");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从响应头提取 content-encoding(忽略 identity 和 chunked)
|
||||
fn get_content_encoding(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get("content-encoding")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty() && s != "identity")
|
||||
}
|
||||
|
||||
/// RFC 2616 / RFC 7230 中定义的不应被代理继续转发的响应头。
|
||||
const HOP_BY_HOP_RESPONSE_HEADERS: &[&str] = &[
|
||||
"connection",
|
||||
@@ -878,40 +827,6 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[test]
|
||||
fn decompress_body_deflate_handles_zlib_wrapped_per_rfc9110() {
|
||||
// RFC 9110 规范的 deflate = zlib 包裹格式(合规上游发的就是这个)
|
||||
let payload = br#"{"ok":true}"#;
|
||||
let mut encoder =
|
||||
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
std::io::Write::write_all(&mut encoder, payload).unwrap();
|
||||
let compressed = encoder.finish().unwrap();
|
||||
|
||||
let decompressed = decompress_body("deflate", &compressed).unwrap().unwrap();
|
||||
assert_eq!(decompressed, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompress_body_deflate_falls_back_to_raw_stream() {
|
||||
// 部分上游违规发 raw deflate 流,保持兼容
|
||||
let payload = br#"{"ok":true}"#;
|
||||
let mut encoder =
|
||||
flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
std::io::Write::write_all(&mut encoder, payload).unwrap();
|
||||
let compressed = encoder.finish().unwrap();
|
||||
|
||||
let decompressed = decompress_body("deflate", &compressed).unwrap().unwrap();
|
||||
assert_eq!(decompressed, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompress_body_unknown_encoding_returns_none_to_keep_headers() {
|
||||
// 未知编码必须返回 None(而非伪装成"已解码"),否则 content-encoding
|
||||
// 头被剥掉,下游诊断会把压缩字节误报成明文
|
||||
let result = decompress_body("zstd", b"\x28\xb5\x2f\xfd").unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_sse_field_accepts_optional_space() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user