mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(proxy): 添加本地代理请求覆盖功能,支持自定义请求头和请求体 (#4589)
* feat(proxy): 添加本地代理请求覆盖功能,支持自定义请求头和请求体 * fix(proxy): harden local request overrides validation * feat(proxy): 添加受保护的本地代理请求头名称验证功能 * fix(i18n): 更新本地代理请求覆盖的错误提示信息格式 --------- Co-authored-by: jason.mei <jason.mei@ucloud.cn>
This commit is contained in:
@@ -382,6 +382,21 @@ pub struct CodexChatReasoningConfig {
|
||||
pub output_format: Option<String>,
|
||||
}
|
||||
|
||||
/// Local proxy request overrides applied after route/protocol transforms.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct LocalProxyRequestOverrides {
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub headers: HashMap<String, String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl LocalProxyRequestOverrides {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.headers.is_empty() && self.body.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// 供应商元数据
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProviderMeta {
|
||||
@@ -466,6 +481,12 @@ pub struct ProviderMeta {
|
||||
/// Custom User-Agent for local proxy routing.
|
||||
#[serde(rename = "customUserAgent", skip_serializing_if = "Option::is_none")]
|
||||
pub custom_user_agent: Option<String>,
|
||||
/// Local proxy request overrides applied to the transformed upstream request.
|
||||
#[serde(
|
||||
rename = "localProxyRequestOverrides",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub local_proxy_request_overrides: Option<LocalProxyRequestOverrides>,
|
||||
/// 累加模式应用中,该 provider 是否已写入 live config。
|
||||
/// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。
|
||||
#[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")]
|
||||
@@ -932,10 +953,11 @@ pub struct OpenCodeModelLimit {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ClaudeModelConfig, CodexModelConfig, GeminiModelConfig, OpenCodeProviderConfig, Provider,
|
||||
ProviderManager, ProviderMeta, UniversalProvider,
|
||||
ClaudeModelConfig, CodexModelConfig, GeminiModelConfig, LocalProxyRequestOverrides,
|
||||
OpenCodeProviderConfig, Provider, ProviderManager, ProviderMeta, UniversalProvider,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn provider_meta_serializes_pricing_model_source() {
|
||||
@@ -963,6 +985,33 @@ mod tests {
|
||||
assert!(value.get("pricingModelSource").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_meta_roundtrips_local_proxy_request_overrides() {
|
||||
let meta = ProviderMeta {
|
||||
local_proxy_request_overrides: Some(LocalProxyRequestOverrides {
|
||||
headers: HashMap::from([("X-Test".to_string(), "yes".to_string())]),
|
||||
body: Some(json!({ "temperature": 0.2 })),
|
||||
}),
|
||||
..ProviderMeta::default()
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&meta).expect("serialize ProviderMeta");
|
||||
assert_eq!(
|
||||
value["localProxyRequestOverrides"]["headers"]["X-Test"],
|
||||
"yes"
|
||||
);
|
||||
assert_eq!(
|
||||
value["localProxyRequestOverrides"]["body"]["temperature"],
|
||||
0.2
|
||||
);
|
||||
|
||||
let decoded: ProviderMeta =
|
||||
serde_json::from_value(value).expect("deserialize ProviderMeta");
|
||||
let overrides = decoded.local_proxy_request_overrides.unwrap();
|
||||
assert_eq!(overrides.headers.get("X-Test"), Some(&"yes".to_string()));
|
||||
assert_eq!(overrides.body.unwrap()["temperature"], 0.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_with_id_populates_defaults() {
|
||||
let settings_config = json!({
|
||||
|
||||
@@ -24,7 +24,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 +1389,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 +1847,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)?;
|
||||
|
||||
// 输出请求信息日志
|
||||
@@ -2544,6 +2567,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 +2778,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 +2978,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));
|
||||
|
||||
Reference in New Issue
Block a user