fix(proxy): derive Claude auth strategy from ANTHROPIC env var name

Anthropic SDK assigns distinct semantics to the two env vars:

- ANTHROPIC_API_KEY    -> x-api-key
- ANTHROPIC_AUTH_TOKEN -> Authorization: Bearer

The Claude adapter previously collapsed both into AuthStrategy::Anthropic
and then emitted Authorization: Bearer regardless, breaking strict
Anthropic-protocol endpoints (Anthropic official, Cloudflare AI Gateway,
OpenCode Go, DashScope) and silently overriding the user's intended auth
scheme.

- claude::extract_auth: infer strategy from env var name
  (ANTHROPIC_AUTH_TOKEN -> ClaudeAuth, ANTHROPIC_API_KEY -> Anthropic),
  matching the precedence already used by extract_key.
- claude::get_auth_headers: split the Anthropic arm so it emits
  x-api-key, while ClaudeAuth and Bearer continue to use Bearer.
- stream_check: reuse ClaudeAdapter::get_auth_headers as the single
  source of truth, replacing the prior "always Bearer + maybe x-api-key"
  double injection that produced auth conflicts and false-negative
  health checks.
- Cover each strategy -> header mapping and env-var precedence with
  new unit tests in claude.rs.

Refs #2368, #2380
This commit is contained in:
Jason
2026-05-01 19:09:46 +08:00
parent 35bce24633
commit bdc4c1e8b8
2 changed files with 125 additions and 11 deletions

View File

@@ -398,6 +398,33 @@ impl ClaudeAdapter {
log::warn!("[Claude] 未找到有效的 API Key");
None
}
/// 根据 env 中填写的变量名推断 Anthropic 默认走哪种鉴权策略。
///
/// 与 Anthropic SDK 原生语义保持一致:
/// - `ANTHROPIC_AUTH_TOKEN` → `ClaudeAuth`(发送 `Authorization: Bearer`
/// - `ANTHROPIC_API_KEY` → `Anthropic` (发送 `x-api-key`
///
/// 优先级与 [`extract_key`] 一致;两者都缺时返回 `None` 由调用方决定 fallback。
fn infer_anthropic_auth_strategy(&self, provider: &Provider) -> Option<AuthStrategy> {
let env = provider.settings_config.get("env")?;
let has_value = |key: &str| -> bool {
env.get(key)
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.is_some()
};
if has_value("ANTHROPIC_AUTH_TOKEN") {
return Some(AuthStrategy::ClaudeAuth);
}
if has_value("ANTHROPIC_API_KEY") {
return Some(AuthStrategy::Anthropic);
}
None
}
}
impl Default for ClaudeAdapter {
@@ -511,7 +538,16 @@ impl ProviderAdapter for ClaudeAdapter {
ProviderType::Gemini => Some(AuthInfo::new(key, AuthStrategy::Google)),
ProviderType::OpenRouter => Some(AuthInfo::new(key, AuthStrategy::Bearer)),
ProviderType::ClaudeAuth => Some(AuthInfo::new(key, AuthStrategy::ClaudeAuth)),
_ => Some(AuthInfo::new(key, AuthStrategy::Anthropic)),
_ => {
// 按 env 中的变量名推断鉴权策略,对齐 Anthropic SDK 语义:
// ANTHROPIC_AUTH_TOKEN → Authorization: Bearer
// ANTHROPIC_API_KEY → x-api-key
// 其他来源apiKey 直填等)默认走 x-api-keyAnthropic 官方协议)。
let strategy = self
.infer_anthropic_auth_strategy(provider)
.unwrap_or(AuthStrategy::Anthropic);
Some(AuthInfo::new(key, strategy))
}
}
}
@@ -548,7 +584,13 @@ impl ProviderAdapter for ClaudeAdapter {
// 注意anthropic-version 由 forwarder.rs 统一处理(透传客户端值或设置默认值)
let bearer = format!("Bearer {}", auth.api_key);
match auth.strategy {
AuthStrategy::Anthropic | AuthStrategy::ClaudeAuth | AuthStrategy::Bearer => {
AuthStrategy::Anthropic => {
vec![(
HeaderName::from_static("x-api-key"),
HeaderValue::from_str(&auth.api_key).unwrap(),
)]
}
AuthStrategy::ClaudeAuth | AuthStrategy::Bearer => {
vec![(
HeaderName::from_static("authorization"),
HeaderValue::from_str(&bearer).unwrap(),
@@ -749,7 +791,9 @@ mod tests {
}
#[test]
fn test_extract_auth_anthropic() {
fn test_extract_auth_anthropic_auth_token_uses_claude_auth_strategy() {
// ANTHROPIC_AUTH_TOKEN 在 Anthropic SDK 里语义就是 Authorization: Bearer
// 因此走 ClaudeAuth strategy 而不是 Anthropicx-api-key
let adapter = ClaudeAdapter::new();
let provider = create_provider(json!({
"env": {
@@ -760,7 +804,7 @@ mod tests {
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-ant-test-key");
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
}
#[test]
@@ -778,6 +822,73 @@ mod tests {
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
}
#[test]
fn test_extract_auth_both_env_vars_prefer_auth_token() {
// 两个变量都填时extract_key 选 AUTH_TOKENstrategy 推断也必须保持一致。
let adapter = ClaudeAdapter::new();
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
"ANTHROPIC_AUTH_TOKEN": "sk-from-auth-token",
"ANTHROPIC_API_KEY": "sk-from-api-key"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-from-auth-token");
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
}
#[test]
fn test_extract_auth_apikey_field_fallback_uses_anthropic_strategy() {
// 当用户没填任一 ANTHROPIC_* env而是直接使用 apiKey 字段时,
// 视为没有显式语义偏好,默认走 Anthropic 官方协议x-api-key
let adapter = ClaudeAdapter::new();
let provider = create_provider(json!({
"apiKey": "sk-direct",
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-direct");
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
}
#[test]
fn test_get_auth_headers_anthropic_emits_x_api_key() {
let adapter = ClaudeAdapter::new();
let auth = AuthInfo::new("sk-ant-test".to_string(), AuthStrategy::Anthropic);
let headers = adapter.get_auth_headers(&auth);
assert_eq!(headers.len(), 1);
assert_eq!(headers[0].0.as_str(), "x-api-key");
assert_eq!(headers[0].1.to_str().unwrap(), "sk-ant-test");
}
#[test]
fn test_get_auth_headers_claude_auth_emits_authorization_bearer() {
let adapter = ClaudeAdapter::new();
let auth = AuthInfo::new("sk-relay-test".to_string(), AuthStrategy::ClaudeAuth);
let headers = adapter.get_auth_headers(&auth);
assert_eq!(headers.len(), 1);
assert_eq!(headers[0].0.as_str(), "authorization");
assert_eq!(headers[0].1.to_str().unwrap(), "Bearer sk-relay-test");
}
#[test]
fn test_get_auth_headers_bearer_emits_authorization_bearer() {
let adapter = ClaudeAdapter::new();
let auth = AuthInfo::new("sk-or-test".to_string(), AuthStrategy::Bearer);
let headers = adapter.get_auth_headers(&auth);
assert_eq!(headers.len(), 1);
assert_eq!(headers[0].0.as_str(), "authorization");
assert_eq!(headers[0].1.to_str().unwrap(), "Bearer sk-or-test");
}
#[test]
fn test_extract_auth_openrouter() {
let adapter = ClaudeAdapter::new();

View File

@@ -16,7 +16,9 @@ use crate::proxy::providers::copilot_auth;
use crate::proxy::providers::transform::anthropic_to_openai;
use crate::proxy::providers::transform_gemini::anthropic_to_gemini;
use crate::proxy::providers::transform_responses::anthropic_to_responses;
use crate::proxy::providers::{get_adapter, AuthInfo, AuthStrategy};
use crate::proxy::providers::{
get_adapter, AuthInfo, AuthStrategy, ClaudeAdapter, ProviderAdapter,
};
/// 健康状态枚举
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -433,12 +435,13 @@ impl StreamCheckService {
let os_name = Self::get_os_name();
let arch_name = Self::get_arch_name();
request_builder =
request_builder.header("authorization", format!("Bearer {}", auth.api_key));
// Only Anthropic official strategy adds x-api-key
if auth.strategy == AuthStrategy::Anthropic {
request_builder = request_builder.header("x-api-key", &auth.api_key);
// 鉴权头复用 ClaudeAdapter::get_auth_headers与代理路径forwarder保持单一真理来源。
// - AuthStrategy::Anthropic → x-api-key
// - AuthStrategy::ClaudeAuth → Authorization: Bearer
// - AuthStrategy::Bearer → Authorization: Bearer
// 避免之前"无条件 Bearer + 条件 x-api-key 双发"导致的假阴性 / auth conflict。
for (name, value) in ClaudeAdapter::new().get_auth_headers(auth) {
request_builder = request_builder.header(name, value);
}
request_builder = request_builder