Compare commits

...

3 Commits

Author SHA1 Message Date
YoVinchen
2fadd8bcdf fix(proxy): restrict Anthropic headers to Claude adapter only
- Move anthropic-beta and anthropic-version header handling inside
  Claude-specific condition to avoid sending unnecessary headers
  to Codex and Gemini APIs
- Update test cases to reflect ?beta=true query parameter behavior
- Add edge case tests for non-messages endpoints and existing queries
2026-01-08 10:50:26 +08:00
YoVinchen
70e7feb73b style(services): use inline format arguments in format strings
Apply Rust 1.58+ format string syntax across provider and skill
services. This replaces format!("msg {}", var) with format!("msg {var}")
for improved readability and consistency with modern Rust idioms.

Changed files:
- services/provider/mod.rs: 1 format string
- services/skill.rs: 10 format strings (error messages, log statements)

No functional changes, purely stylistic improvement.
2026-01-08 10:50:26 +08:00
YoVinchen
60fdc38e6b fix(proxy): improve header handling for Claude API compatibility
- Streamline header blacklist by removing overly aggressive filtering
  (browser-specific headers like sec-fetch-*, accept-language)
- Ensure anthropic-beta header always includes 'claude-code-20250219'
  marker required by upstream services for request validation
- Centralize anthropic-version header handling in forwarder to prevent
  duplicate headers across different auth strategies
- Add ?beta=true query parameter to /v1/messages endpoint for
  compatibility with certain upstream services (e.g., DuckCoding)
- Remove redundant anthropic-version from ClaudeAdapter auth headers
  as it's now managed exclusively by the forwarder

This improves proxy reliability with various Claude API endpoints
and third-party services that have specific header requirements.
2026-01-08 10:50:26 +08:00
4 changed files with 92 additions and 61 deletions

View File

@@ -20,23 +20,17 @@ use tokio::sync::RwLock;
/// Headers 黑名单 - 不透传到上游的 Headers /// Headers 黑名单 - 不透传到上游的 Headers
/// ///
/// 参考 Claude Code Hub 设计,过滤以下类别: /// 精简版黑名单,只过滤必须覆盖或可能导致问题的 header
/// 1. 认证类(会被覆盖) /// 参考成功透传的请求,保留更多原始 header
/// 2. 连接类(由 HTTP 客户端管理)
/// 3. 代理转发类
/// 4. CDN/云服务商特定头
/// 5. 请求追踪类
/// 6. 浏览器特定头(可能被上游检测)
/// ///
/// 注意:客户端 IP 类x-forwarded-for, x-real-ip默认透传 /// 注意:客户端 IP 类x-forwarded-for, x-real-ip默认透传
const HEADER_BLACKLIST: &[&str] = &[ const HEADER_BLACKLIST: &[&str] = &[
// 认证类(会被覆盖) // 认证类(会被覆盖)
"authorization", "authorization",
"x-api-key", "x-api-key",
// 连接类 // 连接类(由 HTTP 客户端管理)
"host", "host",
"content-length", "content-length",
"connection",
"transfer-encoding", "transfer-encoding",
// 编码类(会被覆盖为 identity // 编码类(会被覆盖为 identity
"accept-encoding", "accept-encoding",
@@ -68,16 +62,9 @@ const HEADER_BLACKLIST: &[&str] = &[
"x-b3-sampled", "x-b3-sampled",
"traceparent", "traceparent",
"tracestate", "tracestate",
// 浏览器特定头(可能被上游检测为非 CLI 请求) // anthropic 特定头单独处理,避免重复
"sec-fetch-mode",
"sec-fetch-site",
"sec-fetch-dest",
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"accept-language",
// anthropic-beta 单独处理,避免重复
"anthropic-beta", "anthropic-beta",
"anthropic-version",
// 客户端 IP 单独处理(默认透传) // 客户端 IP 单独处理(默认透传)
"x-forwarded-for", "x-forwarded-for",
"x-real-ip", "x-real-ip",
@@ -555,14 +542,30 @@ impl RequestForwarder {
} }
} }
// 处理 anthropic-beta Header透传 // 处理 anthropic-beta Header仅 Claude
// 参考 Claude Code Hub 的实现,直接透传客户端的 beta 标记 // 关键:确保包含 claude-code-20250219 标记,这是上游服务验证请求来源的依据
if let Some(beta) = headers.get("anthropic-beta") { // 如果客户端发送的 beta 标记中没有包含 claude-code-20250219需要补充
if let Ok(beta_str) = beta.to_str() { if adapter.name() == "Claude" {
request = request.header("anthropic-beta", beta_str); const CLAUDE_CODE_BETA: &str = "claude-code-20250219";
passed_headers.push(("anthropic-beta".to_string(), beta_str.to_string())); let beta_value = if let Some(beta) = headers.get("anthropic-beta") {
log::info!("[{}] 透传 anthropic-beta: {}", adapter.name(), beta_str); if let Ok(beta_str) = beta.to_str() {
} // 检查是否已包含 claude-code-20250219
if beta_str.contains(CLAUDE_CODE_BETA) {
beta_str.to_string()
} else {
// 补充 claude-code-20250219
format!("{CLAUDE_CODE_BETA},{beta_str}")
}
} else {
CLAUDE_CODE_BETA.to_string()
}
} else {
// 如果客户端没有发送,使用默认值
CLAUDE_CODE_BETA.to_string()
};
request = request.header("anthropic-beta", &beta_value);
passed_headers.push(("anthropic-beta".to_string(), beta_value.clone()));
log::info!("[{}] 设置 anthropic-beta: {}", adapter.name(), beta_value);
} }
// 客户端 IP 透传(默认开启) // 客户端 IP 透传(默认开启)
@@ -612,19 +615,20 @@ impl RequestForwarder {
); );
} }
// anthropic-version 透传:优先使用客户端的版本号 // anthropic-version 统一处理(仅 Claude:优先使用客户端的版本号,否则使用默认值
// 参考 Claude Code Hub透传客户端值而非固定版本 // 注意:只设置一次,避免重复
if let Some(version) = headers.get("anthropic-version") { if adapter.name() == "Claude" {
if let Ok(version_str) = version.to_str() { let version_str = headers
// 覆盖适配器设置的默认版本 .get("anthropic-version")
request = request.header("anthropic-version", version_str); .and_then(|v| v.to_str().ok())
passed_headers.push(("anthropic-version".to_string(), version_str.to_string())); .unwrap_or("2023-06-01");
log::info!( request = request.header("anthropic-version", version_str);
"[{}] 透传 anthropic-version: {}", passed_headers.push(("anthropic-version".to_string(), version_str.to_string()));
adapter.name(), log::info!(
version_str "[{}] 设置 anthropic-version: {}",
); adapter.name(),
} version_str
);
} }
// ========== 最终发送的 Headers 日志 ========== // ========== 最终发送的 Headers 日志 ==========

View File

@@ -217,28 +217,37 @@ impl ProviderAdapter for ClaudeAdapter {
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。 // 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。 // 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
format!( let base = format!(
"{}/{}", "{}/{}",
base_url.trim_end_matches('/'), base_url.trim_end_matches('/'),
endpoint.trim_start_matches('/') endpoint.trim_start_matches('/')
) );
// 为 /v1/messages 端点添加 ?beta=true 参数
// 这是某些上游服务(如 DuckCoding验证请求来源的关键参数
if endpoint.contains("/v1/messages") && !endpoint.contains("?") {
format!("{base}?beta=true")
} else {
base
}
} }
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder { fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
// 注意anthropic-version 由 forwarder.rs 统一处理(透传客户端值或设置默认值)
// 这里不再设置 anthropic-version避免 header 重复
match auth.strategy { match auth.strategy {
// Anthropic 官方: Authorization Bearer + x-api-key + anthropic-version // Anthropic 官方: Authorization Bearer + x-api-key
AuthStrategy::Anthropic => request AuthStrategy::Anthropic => request
.header("Authorization", format!("Bearer {}", auth.api_key)) .header("Authorization", format!("Bearer {}", auth.api_key))
.header("x-api-key", &auth.api_key) .header("x-api-key", &auth.api_key),
.header("anthropic-version", "2023-06-01"),
// ClaudeAuth 中转服务: 仅 Bearer无 x-api-key // ClaudeAuth 中转服务: 仅 Bearer无 x-api-key
AuthStrategy::ClaudeAuth => request AuthStrategy::ClaudeAuth => {
.header("Authorization", format!("Bearer {}", auth.api_key)) request.header("Authorization", format!("Bearer {}", auth.api_key))
.header("anthropic-version", "2023-06-01"), }
// OpenRouter: Bearer // OpenRouter: Bearer
AuthStrategy::Bearer => request AuthStrategy::Bearer => {
.header("Authorization", format!("Bearer {}", auth.api_key)) request.header("Authorization", format!("Bearer {}", auth.api_key))
.header("anthropic-version", "2023-06-01"), }
_ => request, _ => request,
} }
} }
@@ -416,15 +425,33 @@ mod tests {
#[test] #[test]
fn test_build_url_anthropic() { fn test_build_url_anthropic() {
let adapter = ClaudeAdapter::new(); let adapter = ClaudeAdapter::new();
// /v1/messages 端点会自动添加 ?beta=true 参数
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages"); let url = adapter.build_url("https://api.anthropic.com", "/v1/messages");
assert_eq!(url, "https://api.anthropic.com/v1/messages"); assert_eq!(url, "https://api.anthropic.com/v1/messages?beta=true");
} }
#[test] #[test]
fn test_build_url_openrouter() { fn test_build_url_openrouter() {
let adapter = ClaudeAdapter::new(); let adapter = ClaudeAdapter::new();
// /v1/messages 端点会自动添加 ?beta=true 参数
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages"); let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
assert_eq!(url, "https://openrouter.ai/api/v1/messages"); assert_eq!(url, "https://openrouter.ai/api/v1/messages?beta=true");
}
#[test]
fn test_build_url_no_beta_for_other_endpoints() {
let adapter = ClaudeAdapter::new();
// 非 /v1/messages 端点不添加 ?beta=true
let url = adapter.build_url("https://api.anthropic.com", "/v1/complete");
assert_eq!(url, "https://api.anthropic.com/v1/complete");
}
#[test]
fn test_build_url_preserve_existing_query() {
let adapter = ClaudeAdapter::new();
// 已有查询参数时不重复添加
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?foo=bar");
assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar");
} }
#[test] #[test]

View File

@@ -374,7 +374,7 @@ impl ProviderService {
let providers = state.db.get_all_providers(app_type.as_str())?; let providers = state.db.get_all_providers(app_type.as_str())?;
let provider = providers let provider = providers
.get(&current_id) .get(&current_id)
.ok_or_else(|| AppError::Message(format!("Provider {} not found", current_id)))?; .ok_or_else(|| AppError::Message(format!("Provider {current_id} not found")))?;
match app_type { match app_type {
AppType::Claude => Self::extract_claude_common_config(&provider.settings_config), AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),

View File

@@ -323,7 +323,7 @@ impl SkillService {
// 获取 skill 信息 // 获取 skill 信息
let skill = db let skill = db
.get_installed_skill(id)? .get_installed_skill(id)?
.ok_or_else(|| anyhow!("Skill not found: {}", id))?; .ok_or_else(|| anyhow!("Skill not found: {id}"))?;
// 从所有应用目录删除 // 从所有应用目录删除
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] { for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
@@ -353,7 +353,7 @@ impl SkillService {
// 获取当前 skill // 获取当前 skill
let mut skill = db let mut skill = db
.get_installed_skill(id)? .get_installed_skill(id)?
.ok_or_else(|| anyhow!("Skill not found: {}", id))?; .ok_or_else(|| anyhow!("Skill not found: {id}"))?;
// 更新状态 // 更新状态
skill.apps.set_enabled_for(app, enabled); skill.apps.set_enabled_for(app, enabled);
@@ -521,7 +521,7 @@ impl SkillService {
// 创建记录 // 创建记录
let skill = InstalledSkill { let skill = InstalledSkill {
id: format!("local:{}", dir_name), id: format!("local:{dir_name}"),
name, name,
description, description,
directory: dir_name, directory: dir_name,
@@ -551,7 +551,7 @@ impl SkillService {
let source = ssot_dir.join(directory); let source = ssot_dir.join(directory);
if !source.exists() { if !source.exists() {
return Err(anyhow!("Skill 不存在于 SSOT: {}", directory)); return Err(anyhow!("Skill 不存在于 SSOT: {directory}"));
} }
let app_dir = Self::get_app_skills_dir(app)?; let app_dir = Self::get_app_skills_dir(app)?;
@@ -566,7 +566,7 @@ impl SkillService {
Self::copy_dir_recursive(&source, &dest)?; Self::copy_dir_recursive(&source, &dest)?;
log::debug!("Skill {} 已复制到 {:?}", directory, app); log::debug!("Skill {directory} 已复制到 {app:?}");
Ok(()) Ok(())
} }
@@ -578,7 +578,7 @@ impl SkillService {
if skill_path.exists() { if skill_path.exists() {
fs::remove_dir_all(&skill_path)?; fs::remove_dir_all(&skill_path)?;
log::debug!("Skill {} 已从 {:?} 删除", directory, app); log::debug!("Skill {directory} 已从 {app:?} 删除");
} }
Ok(()) Ok(())
@@ -1044,7 +1044,7 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
}; };
let skill = InstalledSkill { let skill = InstalledSkill {
id: format!("local:{}", directory), id: format!("local:{directory}"),
name, name,
description, description,
directory, directory,
@@ -1060,7 +1060,7 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
count += 1; count += 1;
} }
log::info!("Skills 迁移完成,共 {} 个", count); log::info!("Skills 迁移完成,共 {count} 个");
Ok(count) Ok(count)
} }