From ff706e9e96d6c675ab4fc922b636143fa3707ade Mon Sep 17 00:00:00 2001 From: RoromoriYuzu <2391978156@qq.com> Date: Sun, 26 Apr 2026 02:40:32 +0800 Subject: [PATCH] feat: add provider user agent override --- src-tauri/src/provider.rs | 3 ++ src-tauri/src/proxy/forwarder.rs | 29 ++++++++++++++++ .../providers/forms/ClaudeFormFields.tsx | 31 ++++++++++++++++- .../providers/forms/CodexFormFields.tsx | 33 +++++++++++++++++++ .../providers/forms/ProviderForm.tsx | 12 +++++++ src/types.ts | 2 ++ 6 files changed, 109 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 160ae8be9..39cffc498 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -460,6 +460,9 @@ pub struct ProviderMeta { /// Codex Responses -> Chat Completions reasoning capability metadata. #[serde(rename = "codexChatReasoning", skip_serializing_if = "Option::is_none")] pub codex_chat_reasoning: Option, + /// Custom User-Agent for local proxy routing. + #[serde(rename = "customUserAgent", skip_serializing_if = "Option::is_none")] + pub custom_user_agent: Option, /// 累加模式应用中,该 provider 是否已写入 live config。 /// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。 #[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")] diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 3c2ee5068..92dc7480f 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -1537,6 +1537,15 @@ impl RequestForwarder { Vec::new() }; + let custom_user_agent = provider + .meta + .as_ref() + .and_then(|meta| meta.custom_user_agent.as_deref()) + .map(str::trim) + .filter(|ua| !ua.is_empty()) + .filter(|_| !is_copilot) + .and_then(|ua| http::HeaderValue::from_str(ua).ok()); + // --- Copilot 优化器:动态 header 注入 --- if let Some((ref classification, ref det_request_id, ref interaction_id)) = copilot_optimization @@ -1631,6 +1640,7 @@ impl RequestForwarder { let mut ordered_headers = http::HeaderMap::new(); let mut saw_auth = false; let mut saw_accept_encoding = false; + let mut saw_user_agent = false; let mut saw_anthropic_beta = false; let mut saw_anthropic_version = false; @@ -1711,6 +1721,19 @@ impl RequestForwarder { continue; } + // --- user-agent: provider-level override for local proxy routing --- + if !is_copilot && key_str.eq_ignore_ascii_case("user-agent") { + if !saw_user_agent { + saw_user_agent = true; + if let Some(ref ua) = custom_user_agent { + ordered_headers.append(http::header::USER_AGENT, ua.clone()); + } else { + ordered_headers.append(key.clone(), value.clone()); + } + } + continue; + } + // --- anthropic-beta — 用重建值替换(确保含 claude-code 标记) --- if key_str.eq_ignore_ascii_case("anthropic-beta") { if !saw_anthropic_beta { @@ -1760,6 +1783,12 @@ impl RequestForwarder { ); } + if !saw_user_agent { + if let Some(ref ua) = custom_user_agent { + ordered_headers.append(http::header::USER_AGENT, ua.clone()); + } + } + // 如果原始请求中没有 anthropic-beta 且有值需要添加,追加 if !saw_anthropic_beta { if let Some(ref beta_val) = anthropic_beta_value { diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index d0f1d749b..7625c5347 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -138,6 +138,10 @@ interface ClaudeFormFieldsProps { // Full URL mode isFullUrl: boolean; onFullUrlChange: (value: boolean) => void; + + // Local proxy User-Agent override + customUserAgent: string; + onCustomUserAgentChange: (value: string) => void; } export function ClaudeFormFields({ @@ -190,6 +194,8 @@ export function ClaudeFormFields({ onApiKeyFieldChange, isFullUrl, onFullUrlChange, + customUserAgent, + onCustomUserAgentChange, }: ClaudeFormFieldsProps) { const { t } = useTranslation(); const hasAnyAdvancedValue = !!( @@ -665,7 +671,30 @@ export function ClaudeFormFields({ /> )} - {/* 高级选项(API 格式 + 认证字段 + 模型映射) */} + {category !== "official" && ( +
+ + {t("providerForm.customUserAgent", { + defaultValue: "自定义 User-Agent", + })} + + onCustomUserAgentChange(e.target.value)} + placeholder="Mozilla/5.0 ..." + autoComplete="off" + /> +

+ {t("providerForm.customUserAgentHint", { + defaultValue: + "仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。", + })} +

+
+ )} + {shouldShowModelSelector && ( diff --git a/src/components/providers/forms/CodexFormFields.tsx b/src/components/providers/forms/CodexFormFields.tsx index a01cf4b24..680ec0878 100644 --- a/src/components/providers/forms/CodexFormFields.tsx +++ b/src/components/providers/forms/CodexFormFields.tsx @@ -72,6 +72,10 @@ interface CodexFormFieldsProps { // Speed Test Endpoints speedTestEndpoints: EndpointCandidate[]; + + // Local proxy User-Agent override + customUserAgent: string; + onCustomUserAgentChange: (value: string) => void; } type CodexCatalogRow = CodexCatalogModel & { rowId: string }; @@ -128,6 +132,8 @@ export function CodexFormFields({ catalogModels = [], onCatalogModelsChange, speedTestEndpoints, + customUserAgent, + onCustomUserAgentChange, }: CodexFormFieldsProps) { const { t } = useTranslation(); @@ -582,6 +588,33 @@ export function CodexFormFields({ onCustomEndpointsChange={onCustomEndpointsChange} /> )} + + {category !== "official" && ( +
+ + onCustomUserAgentChange(e.target.value)} + placeholder="Mozilla/5.0 ..." + autoComplete="off" + /> +

+ {t("providerForm.customUserAgentHint", { + defaultValue: + "仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。", + })} +

+
+ )} ); } diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 191b067ee..6908bed07 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -355,6 +355,7 @@ function ProviderFormFull({ ), }); setCodexChatReasoning(initialData?.meta?.codexChatReasoning ?? {}); + setCustomUserAgent(initialData?.meta?.customUserAgent ?? ""); }, [appId, initialData, supportsFullUrl]); const defaultValues: ProviderFormData = useMemo( @@ -509,6 +510,9 @@ function ProviderFormFull({ useState( () => initialData?.meta?.codexChatReasoning ?? {}, ); + const [customUserAgent, setCustomUserAgent] = useState( + () => initialData?.meta?.customUserAgent ?? "", + ); const { codexAuth, @@ -1386,6 +1390,10 @@ function ProviderFormFull({ localCodexApiFormat === "openai_chat" ? normalizeCodexChatReasoningForSave(codexChatReasoning) : undefined, + customUserAgent: + appId === "claude" || appId === "codex" + ? customUserAgent.trim() || undefined + : undefined, testConfig: testConfig.enabled ? testConfig : undefined, costMultiplier: pricingConfig.enabled ? pricingConfig.costMultiplier @@ -2009,6 +2017,8 @@ function ProviderFormFull({ onApiKeyFieldChange={handleApiKeyFieldChange} isFullUrl={localIsFullUrl} onFullUrlChange={setLocalIsFullUrl} + customUserAgent={customUserAgent} + onCustomUserAgentChange={setCustomUserAgent} /> )} @@ -2041,6 +2051,8 @@ function ProviderFormFull({ catalogModels={codexCatalogModels} onCatalogModelsChange={setCodexCatalogModels} speedTestEndpoints={speedTestEndpoints} + customUserAgent={customUserAgent} + onCustomUserAgentChange={setCustomUserAgent} /> )} diff --git a/src/types.ts b/src/types.ts index 71b547653..a09238110 100644 --- a/src/types.ts +++ b/src/types.ts @@ -219,6 +219,8 @@ export interface ProviderMeta { codexFastMode?: boolean; // Codex Responses -> Chat Completions reasoning capability metadata codexChatReasoning?: CodexChatReasoning; + // Custom User-Agent for local proxy routing. Only applied by the local proxy. + customUserAgent?: string; // 供应商类型(用于识别 Copilot 等特殊供应商) providerType?: string; // GitHub Copilot 关联账号 ID(旧字段,保留兼容读取)