mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
1586451862
* feat(failover): add auto-failover master switch with proxy integration
- Add persistent auto_failover_enabled setting in database
- Add get/set_auto_failover_enabled commands
- Provider router respects master switch state
- Proxy shutdown automatically disables failover
- Enabling failover auto-starts proxy server
- Optimistic updates for failover queue toggle
* feat(proxy): persist proxy takeover state across app restarts
- Add proxy_takeover_{app_type} settings for per-app state tracking
- Restore proxy takeover state automatically on app startup
- Preserve state on normal exit, clear on manual stop
- Add stop_with_restore_keep_state method for graceful shutdown
* fix(proxy): set takeover state for all apps in start_with_takeover
* fix(windows): hide console window when checking CLI versions
Add CREATE_NO_WINDOW flag to prevent command prompt from flashing
when detecting claude/codex/gemini CLI versions on Windows.
* refactor(failover): make auto-failover toggle per-app independent
- Change setting key from 'auto_failover_enabled' to 'auto_failover_enabled_{app_type}'
- Update provider_router to check per-app failover setting
- When failover disabled, use current provider only; when enabled, use queue order
- Add unit tests for failover enabled/disabled behavior
* feat(failover): auto-switch to higher priority provider on recovery
- After circuit breaker reset, check if recovered provider has higher priority
- Automatically switch back if queue_order is lower (higher priority)
- Stream health check now resets circuit breaker on success/degraded
* chore: remove unused start_proxy_with_takeover command
- Remove command registration from lib.rs
- Add comment clarifying failover queue is preserved on proxy stop
* feat(ui): integrate failover controls into provider cards
- Add failover toggle button to provider card actions
- Show priority badge (P1, P2, ...) for queued providers
- Highlight active provider with green border in failover mode
- Sync drag-drop order with failover queue
- Move per-app failover toggle to FailoverQueueManager
- Simplify SettingsPage failover section
* test(providers): add mocks for failover hooks in ProviderList tests
* refactor(failover): merge failover_queue table into providers
- Add in_failover_queue field to providers table
- Remove standalone failover_queue table and related indexes
- Simplify queue ordering by reusing sort_index field
- Remove reorder_failover_queue and set_failover_item_enabled commands
- Update frontend to use simplified FailoverQueueItem type
* fix(database): ensure in_failover_queue column exists for v2 databases
Add column check in create_tables to handle existing v2 databases
that were created before the failover queue refactor.
* fix(ui): differentiate active provider border color by proxy mode
- Use green border/gradient when proxy takeover is active
- Use blue border/gradient in normal mode (no proxy)
- Improves visual distinction between proxy and non-proxy states
* fix(database): clear provider health record when removing from failover queue
When a provider is removed from the failover queue, its health monitoring
is no longer needed. This change ensures the health record is also deleted
from the database to prevent stale data.
* fix(failover): improve cache cleanup for provider health and circuit breaker
- Use removeQueries instead of invalidateQueries when stopping proxy to
completely clear health and circuit breaker caches
- Clear provider health and circuit breaker caches when removing from
failover queue
- Refresh failover queue after drag-sort since queue order depends on
sort_index
- Only show health badge when provider is in failover queue
* style: apply prettier formatting to App.tsx and ProviderList.tsx
* fix(proxy): handle missing health records and clear health on proxy stop
- Return default healthy state when provider health record not found
- Add clear_provider_health_for_app to clear health for specific app
- Clear app health records when stopping proxy takeover
* fix(proxy): track actual provider used in forwarding for accurate logging
Introduce ForwardResult and ForwardError structs to return the actual
provider that handled the request. This ensures usage statistics and
error logs reflect the correct provider after failover.
429 lines
14 KiB
Rust
429 lines
14 KiB
Rust
//! Provider Adapters Module
|
||
//!
|
||
//! 供应商适配器模块,提供统一的接口抽象不同上游供应商的处理逻辑。
|
||
//!
|
||
//! ## 模块结构
|
||
//! - `adapter`: 定义 `ProviderAdapter` trait
|
||
//! - `auth`: 认证类型和策略
|
||
//! - `claude`: Claude (Anthropic) 适配器
|
||
//! - `codex`: Codex (OpenAI) 适配器
|
||
//! - `gemini`: Gemini (Google) 适配器
|
||
//! - `models`: API 数据模型
|
||
//! - `transform`: 格式转换
|
||
|
||
mod adapter;
|
||
mod auth;
|
||
mod claude;
|
||
mod codex;
|
||
mod gemini;
|
||
pub mod models;
|
||
pub mod streaming;
|
||
pub mod transform;
|
||
|
||
use crate::app_config::AppType;
|
||
use crate::provider::Provider;
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
// 公开导出
|
||
pub use adapter::ProviderAdapter;
|
||
pub use auth::{AuthInfo, AuthStrategy};
|
||
pub use claude::ClaudeAdapter;
|
||
pub use codex::CodexAdapter;
|
||
pub use gemini::GeminiAdapter;
|
||
|
||
/// 供应商类型枚举
|
||
///
|
||
/// 区分不同供应商的具体实现方式,决定认证和请求处理逻辑。
|
||
/// 比 AppType 更细粒度,支持同一 AppType 下的多种变体。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum ProviderType {
|
||
/// Anthropic 官方 API (x-api-key + anthropic-version)
|
||
Claude,
|
||
/// Claude 中转服务 (仅 Bearer 认证,无 x-api-key)
|
||
ClaudeAuth,
|
||
/// OpenAI Codex Response API
|
||
Codex,
|
||
/// Google Gemini API (x-goog-api-key)
|
||
Gemini,
|
||
/// Google Gemini CLI (OAuth Bearer)
|
||
GeminiCli,
|
||
/// OpenRouter(已支持 Claude Code 兼容接口,默认透传;保留旧转换逻辑备用)
|
||
OpenRouter,
|
||
}
|
||
|
||
impl ProviderType {
|
||
/// 是否需要格式转换
|
||
///
|
||
/// 过去 OpenRouter 需要将 Anthropic 格式转换为 OpenAI 格式;
|
||
/// 现在默认关闭转换(因为 OpenRouter 已支持 Claude Code 兼容接口)。
|
||
#[allow(dead_code)]
|
||
pub fn needs_transform(&self) -> bool {
|
||
match self {
|
||
ProviderType::OpenRouter => false,
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// 获取默认端点
|
||
#[allow(dead_code)]
|
||
pub fn default_endpoint(&self) -> &'static str {
|
||
match self {
|
||
ProviderType::Claude | ProviderType::ClaudeAuth => "https://api.anthropic.com",
|
||
ProviderType::Codex => "https://api.openai.com",
|
||
ProviderType::Gemini | ProviderType::GeminiCli => {
|
||
"https://generativelanguage.googleapis.com"
|
||
}
|
||
ProviderType::OpenRouter => "https://openrouter.ai/api",
|
||
}
|
||
}
|
||
|
||
/// 从 AppType 和 Provider 配置推断供应商类型
|
||
///
|
||
/// 根据配置中的 base_url、auth_mode、api_key 格式等信息推断具体的供应商类型
|
||
#[allow(dead_code)]
|
||
pub fn from_app_type_and_config(app_type: &AppType, provider: &Provider) -> Self {
|
||
match app_type {
|
||
AppType::Claude => {
|
||
// 检测是否为 OpenRouter
|
||
let adapter = ClaudeAdapter::new();
|
||
if let Ok(base_url) = adapter.extract_base_url(provider) {
|
||
if base_url.contains("openrouter.ai") {
|
||
return ProviderType::OpenRouter;
|
||
}
|
||
}
|
||
// 检测是否为中转服务(仅 Bearer 认证)
|
||
// 注意:ProviderMeta 没有直接的 auth_mode 字段,
|
||
// 我们通过检查 settings_config 中的配置来判断
|
||
// 检查 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 ProviderType::ClaudeAuth;
|
||
}
|
||
}
|
||
// 检查 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 ProviderType::ClaudeAuth;
|
||
}
|
||
}
|
||
}
|
||
ProviderType::Claude
|
||
}
|
||
AppType::Codex => ProviderType::Codex,
|
||
AppType::Gemini => {
|
||
// 检测是否为 CLI 模式(OAuth)
|
||
let adapter = GeminiAdapter::new();
|
||
if let Some(auth) = adapter.extract_auth(provider) {
|
||
let key = &auth.api_key;
|
||
// OAuth access_token 以 ya29. 开头
|
||
if key.starts_with("ya29.") {
|
||
return ProviderType::GeminiCli;
|
||
}
|
||
// JSON 格式的 OAuth 凭证
|
||
if key.starts_with('{') {
|
||
return ProviderType::GeminiCli;
|
||
}
|
||
}
|
||
ProviderType::Gemini
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 转换为字符串表示
|
||
pub fn as_str(&self) -> &'static str {
|
||
match self {
|
||
ProviderType::Claude => "claude",
|
||
ProviderType::ClaudeAuth => "claude_auth",
|
||
ProviderType::Codex => "codex",
|
||
ProviderType::Gemini => "gemini",
|
||
ProviderType::GeminiCli => "gemini_cli",
|
||
ProviderType::OpenRouter => "openrouter",
|
||
}
|
||
}
|
||
}
|
||
|
||
impl std::fmt::Display for ProviderType {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
write!(f, "{}", self.as_str())
|
||
}
|
||
}
|
||
|
||
impl std::str::FromStr for ProviderType {
|
||
type Err = String;
|
||
|
||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||
match s.to_lowercase().as_str() {
|
||
"claude" => Ok(ProviderType::Claude),
|
||
"claude_auth" | "claude-auth" => Ok(ProviderType::ClaudeAuth),
|
||
"codex" => Ok(ProviderType::Codex),
|
||
"gemini" => Ok(ProviderType::Gemini),
|
||
"gemini_cli" | "gemini-cli" => Ok(ProviderType::GeminiCli),
|
||
"openrouter" => Ok(ProviderType::OpenRouter),
|
||
_ => Err(format!("Invalid provider type: {s}")),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 根据 AppType 获取对应的适配器
|
||
pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
|
||
match app_type {
|
||
AppType::Claude => Box::new(ClaudeAdapter::new()),
|
||
AppType::Codex => Box::new(CodexAdapter::new()),
|
||
AppType::Gemini => Box::new(GeminiAdapter::new()),
|
||
}
|
||
}
|
||
|
||
/// 根据 ProviderType 获取对应的适配器
|
||
#[allow(dead_code)]
|
||
pub fn get_adapter_for_provider_type(provider_type: &ProviderType) -> Box<dyn ProviderAdapter> {
|
||
match provider_type {
|
||
ProviderType::Claude | ProviderType::ClaudeAuth | ProviderType::OpenRouter => {
|
||
Box::new(ClaudeAdapter::new())
|
||
}
|
||
ProviderType::Codex => Box::new(CodexAdapter::new()),
|
||
ProviderType::Gemini | ProviderType::GeminiCli => Box::new(GeminiAdapter::new()),
|
||
}
|
||
}
|
||
|
||
#[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 Provider".to_string(),
|
||
settings_config: config,
|
||
website_url: None,
|
||
category: None,
|
||
created_at: None,
|
||
sort_index: None,
|
||
notes: None,
|
||
meta: None,
|
||
icon: None,
|
||
icon_color: None,
|
||
in_failover_queue: false,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_provider_type_needs_transform() {
|
||
assert!(!ProviderType::Claude.needs_transform());
|
||
assert!(!ProviderType::ClaudeAuth.needs_transform());
|
||
assert!(!ProviderType::Codex.needs_transform());
|
||
assert!(!ProviderType::Gemini.needs_transform());
|
||
assert!(!ProviderType::GeminiCli.needs_transform());
|
||
assert!(!ProviderType::OpenRouter.needs_transform());
|
||
}
|
||
|
||
#[test]
|
||
fn test_provider_type_default_endpoint() {
|
||
assert_eq!(
|
||
ProviderType::Claude.default_endpoint(),
|
||
"https://api.anthropic.com"
|
||
);
|
||
assert_eq!(
|
||
ProviderType::ClaudeAuth.default_endpoint(),
|
||
"https://api.anthropic.com"
|
||
);
|
||
assert_eq!(
|
||
ProviderType::Codex.default_endpoint(),
|
||
"https://api.openai.com"
|
||
);
|
||
assert_eq!(
|
||
ProviderType::Gemini.default_endpoint(),
|
||
"https://generativelanguage.googleapis.com"
|
||
);
|
||
assert_eq!(
|
||
ProviderType::GeminiCli.default_endpoint(),
|
||
"https://generativelanguage.googleapis.com"
|
||
);
|
||
assert_eq!(
|
||
ProviderType::OpenRouter.default_endpoint(),
|
||
"https://openrouter.ai/api"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_provider_type_from_str() {
|
||
assert_eq!(
|
||
"claude".parse::<ProviderType>().unwrap(),
|
||
ProviderType::Claude
|
||
);
|
||
assert_eq!(
|
||
"claude_auth".parse::<ProviderType>().unwrap(),
|
||
ProviderType::ClaudeAuth
|
||
);
|
||
assert_eq!(
|
||
"claude-auth".parse::<ProviderType>().unwrap(),
|
||
ProviderType::ClaudeAuth
|
||
);
|
||
assert_eq!(
|
||
"codex".parse::<ProviderType>().unwrap(),
|
||
ProviderType::Codex
|
||
);
|
||
assert_eq!(
|
||
"gemini".parse::<ProviderType>().unwrap(),
|
||
ProviderType::Gemini
|
||
);
|
||
assert_eq!(
|
||
"gemini_cli".parse::<ProviderType>().unwrap(),
|
||
ProviderType::GeminiCli
|
||
);
|
||
assert_eq!(
|
||
"gemini-cli".parse::<ProviderType>().unwrap(),
|
||
ProviderType::GeminiCli
|
||
);
|
||
assert_eq!(
|
||
"openrouter".parse::<ProviderType>().unwrap(),
|
||
ProviderType::OpenRouter
|
||
);
|
||
assert!("invalid".parse::<ProviderType>().is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_provider_type_as_str() {
|
||
assert_eq!(ProviderType::Claude.as_str(), "claude");
|
||
assert_eq!(ProviderType::ClaudeAuth.as_str(), "claude_auth");
|
||
assert_eq!(ProviderType::Codex.as_str(), "codex");
|
||
assert_eq!(ProviderType::Gemini.as_str(), "gemini");
|
||
assert_eq!(ProviderType::GeminiCli.as_str(), "gemini_cli");
|
||
assert_eq!(ProviderType::OpenRouter.as_str(), "openrouter");
|
||
}
|
||
|
||
#[test]
|
||
fn test_provider_type_serde() {
|
||
// Test serialization
|
||
let claude = ProviderType::Claude;
|
||
let serialized = serde_json::to_string(&claude).unwrap();
|
||
assert_eq!(serialized, "\"claude\"");
|
||
|
||
let claude_auth = ProviderType::ClaudeAuth;
|
||
let serialized = serde_json::to_string(&claude_auth).unwrap();
|
||
assert_eq!(serialized, "\"claude_auth\"");
|
||
|
||
// Test deserialization
|
||
let deserialized: ProviderType = serde_json::from_str("\"claude\"").unwrap();
|
||
assert_eq!(deserialized, ProviderType::Claude);
|
||
|
||
let deserialized: ProviderType = serde_json::from_str("\"gemini_cli\"").unwrap();
|
||
assert_eq!(deserialized, ProviderType::GeminiCli);
|
||
}
|
||
|
||
#[test]
|
||
fn test_from_app_type_claude_direct() {
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||
"ANTHROPIC_AUTH_TOKEN": "sk-ant-test"
|
||
}
|
||
}));
|
||
|
||
let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider);
|
||
assert_eq!(provider_type, ProviderType::Claude);
|
||
}
|
||
|
||
#[test]
|
||
fn test_from_app_type_claude_openrouter() {
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
|
||
"OPENROUTER_API_KEY": "sk-or-test"
|
||
}
|
||
}));
|
||
|
||
let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider);
|
||
assert_eq!(provider_type, ProviderType::OpenRouter);
|
||
}
|
||
|
||
#[test]
|
||
fn test_from_app_type_claude_auth() {
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
|
||
"ANTHROPIC_AUTH_TOKEN": "sk-test"
|
||
},
|
||
"auth_mode": "bearer_only"
|
||
}));
|
||
|
||
let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider);
|
||
assert_eq!(provider_type, ProviderType::ClaudeAuth);
|
||
}
|
||
|
||
#[test]
|
||
fn test_from_app_type_codex() {
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"OPENAI_API_KEY": "sk-test"
|
||
}
|
||
}));
|
||
|
||
let provider_type = ProviderType::from_app_type_and_config(&AppType::Codex, &provider);
|
||
assert_eq!(provider_type, ProviderType::Codex);
|
||
}
|
||
|
||
#[test]
|
||
fn test_from_app_type_gemini_api_key() {
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"GEMINI_API_KEY": "AIza-test-key"
|
||
}
|
||
}));
|
||
|
||
let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider);
|
||
assert_eq!(provider_type, ProviderType::Gemini);
|
||
}
|
||
|
||
#[test]
|
||
fn test_from_app_type_gemini_cli_oauth() {
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"GEMINI_API_KEY": "ya29.test-access-token"
|
||
}
|
||
}));
|
||
|
||
let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider);
|
||
assert_eq!(provider_type, ProviderType::GeminiCli);
|
||
}
|
||
|
||
#[test]
|
||
fn test_from_app_type_gemini_cli_json() {
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"GEMINI_API_KEY": "{\"access_token\":\"ya29.test\",\"refresh_token\":\"1//test\"}"
|
||
}
|
||
}));
|
||
|
||
let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider);
|
||
assert_eq!(provider_type, ProviderType::GeminiCli);
|
||
}
|
||
|
||
#[test]
|
||
fn test_get_adapter_for_provider_type() {
|
||
let adapter = get_adapter_for_provider_type(&ProviderType::Claude);
|
||
assert_eq!(adapter.name(), "Claude");
|
||
|
||
let adapter = get_adapter_for_provider_type(&ProviderType::ClaudeAuth);
|
||
assert_eq!(adapter.name(), "Claude");
|
||
|
||
let adapter = get_adapter_for_provider_type(&ProviderType::OpenRouter);
|
||
assert_eq!(adapter.name(), "Claude");
|
||
|
||
let adapter = get_adapter_for_provider_type(&ProviderType::Codex);
|
||
assert_eq!(adapter.name(), "Codex");
|
||
|
||
let adapter = get_adapter_for_provider_type(&ProviderType::Gemini);
|
||
assert_eq!(adapter.name(), "Gemini");
|
||
|
||
let adapter = get_adapter_for_provider_type(&ProviderType::GeminiCli);
|
||
assert_eq!(adapter.name(), "Gemini");
|
||
}
|
||
}
|