mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-16 13:34:04 +08:00
feat(proxy): honor custom User-Agent across stream check and model fetch
Extract a shared `parse_custom_user_agent` helper in provider.rs returning `Result<Option<HeaderValue>>`, and reuse it in the forwarder, stream check, and model fetch paths so detection, forwarding, and model listing all apply the same provider-level User-Agent. Previously only the forwarder honored it, so stream check could fail (or model listing 403) on UA-gated upstreams that the proxy itself handled fine. - stream_check injects the provider's custom UA on the claude/codex paths and still skips the GitHub Copilot fingerprint UA. - model_fetch service + command and the model-fetch.ts wrapper thread an optional UA through to GET /v1/models. - runtime callers silently ignore invalid values via `.ok().flatten()` (no save-time block, so deeplink imports stay lenient).
This commit is contained in:
@@ -14,12 +14,18 @@ pub async fn fetch_models_for_config(
|
||||
api_key: String,
|
||||
is_full_url: Option<bool>,
|
||||
models_url: Option<String>,
|
||||
custom_user_agent: Option<String>,
|
||||
) -> Result<Vec<FetchedModel>, 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
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 解析 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<Option<HeaderValue>, 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<Option<HeaderValue>, InvalidHeaderValue> {
|
||||
parse_custom_user_agent(self.custom_user_agent.as_deref())
|
||||
}
|
||||
|
||||
/// 解析指定托管认证供应商绑定的账号 ID。
|
||||
///
|
||||
/// 新版优先读取 authBinding,旧版继续兼容 githubAccountId。
|
||||
|
||||
@@ -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)) =
|
||||
|
||||
@@ -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<HeaderValue>,
|
||||
) -> Result<Vec<FetchedModel>, 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}"));
|
||||
|
||||
@@ -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<reqwest::header::HeaderValue> {
|
||||
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!({
|
||||
|
||||
@@ -18,12 +18,14 @@ export async function fetchModelsForConfig(
|
||||
apiKey: string,
|
||||
isFullUrl?: boolean,
|
||||
modelsUrl?: string,
|
||||
customUserAgent?: string,
|
||||
): Promise<FetchedModel[]> {
|
||||
return invoke("fetch_models_for_config", {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
isFullUrl,
|
||||
modelsUrl,
|
||||
customUserAgent,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user