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:
Jason
2026-06-10 16:39:12 +08:00
parent 25983f3420
commit 8b925c2f2f
6 changed files with 138 additions and 17 deletions
+10 -5
View File
@@ -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}"));
+79 -4
View File
@@ -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-Agentmeta.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-Agentmeta.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 字节均 ≥ 0x80HeaderValue 按字节放行)→ 应返回 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!({