mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
e6654bd7f9
- Remove `is_proxy_target` field from Provider struct (Rust & TypeScript) - Remove related DAO methods: get_proxy_target_provider, set_proxy_target - Remove deprecated Tauri commands: get_proxy_targets, set_proxy_target - Add `is_available()` method to CircuitBreaker for availability checks without consuming HalfOpen probe permits (used in select_providers) - Keep `allow_request()` for actual request gating with permit tracking - Update stream_check to use failover_queue instead of is_proxy_target - Clean up commented-out reset circuit breaker button in ProviderActions - Remove unused useProxyTargets and useSetProxyTarget hooks
403 lines
12 KiB
Rust
403 lines
12 KiB
Rust
//! Claude (Anthropic) Provider Adapter
|
|
//!
|
|
//! 支持透传模式和 OpenRouter 兼容模式
|
|
//!
|
|
//! ## 认证模式
|
|
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
|
|
//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key)
|
|
//! - **OpenRouter**: 需要 Anthropic ↔ OpenAI 格式转换
|
|
|
|
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
|
use crate::provider::Provider;
|
|
use crate::proxy::error::ProxyError;
|
|
use reqwest::RequestBuilder;
|
|
|
|
/// Claude 适配器
|
|
pub struct ClaudeAdapter;
|
|
|
|
impl ClaudeAdapter {
|
|
pub fn new() -> Self {
|
|
Self
|
|
}
|
|
|
|
/// 获取供应商类型
|
|
///
|
|
/// 根据 base_url 和 auth_mode 检测具体的供应商类型:
|
|
/// - OpenRouter: base_url 包含 openrouter.ai
|
|
/// - ClaudeAuth: auth_mode 为 bearer_only
|
|
/// - Claude: 默认 Anthropic 官方
|
|
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
|
|
// 检测 OpenRouter
|
|
if let Ok(base_url) = self.extract_base_url(provider) {
|
|
if base_url.contains("openrouter.ai") {
|
|
return ProviderType::OpenRouter;
|
|
}
|
|
}
|
|
|
|
// 检测 ClaudeAuth (仅 Bearer 认证)
|
|
if self.is_bearer_only_mode(provider) {
|
|
return ProviderType::ClaudeAuth;
|
|
}
|
|
|
|
ProviderType::Claude
|
|
}
|
|
|
|
/// 检测是否使用 OpenRouter
|
|
fn is_openrouter(&self, provider: &Provider) -> bool {
|
|
if let Ok(base_url) = self.extract_base_url(provider) {
|
|
return base_url.contains("openrouter.ai");
|
|
}
|
|
false
|
|
}
|
|
|
|
/// 检测是否为仅 Bearer 认证模式
|
|
fn is_bearer_only_mode(&self, provider: &Provider) -> bool {
|
|
// 检查 settings_config 中的 auth_mode
|
|
if let Some(auth_mode) = provider
|
|
.settings_config
|
|
.get("auth_mode")
|
|
.and_then(|v| v.as_str())
|
|
{
|
|
if auth_mode == "bearer_only" {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// 检查 env 中的 AUTH_MODE
|
|
if let Some(env) = provider.settings_config.get("env") {
|
|
if let Some(auth_mode) = env.get("AUTH_MODE").and_then(|v| v.as_str()) {
|
|
if auth_mode == "bearer_only" {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
/// 从 Provider 配置中提取 API Key
|
|
fn extract_key(&self, provider: &Provider) -> Option<String> {
|
|
if let Some(env) = provider.settings_config.get("env") {
|
|
// Anthropic 标准 key
|
|
if let Some(key) = env
|
|
.get("ANTHROPIC_AUTH_TOKEN")
|
|
.and_then(|v| v.as_str())
|
|
.filter(|s| !s.is_empty())
|
|
{
|
|
log::debug!("[Claude] 使用 ANTHROPIC_AUTH_TOKEN");
|
|
return Some(key.to_string());
|
|
}
|
|
// OpenRouter key
|
|
if let Some(key) = env
|
|
.get("OPENROUTER_API_KEY")
|
|
.and_then(|v| v.as_str())
|
|
.filter(|s| !s.is_empty())
|
|
{
|
|
log::debug!("[Claude] 使用 OPENROUTER_API_KEY");
|
|
return Some(key.to_string());
|
|
}
|
|
// 备选 OpenAI key (用于 OpenRouter)
|
|
if let Some(key) = env
|
|
.get("OPENAI_API_KEY")
|
|
.and_then(|v| v.as_str())
|
|
.filter(|s| !s.is_empty())
|
|
{
|
|
log::debug!("[Claude] 使用 OPENAI_API_KEY");
|
|
return Some(key.to_string());
|
|
}
|
|
}
|
|
|
|
// 尝试直接获取
|
|
if let Some(key) = provider
|
|
.settings_config
|
|
.get("apiKey")
|
|
.or_else(|| provider.settings_config.get("api_key"))
|
|
.and_then(|v| v.as_str())
|
|
.filter(|s| !s.is_empty())
|
|
{
|
|
log::debug!("[Claude] 使用 apiKey/api_key");
|
|
return Some(key.to_string());
|
|
}
|
|
|
|
log::warn!("[Claude] 未找到有效的 API Key");
|
|
None
|
|
}
|
|
}
|
|
|
|
impl Default for ClaudeAdapter {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl ProviderAdapter for ClaudeAdapter {
|
|
fn name(&self) -> &'static str {
|
|
"Claude"
|
|
}
|
|
|
|
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
|
|
// 1. 从 env 中获取
|
|
if let Some(env) = provider.settings_config.get("env") {
|
|
if let Some(url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
|
|
return Ok(url.trim_end_matches('/').to_string());
|
|
}
|
|
}
|
|
|
|
// 2. 尝试直接获取
|
|
if let Some(url) = provider
|
|
.settings_config
|
|
.get("base_url")
|
|
.and_then(|v| v.as_str())
|
|
{
|
|
return Ok(url.trim_end_matches('/').to_string());
|
|
}
|
|
|
|
if let Some(url) = provider
|
|
.settings_config
|
|
.get("baseURL")
|
|
.and_then(|v| v.as_str())
|
|
{
|
|
return Ok(url.trim_end_matches('/').to_string());
|
|
}
|
|
|
|
if let Some(url) = provider
|
|
.settings_config
|
|
.get("apiEndpoint")
|
|
.and_then(|v| v.as_str())
|
|
{
|
|
return Ok(url.trim_end_matches('/').to_string());
|
|
}
|
|
|
|
Err(ProxyError::ConfigError(
|
|
"Claude Provider 缺少 base_url 配置".to_string(),
|
|
))
|
|
}
|
|
|
|
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
|
|
let provider_type = self.provider_type(provider);
|
|
let strategy = match provider_type {
|
|
ProviderType::OpenRouter => AuthStrategy::Bearer,
|
|
ProviderType::ClaudeAuth => AuthStrategy::ClaudeAuth,
|
|
_ => AuthStrategy::Anthropic,
|
|
};
|
|
|
|
self.extract_key(provider)
|
|
.map(|key| AuthInfo::new(key, strategy))
|
|
}
|
|
|
|
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
|
// OpenRouter 使用 /v1/chat/completions
|
|
if base_url.contains("openrouter.ai") {
|
|
return format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
|
|
}
|
|
|
|
// Anthropic 直连
|
|
format!(
|
|
"{}/{}",
|
|
base_url.trim_end_matches('/'),
|
|
endpoint.trim_start_matches('/')
|
|
)
|
|
}
|
|
|
|
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
|
match auth.strategy {
|
|
// Anthropic 官方: Authorization Bearer + x-api-key + anthropic-version
|
|
AuthStrategy::Anthropic => request
|
|
.header("Authorization", format!("Bearer {}", auth.api_key))
|
|
.header("x-api-key", &auth.api_key)
|
|
.header("anthropic-version", "2023-06-01"),
|
|
// ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key
|
|
AuthStrategy::ClaudeAuth => {
|
|
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
|
}
|
|
// OpenRouter: Bearer
|
|
AuthStrategy::Bearer => {
|
|
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
|
}
|
|
_ => request,
|
|
}
|
|
}
|
|
|
|
fn needs_transform(&self, provider: &Provider) -> bool {
|
|
self.is_openrouter(provider)
|
|
}
|
|
|
|
fn transform_request(
|
|
&self,
|
|
body: serde_json::Value,
|
|
provider: &Provider,
|
|
) -> Result<serde_json::Value, ProxyError> {
|
|
super::transform::anthropic_to_openai(body, provider)
|
|
}
|
|
|
|
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
|
|
super::transform::openai_to_anthropic(body)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
fn create_provider(config: serde_json::Value) -> Provider {
|
|
Provider {
|
|
id: "test".to_string(),
|
|
name: "Test Claude".to_string(),
|
|
settings_config: config,
|
|
website_url: None,
|
|
category: Some("claude".to_string()),
|
|
created_at: None,
|
|
sort_index: None,
|
|
notes: None,
|
|
meta: None,
|
|
icon: None,
|
|
icon_color: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_base_url_from_env() {
|
|
let adapter = ClaudeAdapter::new();
|
|
let provider = create_provider(json!({
|
|
"env": {
|
|
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
|
}
|
|
}));
|
|
|
|
let url = adapter.extract_base_url(&provider).unwrap();
|
|
assert_eq!(url, "https://api.anthropic.com");
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_auth_anthropic() {
|
|
let adapter = ClaudeAdapter::new();
|
|
let provider = create_provider(json!({
|
|
"env": {
|
|
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
|
"ANTHROPIC_AUTH_TOKEN": "sk-ant-test-key"
|
|
}
|
|
}));
|
|
|
|
let auth = adapter.extract_auth(&provider).unwrap();
|
|
assert_eq!(auth.api_key, "sk-ant-test-key");
|
|
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_auth_openrouter() {
|
|
let adapter = ClaudeAdapter::new();
|
|
let provider = create_provider(json!({
|
|
"env": {
|
|
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
|
|
"OPENROUTER_API_KEY": "sk-or-test-key"
|
|
}
|
|
}));
|
|
|
|
let auth = adapter.extract_auth(&provider).unwrap();
|
|
assert_eq!(auth.api_key, "sk-or-test-key");
|
|
assert_eq!(auth.strategy, AuthStrategy::Bearer);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_auth_claude_auth_mode() {
|
|
let adapter = ClaudeAdapter::new();
|
|
let provider = create_provider(json!({
|
|
"env": {
|
|
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
|
|
"ANTHROPIC_AUTH_TOKEN": "sk-proxy-key"
|
|
},
|
|
"auth_mode": "bearer_only"
|
|
}));
|
|
|
|
let auth = adapter.extract_auth(&provider).unwrap();
|
|
assert_eq!(auth.api_key, "sk-proxy-key");
|
|
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_auth_claude_auth_env_mode() {
|
|
let adapter = ClaudeAdapter::new();
|
|
let provider = create_provider(json!({
|
|
"env": {
|
|
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
|
|
"ANTHROPIC_AUTH_TOKEN": "sk-proxy-key",
|
|
"AUTH_MODE": "bearer_only"
|
|
}
|
|
}));
|
|
|
|
let auth = adapter.extract_auth(&provider).unwrap();
|
|
assert_eq!(auth.api_key, "sk-proxy-key");
|
|
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
|
|
}
|
|
|
|
#[test]
|
|
fn test_provider_type_detection() {
|
|
let adapter = ClaudeAdapter::new();
|
|
|
|
// Anthropic 官方
|
|
let anthropic = create_provider(json!({
|
|
"env": {
|
|
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
|
"ANTHROPIC_AUTH_TOKEN": "sk-ant-test"
|
|
}
|
|
}));
|
|
assert_eq!(adapter.provider_type(&anthropic), ProviderType::Claude);
|
|
|
|
// OpenRouter
|
|
let openrouter = create_provider(json!({
|
|
"env": {
|
|
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
|
|
"OPENROUTER_API_KEY": "sk-or-test"
|
|
}
|
|
}));
|
|
assert_eq!(adapter.provider_type(&openrouter), ProviderType::OpenRouter);
|
|
|
|
// ClaudeAuth
|
|
let claude_auth = create_provider(json!({
|
|
"env": {
|
|
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
|
|
"ANTHROPIC_AUTH_TOKEN": "sk-test"
|
|
},
|
|
"auth_mode": "bearer_only"
|
|
}));
|
|
assert_eq!(
|
|
adapter.provider_type(&claude_auth),
|
|
ProviderType::ClaudeAuth
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_url_anthropic() {
|
|
let adapter = ClaudeAdapter::new();
|
|
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages");
|
|
assert_eq!(url, "https://api.anthropic.com/v1/messages");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_url_openrouter() {
|
|
let adapter = ClaudeAdapter::new();
|
|
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
|
|
assert_eq!(url, "https://openrouter.ai/api/v1/chat/completions");
|
|
}
|
|
|
|
#[test]
|
|
fn test_needs_transform() {
|
|
let adapter = ClaudeAdapter::new();
|
|
|
|
let anthropic_provider = create_provider(json!({
|
|
"env": {
|
|
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
|
}
|
|
}));
|
|
assert!(!adapter.needs_transform(&anthropic_provider));
|
|
|
|
let openrouter_provider = create_provider(json!({
|
|
"env": {
|
|
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
|
}
|
|
}));
|
|
assert!(adapter.needs_transform(&openrouter_provider));
|
|
}
|
|
}
|