diff --git a/src-tauri/src/commands/model_fetch.rs b/src-tauri/src/commands/model_fetch.rs index 7064e965b..c211bd651 100644 --- a/src-tauri/src/commands/model_fetch.rs +++ b/src-tauri/src/commands/model_fetch.rs @@ -14,12 +14,18 @@ pub async fn fetch_models_for_config( api_key: String, is_full_url: Option, models_url: Option, + custom_user_agent: Option, ) -> Result, String> { + // 与转发 / 检测路径共用 parse_custom_user_agent:非法 UA 静默忽略(不阻断取模型)。 + let user_agent = crate::provider::parse_custom_user_agent(custom_user_agent.as_deref()) + .ok() + .flatten(); model_fetch::fetch_models( &base_url, &api_key, is_full_url.unwrap_or(false), models_url.as_deref(), + user_agent, ) .await } diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 39cffc498..c99758ebe 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -1,3 +1,4 @@ +use http::header::{HeaderValue, InvalidHeaderValue}; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -477,6 +478,30 @@ pub struct ProviderMeta { pub github_account_id: Option, } +/// 解析 Provider 级自定义 User-Agent 字符串(单一真理来源)。 +/// +/// 转发(forwarder)、流式检测(stream_check)、获取模型列表(model_fetch)三条路径 +/// 共用同一口径,避免出现"某条路径用了 UA、另一条没用 / 报错"的不一致。 +/// +/// 合法性由 `http::HeaderValue::from_str` 按**字节**判定(`b >= 32 && b != 127 || b == '\t'`), +/// 与前端 `src/lib/userAgent.ts::isValidUserAgentHeader` 严格一致: +/// - `Ok(None)`:未设置或纯空白(trim 后为空)。 +/// - `Ok(Some(hv))`:合法。制表符、可见 ASCII(0x20–0x7E)、以及任意非 ASCII 字符 +/// (UTF-8 字节均 ≥ 0x80)都合法。 +/// - `Err(_)`:仅含控制字符时——除 `\t` 外的 0x00–0x1F(含换行)与 0x7F(DEL)。 +/// +/// 非法值的处理:三条运行时路径**均静默忽略**(`.ok().flatten()`,绝不让某条路径报错而 +/// 另一条放行);前端在输入框处给出非阻断提示。当前**不在保存时阻断**——deeplink 导入等 +/// 非表单路径应宽容,运行时静默忽略即为安全网。 +pub fn parse_custom_user_agent( + raw: Option<&str>, +) -> Result, InvalidHeaderValue> { + match raw.map(str::trim).filter(|s| !s.is_empty()) { + Some(ua) => HeaderValue::from_str(ua).map(Some), + None => Ok(None), + } +} + impl ProviderMeta { /// Codex OAuth FAST mode 是否启用。默认关闭,因为 `service_tier="priority"` /// 会按更高速率消耗 ChatGPT 订阅配额,用户需显式开启以换取更低延迟。 @@ -484,6 +509,11 @@ impl ProviderMeta { self.codex_fast_mode.unwrap_or(false) } + /// 经校验的 Provider 级自定义 User-Agent。见 [`parse_custom_user_agent`]。 + pub fn custom_user_agent_header(&self) -> Result, InvalidHeaderValue> { + parse_custom_user_agent(self.custom_user_agent.as_deref()) + } + /// 解析指定托管认证供应商绑定的账号 ID。 /// /// 新版优先读取 authBinding,旧版继续兼容 githubAccountId。 diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 92dc7480f..e83e2f68e 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -1537,14 +1537,17 @@ 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()); + // 自定义 User-Agent:与 stream_check / model_fetch 共用 parse_custom_user_agent, + // 运行时静默忽略非法值(前端在输入处给非阻断提示,不在保存时阻断)。 + // Copilot 指纹 UA 不可覆盖。 + let custom_user_agent = if is_copilot { + None + } else { + provider + .meta + .as_ref() + .and_then(|meta| meta.custom_user_agent_header().ok().flatten()) + }; // --- Copilot 优化器:动态 header 注入 --- if let Some((ref classification, ref det_request_id, ref interaction_id)) = diff --git a/src-tauri/src/services/model_fetch.rs b/src-tauri/src/services/model_fetch.rs index b495c9fb9..9a89303b5 100644 --- a/src-tauri/src/services/model_fetch.rs +++ b/src-tauri/src/services/model_fetch.rs @@ -4,6 +4,7 @@ //! 主要面向第三方聚合站(硅基流动、OpenRouter 等),以及把 Anthropic //! 协议挂在兼容子路径上的官方供应商(DeepSeek、Kimi、智谱 GLM 等)。 +use reqwest::header::{HeaderValue, USER_AGENT}; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; use std::time::Duration; @@ -55,6 +56,7 @@ pub async fn fetch_models( api_key: &str, is_full_url: bool, models_url_override: Option<&str>, + user_agent: Option, ) -> Result, String> { if api_key.is_empty() { return Err("API Key is required to fetch models".to_string()); @@ -66,13 +68,16 @@ pub async fn fetch_models( for url in &candidates { log::debug!("[ModelFetch] Trying endpoint: {url}"); - let response = match client + let mut request = client .get(url) .header("Authorization", format!("Bearer {api_key}")) - .timeout(Duration::from_secs(FETCH_TIMEOUT_SECS)) - .send() - .await - { + .timeout(Duration::from_secs(FETCH_TIMEOUT_SECS)); + // 自定义 User-Agent:部分 /models 端点同样有 UA 白名单(如 Kimi Coding Plan), + // 与转发 / 检测路径共用同一 UA,避免"代理可用但取模型失败"。 + if let Some(ua) = &user_agent { + request = request.header(USER_AGENT, ua.clone()); + } + let response = match request.send().await { Ok(r) => r, Err(e) => { return Err(format!("Request failed: {e}")); diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 665d8c0d2..c7a1a0bb2 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -293,6 +293,20 @@ impl StreamCheckService { )) } + /// Provider 级自定义 User-Agent(`meta.customUserAgent`),经 `parse_custom_user_agent` 校验。 + /// + /// 与 forwarder 转发路径(`RequestForwarder::forward`)、model_fetch 共用单一口径:trim、 + /// 空串视为未设置、**非法值静默忽略**(返回 `None`,不报错)。Stream Check 必须复用同一个 + /// UA 去探测,否则会与真实流量用不同的 User-Agent(例如 Kimi Coding Plan 的 UA 白名单), + /// 导致"检测失败但代理可用"或反之的分歧——非法 UA 时尤甚(转发静默丢弃、检测却会因 + /// reqwest 非法头在 `.send()` 报错)。 + fn custom_user_agent(provider: &Provider) -> Option { + provider + .meta + .as_ref() + .and_then(|meta| meta.custom_user_agent_header().ok().flatten()) + } + /// Claude 流式检查 /// /// 根据供应商的 api_format 选择请求格式: @@ -489,6 +503,14 @@ impl StreamCheckService { } } + // Provider 级自定义 User-Agent(meta.customUserAgent)覆盖默认 UA,与 forwarder + // 转发路径口径一致;Copilot 指纹 UA 不可被覆盖。 + if !is_github_copilot { + if let Some(ua) = Self::custom_user_agent(provider) { + request_builder = request_builder.header("user-agent", ua); + } + } + let response = request_builder .timeout(timeout) .json(&body) @@ -549,6 +571,15 @@ impl StreamCheckService { let os_name = Self::get_os_name(); let arch_name = Self::get_arch_name(); + // Provider 级自定义 User-Agent(meta.customUserAgent)覆盖默认 codex UA,与 forwarder + // 转发路径口径一致——否则 Stream Check 会用与真实流量不同的 UA 探测(如 Kimi UA 白名单)。 + let user_agent = Self::custom_user_agent(provider).unwrap_or_else(|| { + reqwest::header::HeaderValue::from_str(&format!( + "codex_cli_rs/0.80.0 ({os_name} 15.7.2; {arch_name}) Terminal" + )) + .unwrap_or_else(|_| reqwest::header::HeaderValue::from_static("codex_cli_rs/0.80.0")) + }); + let mut body = if uses_chat { // Chat Completions 请求体(与 transform_codex_chat::responses_to_chat_completions 对齐) json!({ @@ -586,10 +617,7 @@ impl StreamCheckService { .header("content-type", "application/json") .header("accept", "text/event-stream") .header("accept-encoding", "identity") - .header( - "user-agent", - format!("codex_cli_rs/0.80.0 ({os_name} 15.7.2; {arch_name}) Terminal"), - ) + .header("user-agent", user_agent.clone()) .header("originator", "codex_cli_rs") .timeout(timeout) .json(&body) @@ -1630,6 +1658,53 @@ mod tests { assert!(!StreamCheckService::additive_app_uses_auth_header(&p)); } + #[test] + fn test_custom_user_agent_trims_and_filters_empty() { + use crate::provider::ProviderMeta; + + let mut p = make_provider(serde_json::json!({ + "baseUrl": "https://api.kimi.com/coding", + "apiKey": "k", + })); + + // 未设置 meta → None + assert!(StreamCheckService::custom_user_agent(&p).is_none()); + + // 带首尾空格的 UA → 去空格后返回合法 HeaderValue + p.meta = Some(ProviderMeta { + custom_user_agent: Some(" claude-cli/2.1.161 ".to_string()), + ..Default::default() + }); + assert_eq!( + StreamCheckService::custom_user_agent(&p) + .unwrap() + .to_str() + .unwrap(), + "claude-cli/2.1.161" + ); + + // 纯空白 → 视为未设置(与 forwarder 路径口径一致) + p.meta = Some(ProviderMeta { + custom_user_agent: Some(" ".to_string()), + ..Default::default() + }); + assert!(StreamCheckService::custom_user_agent(&p).is_none()); + + // 非 ASCII 字符其实合法(UTF-8 字节均 ≥ 0x80,HeaderValue 按字节放行)→ 应返回 Some + p.meta = Some(ProviderMeta { + custom_user_agent: Some("claude-cli/2.1.161 \u{4e2d}".to_string()), + ..Default::default() + }); + assert!(StreamCheckService::custom_user_agent(&p).is_some()); + + // 含控制字符(内嵌换行)才非法 → None(静默忽略,与 forwarder 一致,不让 Stream Check 报错) + p.meta = Some(ProviderMeta { + custom_user_agent: Some("claude-cli/2.1.161\nX".to_string()), + ..Default::default() + }); + assert!(StreamCheckService::custom_user_agent(&p).is_none()); + } + #[test] fn test_resolve_opencode_base_url_explicit_wins() { let p = make_provider(serde_json::json!({ diff --git a/src/lib/api/model-fetch.ts b/src/lib/api/model-fetch.ts index 4f346972d..6b6d89f3d 100644 --- a/src/lib/api/model-fetch.ts +++ b/src/lib/api/model-fetch.ts @@ -18,12 +18,14 @@ export async function fetchModelsForConfig( apiKey: string, isFullUrl?: boolean, modelsUrl?: string, + customUserAgent?: string, ): Promise { return invoke("fetch_models_for_config", { baseUrl, apiKey, isFullUrl, modelsUrl, + customUserAgent, }); }