From 6fd4e6f4623a03769ffa4c6c1069e122dc94649e Mon Sep 17 00:00:00 2001 From: Jianfeng Mei Date: Wed, 24 Jun 2026 22:19:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(proxy):=20=E6=B7=BB=E5=8A=A0=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E4=BB=A3=E7=90=86=E8=AF=B7=E6=B1=82=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E8=AF=B7=E6=B1=82=E5=A4=B4=E5=92=8C=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E4=BD=93=20(#4589)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(proxy): 添加本地代理请求覆盖功能,支持自定义请求头和请求体 * fix(proxy): harden local request overrides validation * feat(proxy): 添加受保护的本地代理请求头名称验证功能 * fix(i18n): 更新本地代理请求覆盖的错误提示信息格式 --------- Co-authored-by: jason.mei --- src-tauri/src/provider.rs | 53 +++- src-tauri/src/proxy/forwarder.rs | 286 +++++++++++++++++- .../providers/forms/ClaudeFormFields.tsx | 24 +- .../providers/forms/CodexFormFields.tsx | 24 +- .../forms/LocalProxyRequestOverridesField.tsx | 91 ++++++ .../providers/forms/ProviderForm.tsx | 91 +++++- src/i18n/locales/en.json | 7 + src/i18n/locales/ja.json | 7 + src/i18n/locales/zh-TW.json | 7 + src/i18n/locales/zh.json | 7 + src/lib/requestOverrides.ts | 182 +++++++++++ src/types.ts | 7 + tests/components/ClaudeFormFields.test.tsx | 4 + tests/lib/requestOverrides.test.ts | 75 +++++ 14 files changed, 855 insertions(+), 10 deletions(-) create mode 100644 src/components/providers/forms/LocalProxyRequestOverridesField.tsx create mode 100644 src/lib/requestOverrides.ts create mode 100644 tests/lib/requestOverrides.test.ts diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 41a58fad1..9eb2195dd 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -382,6 +382,21 @@ pub struct CodexChatReasoningConfig { pub output_format: Option, } +/// 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, +} + +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, + /// 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, /// 累加模式应用中,该 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!({ diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 593b19807..0c840c399 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -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)); diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index 2ce0f1d98..736593af5 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -48,6 +48,7 @@ import { type FetchedModel, } from "@/lib/api/model-fetch"; import { CustomUserAgentField } from "./CustomUserAgentField"; +import { LocalProxyRequestOverridesField } from "./LocalProxyRequestOverridesField"; import type { ProviderCategory, ClaudeApiFormat, @@ -145,6 +146,10 @@ interface ClaudeFormFieldsProps { // Local proxy User-Agent override customUserAgent: string; onCustomUserAgentChange: (value: string) => void; + localProxyHeadersOverride: string; + onLocalProxyHeadersOverrideChange: (value: string) => void; + localProxyBodyOverride: string; + onLocalProxyBodyOverrideChange: (value: string) => void; } export function ClaudeFormFields({ @@ -201,8 +206,15 @@ export function ClaudeFormFields({ onFullUrlChange, customUserAgent, onCustomUserAgentChange, + localProxyHeadersOverride, + onLocalProxyHeadersOverrideChange, + localProxyBodyOverride, + onLocalProxyBodyOverrideChange, }: ClaudeFormFieldsProps) { const { t } = useTranslation(); + const hasRequestOverrides = Boolean( + localProxyHeadersOverride.trim() || localProxyBodyOverride.trim(), + ); const hasAnyAdvancedValue = !!( claudeModel || defaultHaikuModel || @@ -211,7 +223,8 @@ export function ClaudeFormFields({ defaultFableModel || apiFormat !== "anthropic" || apiKeyField !== "ANTHROPIC_AUTH_TOKEN" || - customUserAgent + customUserAgent || + hasRequestOverrides ); const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue); @@ -963,6 +976,15 @@ export function ClaudeFormFields({ value={customUserAgent} onChange={onCustomUserAgentChange} /> + +
+ +
)} diff --git a/src/components/providers/forms/CodexFormFields.tsx b/src/components/providers/forms/CodexFormFields.tsx index ed29245b5..212660ba5 100644 --- a/src/components/providers/forms/CodexFormFields.tsx +++ b/src/components/providers/forms/CodexFormFields.tsx @@ -26,6 +26,7 @@ import { type FetchedModel, } from "@/lib/api/model-fetch"; import { CustomUserAgentField } from "./CustomUserAgentField"; +import { LocalProxyRequestOverridesField } from "./LocalProxyRequestOverridesField"; import { cn } from "@/lib/utils"; import type { CodexApiFormat, @@ -78,6 +79,10 @@ interface CodexFormFieldsProps { // Local proxy User-Agent override customUserAgent: string; onCustomUserAgentChange: (value: string) => void; + localProxyHeadersOverride: string; + onLocalProxyHeadersOverrideChange: (value: string) => void; + localProxyBodyOverride: string; + onLocalProxyBodyOverrideChange: (value: string) => void; } type CodexCatalogRow = CodexCatalogModel & { rowId: string }; @@ -136,6 +141,10 @@ export function CodexFormFields({ speedTestEndpoints, customUserAgent, onCustomUserAgentChange, + localProxyHeadersOverride, + onLocalProxyHeadersOverrideChange, + localProxyBodyOverride, + onLocalProxyBodyOverrideChange, }: CodexFormFieldsProps) { const { t } = useTranslation(); @@ -150,7 +159,11 @@ export function CodexFormFields({ const supportsEffort = codexChatReasoning.supportsEffort === true; // needsLocalRouting 非默认值说明预设/用户动过路由配置,需要让模型映射保持可见 - const hasAnyAdvancedValue = !!customUserAgent || needsLocalRouting; + const hasRequestOverrides = Boolean( + localProxyHeadersOverride.trim() || localProxyBodyOverride.trim(), + ); + const hasAnyAdvancedValue = + !!customUserAgent || hasRequestOverrides || needsLocalRouting; const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue); // 预设/编辑加载填充高级值后自动展开(仅从折叠→展开,不会自动折叠) @@ -480,6 +493,7 @@ export function CodexFormFields({
+
+ +
{/* 模型映射 —— 仅在本地路由 + 可编辑时显示;上方恒有 UA 字段,分隔线无需条件 */} diff --git a/src/components/providers/forms/LocalProxyRequestOverridesField.tsx b/src/components/providers/forms/LocalProxyRequestOverridesField.tsx new file mode 100644 index 000000000..3a70dfbb2 --- /dev/null +++ b/src/components/providers/forms/LocalProxyRequestOverridesField.tsx @@ -0,0 +1,91 @@ +import { useTranslation } from "react-i18next"; +import { FormLabel } from "@/components/ui/form"; +import { Textarea } from "@/components/ui/textarea"; +import { + parseBodyOverrideJson, + parseHeaderOverrideJson, +} from "@/lib/requestOverrides"; + +interface LocalProxyRequestOverridesFieldProps { + headersJson: string; + bodyJson: string; + onHeadersJsonChange: (value: string) => void; + onBodyJsonChange: (value: string) => void; +} + +export function LocalProxyRequestOverridesField({ + headersJson, + bodyJson, + onHeadersJsonChange, + onBodyJsonChange, +}: LocalProxyRequestOverridesFieldProps) { + const { t } = useTranslation(); + const headerError = parseHeaderOverrideJson(headersJson).error; + const bodyError = parseBodyOverrideJson(bodyJson).error; + + return ( +
+
+ + {t("providerForm.localProxyRequestOverrides", { + defaultValue: "本地代理请求覆盖", + })} + +

+ {t("providerForm.localProxyRequestOverridesHint", { + defaultValue: + "仅在本地路由/代理接管后生效,应用于协议转换后的上游请求。", + })} +

+
+ +
+
+ + {t("providerForm.localProxyHeaderOverrides", { + defaultValue: "Header 覆盖", + })} + +