From 1e3a978ecbdeb0114a183186f620dfaa95f76070 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 11 Dec 2025 20:10:21 +0800 Subject: [PATCH 01/18] fix(proxy): wait for server shutdown before exiting app The previous cleanup logic only sent a shutdown signal but didn't wait for the proxy server to actually stop. This caused a race condition where the app would exit before cleanup completed, leaving Live configs in an inconsistent state. Changes: - Add `server_handle` field to ProxyServer to track the spawned task - Modify `stop()` to wait for server task completion (5s timeout) - Add 100ms delay before process exit to ensure I/O flush - Export ProxyService and fix test files that were missing proxy_service field --- src-tauri/src/commands/misc.rs | 2 +- src-tauri/src/lib.rs | 8 ++++++-- src-tauri/src/proxy/server.rs | 24 +++++++++++++++++++++--- src-tauri/tests/deeplink_import.rs | 10 +++++----- src-tauri/tests/support.rs | 12 +++++++----- 5 files changed, 40 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index f5a1b6bcc..50f4f7f0f 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -232,7 +232,7 @@ fn scan_cli_version(tool: &str) -> (Option, Option) { // 构建 PATH 环境变量,确保 node 可被找到 let current_path = std::env::var("PATH").unwrap_or_default(); let new_path = format!("{}:{}", path.display(), current_path); - + let output = Command::new(&tool_path) .arg("--version") .env("PATH", &new_path) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c791a0a28..e0faff175 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -39,8 +39,8 @@ pub use mcp::{ }; pub use provider::{Provider, ProviderMeta}; pub use services::{ - ConfigService, EndpointLatency, McpService, PromptService, ProviderService, SkillService, - SpeedtestService, + ConfigService, EndpointLatency, McpService, PromptService, ProviderService, ProxyService, + SkillService, SpeedtestService, }; pub use settings::{update_settings, AppSettings}; pub use store::AppState; @@ -697,6 +697,10 @@ pub fn run() { tauri::async_runtime::spawn(async move { cleanup_before_exit(&app_handle).await; log::info!("清理完成,退出应用"); + + // 短暂等待确保所有 I/O 操作(如数据库写入)刷新到磁盘 + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + // 使用 std::process::exit 避免再次触发 ExitRequested std::process::exit(0); }); diff --git a/src-tauri/src/proxy/server.rs b/src-tauri/src/proxy/server.rs index b66b0026b..7c60ecf3b 100644 --- a/src-tauri/src/proxy/server.rs +++ b/src-tauri/src/proxy/server.rs @@ -11,6 +11,7 @@ use axum::{ use std::net::SocketAddr; use std::sync::Arc; use tokio::sync::{oneshot, RwLock}; +use tokio::task::JoinHandle; use tower_http::cors::{Any, CorsLayer}; /// 代理服务器状态(共享) @@ -29,6 +30,8 @@ pub struct ProxyServer { config: ProxyConfig, state: ProxyState, shutdown_tx: Arc>>>, + /// 服务器任务句柄,用于等待服务器实际关闭 + server_handle: Arc>>>, } impl ProxyServer { @@ -45,6 +48,7 @@ impl ProxyServer { config, state, shutdown_tx: Arc::new(RwLock::new(None)), + server_handle: Arc::new(RwLock::new(None)), } } @@ -87,7 +91,7 @@ impl ProxyServer { // 启动服务器 let state = self.state.clone(); - tokio::spawn(async move { + let handle = tokio::spawn(async move { axum::serve(listener, app) .with_graceful_shutdown(async { shutdown_rx.await.ok(); @@ -100,6 +104,9 @@ impl ProxyServer { *state.start_time.write().await = None; }); + // 保存服务器任务句柄 + *self.server_handle.write().await = Some(handle); + Ok(ProxyServerInfo { address: self.config.listen_address.clone(), port: self.config.listen_port, @@ -108,12 +115,23 @@ impl ProxyServer { } pub async fn stop(&self) -> Result<(), ProxyError> { + // 1. 发送关闭信号 if let Some(tx) = self.shutdown_tx.write().await.take() { let _ = tx.send(()); - Ok(()) } else { - Err(ProxyError::NotRunning) + return Err(ProxyError::NotRunning); } + + // 2. 等待服务器任务结束(带 5 秒超时保护) + if let Some(handle) = self.server_handle.write().await.take() { + match tokio::time::timeout(std::time::Duration::from_secs(5), handle).await { + Ok(Ok(())) => log::info!("代理服务器已完全停止"), + Ok(Err(e)) => log::warn!("代理服务器任务异常终止: {e}"), + Err(_) => log::warn!("代理服务器停止超时(5秒),强制继续"), + } + } + + Ok(()) } pub async fn get_status(&self) -> ProxyStatus { diff --git a/src-tauri/tests/deeplink_import.rs b/src-tauri/tests/deeplink_import.rs index 2768f8953..9a349d20a 100644 --- a/src-tauri/tests/deeplink_import.rs +++ b/src-tauri/tests/deeplink_import.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use cc_switch_lib::{import_provider_from_deeplink, parse_deeplink_url, AppState, Database}; +use cc_switch_lib::{import_provider_from_deeplink, parse_deeplink_url, AppState, Database, ProxyService}; #[path = "support.rs"] mod support; @@ -16,8 +16,8 @@ fn deeplink_import_claude_provider_persists_to_db() { let request = parse_deeplink_url(url).expect("parse deeplink url"); let db = Arc::new(Database::memory().expect("create memory db")); - - let state = AppState { db: db.clone() }; + let proxy_service = ProxyService::new(db.clone()); + let state = AppState { db: db.clone(), proxy_service }; let provider_id = import_provider_from_deeplink(&state, request.clone()) .expect("import provider from deeplink"); @@ -53,8 +53,8 @@ fn deeplink_import_codex_provider_builds_auth_and_config() { let request = parse_deeplink_url(url).expect("parse deeplink url"); let db = Arc::new(Database::memory().expect("create memory db")); - - let state = AppState { db: db.clone() }; + let proxy_service = ProxyService::new(db.clone()); + let state = AppState { db: db.clone(), proxy_service }; let provider_id = import_provider_from_deeplink(&state, request.clone()) .expect("import provider from deeplink"); diff --git a/src-tauri/tests/support.rs b/src-tauri/tests/support.rs index b954bad79..bbe603c44 100644 --- a/src-tauri/tests/support.rs +++ b/src-tauri/tests/support.rs @@ -1,7 +1,7 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; -use cc_switch_lib::{update_settings, AppSettings, AppState, Database, MultiAppConfig}; +use cc_switch_lib::{update_settings, AppSettings, AppState, Database, MultiAppConfig, ProxyService}; /// 为测试设置隔离的 HOME 目录,避免污染真实用户数据。 pub fn ensure_test_home() -> &'static Path { @@ -48,15 +48,17 @@ pub fn test_mutex() -> &'static Mutex<()> { /// 创建测试用的 AppState,包含一个空的数据库 pub fn create_test_state() -> Result> { - let db = Database::init()?; - Ok(AppState { db: Arc::new(db) }) + let db = Arc::new(Database::init()?); + let proxy_service = ProxyService::new(db.clone()); + Ok(AppState { db, proxy_service }) } /// 创建测试用的 AppState,并从 MultiAppConfig 迁移数据 pub fn create_test_state_with_config( config: &MultiAppConfig, ) -> Result> { - let db = Database::init()?; + let db = Arc::new(Database::init()?); db.migrate_from_json(config)?; - Ok(AppState { db: Arc::new(db) }) + let proxy_service = ProxyService::new(db.clone()); + Ok(AppState { db, proxy_service }) } From 1926af49886c145e457aef6ec1d374df03d30f2e Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 11 Dec 2025 21:14:22 +0800 Subject: [PATCH 02/18] fix(proxy): update live backup when hot-switching provider in proxy mode When proxy is active, switching providers only updated the database flags but not the live backup. This caused the wrong provider config to be restored when stopping the proxy. Added `update_live_backup_from_provider()` method to ProxyService that generates backup from provider's settings_config instead of reading from live files (which are already taken over by proxy). --- src-tauri/src/services/provider/mod.rs | 13 ++++++++ src-tauri/src/services/proxy.rs | 44 ++++++++++++++++++++++++++ src-tauri/tests/deeplink_import.rs | 14 ++++++-- src-tauri/tests/support.rs | 4 ++- 4 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index bc6be2206..2d62d5b0a 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -206,6 +206,11 @@ impl ProviderService { id ); + // 获取新供应商的完整配置(用于更新备份) + let provider = providers + .get(id) + .ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?; + // Update database is_current state.db.set_current_provider(app_type.as_str(), id)?; @@ -215,6 +220,14 @@ impl ProviderService { // Update local settings for consistency crate::settings::set_current_provider(&app_type, Some(id))?; + // 更新 Live 备份(确保代理关闭时恢复正确的供应商配置) + futures::executor::block_on( + state + .proxy_service + .update_live_backup_from_provider(app_type.as_str(), provider), + ) + .map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?; + // Note: No Live config write, no MCP sync // The proxy server will route requests to the new provider via is_proxy_target return Ok(()); diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 111154f72..aff8bbc00 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -5,6 +5,7 @@ use crate::app_config::AppType; use crate::config::{get_claude_settings_path, read_json_file, write_json_file}; use crate::database::Database; +use crate::provider::Provider; use crate::proxy::server::ProxyServer; use crate::proxy::types::*; use serde_json::{json, Value}; @@ -434,6 +435,49 @@ impl ProxyService { .map_err(|e| format!("检查接管状态失败: {e}")) } + /// 从供应商配置更新 Live 备份(用于代理模式下的热切换) + /// + /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份, + /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。 + pub async fn update_live_backup_from_provider( + &self, + app_type: &str, + provider: &Provider, + ) -> Result<(), String> { + let backup_json = match app_type { + "claude" => { + // Claude: settings_config 直接作为备份 + serde_json::to_string(&provider.settings_config) + .map_err(|e| format!("序列化 Claude 配置失败: {e}"))? + } + "codex" => { + // Codex: settings_config 包含 {"auth": ..., "config": ...},直接使用 + serde_json::to_string(&provider.settings_config) + .map_err(|e| format!("序列化 Codex 配置失败: {e}"))? + } + "gemini" => { + // Gemini: 只提取 env 字段(与原始备份格式一致) + // proxy.rs 的 read_gemini_live() 返回 {"env": {...}} + let env_backup = if let Some(env) = provider.settings_config.get("env") { + json!({ "env": env }) + } else { + json!({ "env": {} }) + }; + serde_json::to_string(&env_backup) + .map_err(|e| format!("序列化 Gemini 配置失败: {e}"))? + } + _ => return Err(format!("未知的应用类型: {app_type}")), + }; + + self.db + .save_live_backup(app_type, &backup_json) + .await + .map_err(|e| format!("更新 {app_type} 备份失败: {e}"))?; + + log::info!("已更新 {app_type} Live 备份(热切换)"); + Ok(()) + } + /// 代理模式下切换供应商(热切换,不写 Live) pub async fn switch_proxy_target( &self, diff --git a/src-tauri/tests/deeplink_import.rs b/src-tauri/tests/deeplink_import.rs index 9a349d20a..5a0238c61 100644 --- a/src-tauri/tests/deeplink_import.rs +++ b/src-tauri/tests/deeplink_import.rs @@ -1,6 +1,8 @@ use std::sync::Arc; -use cc_switch_lib::{import_provider_from_deeplink, parse_deeplink_url, AppState, Database, ProxyService}; +use cc_switch_lib::{ + import_provider_from_deeplink, parse_deeplink_url, AppState, Database, ProxyService, +}; #[path = "support.rs"] mod support; @@ -17,7 +19,10 @@ fn deeplink_import_claude_provider_persists_to_db() { let db = Arc::new(Database::memory().expect("create memory db")); let proxy_service = ProxyService::new(db.clone()); - let state = AppState { db: db.clone(), proxy_service }; + let state = AppState { + db: db.clone(), + proxy_service, + }; let provider_id = import_provider_from_deeplink(&state, request.clone()) .expect("import provider from deeplink"); @@ -54,7 +59,10 @@ fn deeplink_import_codex_provider_builds_auth_and_config() { let db = Arc::new(Database::memory().expect("create memory db")); let proxy_service = ProxyService::new(db.clone()); - let state = AppState { db: db.clone(), proxy_service }; + let state = AppState { + db: db.clone(), + proxy_service, + }; let provider_id = import_provider_from_deeplink(&state, request.clone()) .expect("import provider from deeplink"); diff --git a/src-tauri/tests/support.rs b/src-tauri/tests/support.rs index bbe603c44..bc5c8b716 100644 --- a/src-tauri/tests/support.rs +++ b/src-tauri/tests/support.rs @@ -1,7 +1,9 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; -use cc_switch_lib::{update_settings, AppSettings, AppState, Database, MultiAppConfig, ProxyService}; +use cc_switch_lib::{ + update_settings, AppSettings, AppState, Database, MultiAppConfig, ProxyService, +}; /// 为测试设置隔离的 HOME 目录,避免污染真实用户数据。 pub fn ensure_test_home() -> &'static Path { From ebe2a665ae2fec35777d4ae759f6710faeeb14ef Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 11 Dec 2025 23:22:05 +0800 Subject: [PATCH 03/18] refactor(proxy): modularize handlers.rs to reduce code duplication Extract common request handling logic into dedicated modules: - handler_config.rs: Usage parser configurations for each API type - handler_context.rs: Request lifecycle context management - response_processor.rs: Unified streaming/non-streaming response handling Reduces handlers.rs from ~1130 lines to ~418 lines (-63%), eliminating repeated initialization and response processing patterns across the four API handlers (Claude, Codex Chat, Codex Responses, Gemini). --- src-tauri/src/proxy/handler_config.rs | 164 +++ src-tauri/src/proxy/handler_context.rs | 128 ++ src-tauri/src/proxy/handlers.rs | 1414 +++++---------------- src-tauri/src/proxy/mod.rs | 3 + src-tauri/src/proxy/response_processor.rs | 411 ++++++ 5 files changed, 1057 insertions(+), 1063 deletions(-) create mode 100644 src-tauri/src/proxy/handler_config.rs create mode 100644 src-tauri/src/proxy/handler_context.rs create mode 100644 src-tauri/src/proxy/response_processor.rs diff --git a/src-tauri/src/proxy/handler_config.rs b/src-tauri/src/proxy/handler_config.rs new file mode 100644 index 000000000..5710f0b48 --- /dev/null +++ b/src-tauri/src/proxy/handler_config.rs @@ -0,0 +1,164 @@ +//! Handler 配置模块 +//! +//! 定义各 API 处理器的配置结构和使用量解析器 + +use crate::app_config::AppType; +use crate::proxy::usage::parser::TokenUsage; +use serde_json::Value; + +/// 使用量解析器类型别名 +pub type StreamUsageParser = fn(&[Value]) -> Option; +pub type ResponseUsageParser = fn(&Value) -> Option; + +/// 模型提取器类型别名 +/// 参数: (流式事件列表, 请求中的模型名称) -> 最终使用的模型名称 +pub type StreamModelExtractor = fn(&[Value], &str) -> String; + +/// 各 API 的使用量解析配置 +#[derive(Clone, Copy)] +pub struct UsageParserConfig { + /// 流式响应解析器 + pub stream_parser: StreamUsageParser, + /// 非流式响应解析器 + pub response_parser: ResponseUsageParser, + /// 流式响应中的模型提取器 + pub model_extractor: StreamModelExtractor, + /// 应用类型字符串(用于日志记录) + pub app_type_str: &'static str, +} + +// ============================================================================ +// 模型提取器实现 +// ============================================================================ + +/// Claude 流式响应模型提取(直接使用请求模型) +fn claude_model_extractor(_events: &[Value], request_model: &str) -> String { + request_model.to_string() +} + +/// OpenAI Chat Completions 流式响应模型提取 +fn openai_model_extractor(events: &[Value], request_model: &str) -> String { + events + .iter() + .find_map(|e| e.get("model")?.as_str()) + .unwrap_or(request_model) + .to_string() +} + +/// Codex Responses API 流式响应模型提取 +fn codex_model_extractor(events: &[Value], request_model: &str) -> String { + events + .iter() + .find_map(|e| { + if e.get("type")?.as_str()? == "response.completed" { + e.get("response")?.get("model")?.as_str() + } else { + None + } + }) + .unwrap_or(request_model) + .to_string() +} + +/// Gemini 流式响应模型提取(优先使用 usage.model) +fn gemini_model_extractor(events: &[Value], request_model: &str) -> String { + // 首先尝试从解析的 usage 中获取模型 + if let Some(usage) = TokenUsage::from_gemini_stream_chunks(events) { + if let Some(model) = usage.model { + return model; + } + } + request_model.to_string() +} + +// ============================================================================ +// 预定义配置 +// ============================================================================ + +/// Claude API 解析配置 +pub const CLAUDE_PARSER_CONFIG: UsageParserConfig = UsageParserConfig { + stream_parser: TokenUsage::from_claude_stream_events, + response_parser: TokenUsage::from_claude_response, + model_extractor: claude_model_extractor, + app_type_str: "claude", +}; + +/// OpenAI Chat Completions API 解析配置(用于 Codex /v1/chat/completions) +pub const OPENAI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig { + stream_parser: TokenUsage::from_openai_stream_events, + response_parser: TokenUsage::from_openai_response, + model_extractor: openai_model_extractor, + app_type_str: "codex", +}; + +/// Codex Responses API 解析配置(用于 /v1/responses) +pub const CODEX_PARSER_CONFIG: UsageParserConfig = UsageParserConfig { + stream_parser: TokenUsage::from_codex_stream_events, + response_parser: TokenUsage::from_codex_response, + model_extractor: codex_model_extractor, + app_type_str: "codex", +}; + +/// Gemini API 解析配置 +pub const GEMINI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig { + stream_parser: TokenUsage::from_gemini_stream_chunks, + response_parser: TokenUsage::from_gemini_response, + model_extractor: gemini_model_extractor, + app_type_str: "gemini", +}; + +// ============================================================================ +// Handler 配置(预留,用于进一步简化) +// ============================================================================ + +/// Handler 基础配置 +/// +/// 预留结构,可用于进一步统一各 handler 的配置 +#[allow(dead_code)] +#[derive(Clone)] +pub struct HandlerConfig { + /// 应用类型 + pub app_type: AppType, + /// 日志标签 + pub tag: &'static str, + /// 应用类型字符串 + pub app_type_str: &'static str, + /// 使用量解析配置 + pub parser_config: &'static UsageParserConfig, +} + +/// Claude Handler 配置 +#[allow(dead_code)] +pub const CLAUDE_HANDLER_CONFIG: HandlerConfig = HandlerConfig { + app_type: AppType::Claude, + tag: "Claude", + app_type_str: "claude", + parser_config: &CLAUDE_PARSER_CONFIG, +}; + +/// Codex Chat Completions Handler 配置 +#[allow(dead_code)] +pub const CODEX_CHAT_HANDLER_CONFIG: HandlerConfig = HandlerConfig { + app_type: AppType::Codex, + tag: "Codex", + app_type_str: "codex", + parser_config: &OPENAI_PARSER_CONFIG, +}; + +/// Codex Responses Handler 配置 +#[allow(dead_code)] +pub const CODEX_RESPONSES_HANDLER_CONFIG: HandlerConfig = HandlerConfig { + app_type: AppType::Codex, + tag: "Codex", + app_type_str: "codex", + parser_config: &CODEX_PARSER_CONFIG, +}; + +/// Gemini Handler 配置 +#[allow(dead_code)] +pub const GEMINI_HANDLER_CONFIG: HandlerConfig = HandlerConfig { + app_type: AppType::Gemini, + tag: "Gemini", + app_type_str: "gemini", + parser_config: &GEMINI_PARSER_CONFIG, +}; diff --git a/src-tauri/src/proxy/handler_context.rs b/src-tauri/src/proxy/handler_context.rs new file mode 100644 index 000000000..08fbbf260 --- /dev/null +++ b/src-tauri/src/proxy/handler_context.rs @@ -0,0 +1,128 @@ +//! 请求上下文模块 +//! +//! 提供请求生命周期的上下文管理,封装通用初始化逻辑 + +use crate::app_config::AppType; +use crate::provider::Provider; +use crate::proxy::{ + forwarder::RequestForwarder, router::ProviderRouter, server::ProxyState, types::ProxyConfig, + ProxyError, +}; +use std::time::Instant; + +/// 请求上下文 +/// +/// 贯穿整个请求生命周期,包含: +/// - 计时信息 +/// - 代理配置 +/// - 选中的 Provider +/// - 请求模型名称 +/// - 日志标签 +pub struct RequestContext { + /// 请求开始时间 + pub start_time: Instant, + /// 代理配置快照 + pub config: ProxyConfig, + /// 选中的 Provider + pub provider: Provider, + /// 请求中的模型名称 + pub request_model: String, + /// 日志标签(如 "Claude"、"Codex"、"Gemini") + pub tag: &'static str, + /// 应用类型字符串(如 "claude"、"codex"、"gemini") + pub app_type_str: &'static str, + /// 应用类型(预留,目前通过 app_type_str 使用) + #[allow(dead_code)] + pub app_type: AppType, +} + +impl RequestContext { + /// 创建请求上下文 + /// + /// # Arguments + /// * `state` - 代理服务器状态 + /// * `body` - 请求体 JSON + /// * `app_type` - 应用类型 + /// * `tag` - 日志标签 + /// * `app_type_str` - 应用类型字符串 + /// + /// # Errors + /// 返回 `ProxyError` 如果 Provider 选择失败 + pub async fn new( + state: &ProxyState, + body: &serde_json::Value, + app_type: AppType, + tag: &'static str, + app_type_str: &'static str, + ) -> Result { + let start_time = Instant::now(); + let config = state.config.read().await.clone(); + + // 从请求体提取模型名称 + let request_model = body + .get("model") + .and_then(|m| m.as_str()) + .unwrap_or("unknown") + .to_string(); + + // Provider 选择 + let router = ProviderRouter::new(state.db.clone()); + let provider = router.select_provider(&app_type, &[]).await?; + + log::info!( + "[{}] Provider: {}, model: {}", + tag, + provider.name, + request_model + ); + + Ok(Self { + start_time, + config, + provider, + request_model, + tag, + app_type_str, + app_type, + }) + } + + /// 从 URI 提取模型名称(Gemini 专用) + /// + /// Gemini API 的模型名称在 URI 中,格式如: + /// `/v1beta/models/gemini-pro:generateContent` + pub fn with_model_from_uri(mut self, uri: &axum::http::Uri) -> Self { + let endpoint = uri + .path_and_query() + .map(|pq| pq.as_str()) + .unwrap_or(uri.path()); + + self.request_model = endpoint + .split('/') + .find(|s| s.starts_with("models/")) + .and_then(|s| s.strip_prefix("models/")) + .map(|s| s.split(':').next().unwrap_or(s)) + .unwrap_or("unknown") + .to_string(); + + log::info!("[{}] 从 URI 提取模型: {}", self.tag, self.request_model); + self + } + + /// 创建 RequestForwarder + pub fn create_forwarder(&self, state: &ProxyState) -> RequestForwarder { + RequestForwarder::new( + state.db.clone(), + self.config.request_timeout, + self.config.max_retries, + state.status.clone(), + state.current_providers.clone(), + ) + } + + /// 计算请求延迟(毫秒) + #[inline] + pub fn latency_ms(&self) -> u64 { + self.start_time.elapsed().as_millis() as u64 + } +} diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 679fd2a73..6e4704cae 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -1,87 +1,369 @@ //! 请求处理器 //! //! 处理各种API端点的HTTP请求 +//! +//! 重构后的结构: +//! - 通用逻辑提取到 `handler_context` 和 `response_processor` 模块 +//! - 各 handler 只保留独特的业务逻辑 +//! - Claude 的格式转换逻辑保留在此文件(独有功能) use super::{ - forwarder::RequestForwarder, - providers::{get_adapter, transform, ProviderType}, + handler_config::{ + CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG, + }, + handler_context::RequestContext, + providers::{get_adapter, streaming::create_anthropic_sse_stream, transform}, + response_processor::{ + create_logged_passthrough_stream, process_response, SseUsageCollector, + }, server::ProxyState, - session::ProxySession, types::*, - usage::{logger::UsageLogger, parser::TokenUsage}, + usage::parser::TokenUsage, ProxyError, }; use crate::app_config::AppType; use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; -use bytes::Bytes; -use futures::stream::{Stream, StreamExt}; use rust_decimal::Decimal; use serde_json::{json, Value}; -use std::{ - str::FromStr, - sync::{ - atomic::{AtomicBool, Ordering}, - Arc, - }, -}; -use tokio::sync::Mutex; +use std::str::FromStr; -/// 记录请求使用量(带 ProxySession 支持) -#[allow(dead_code, clippy::too_many_arguments)] -async fn log_usage_with_session( - state: &ProxyState, - session: &ProxySession, - provider_id: &str, - app_type: &str, - usage: TokenUsage, - latency_ms: u64, - first_token_ms: Option, - status_code: u16, - provider_type: Option<&ProviderType>, -) { - let logger = UsageLogger::new(&state.db); +// ============================================================================ +// 健康检查和状态查询(简单端点) +// ============================================================================ - // 获取 provider 的 cost_multiplier - let multiplier = match state.db.get_provider_by_id(provider_id, app_type) { - Ok(Some(p)) => { - if let Some(meta) = p.meta { - if let Some(cm) = meta.cost_multiplier { - Decimal::from_str(&cm).unwrap_or(Decimal::from(1)) - } else { - Decimal::from(1) - } - } else { - Decimal::from(1) - } - } - _ => Decimal::from(1), - }; - - let model = session - .model - .clone() - .unwrap_or_else(|| "unknown".to_string()); - let provider_type_str = provider_type.map(|pt| pt.as_str().to_string()); - - if let Err(e) = logger.log_with_calculation( - session.session_id.clone(), - provider_id.to_string(), - app_type.to_string(), - model, - usage, - multiplier, - latency_ms, - first_token_ms, - status_code, - Some(session.session_id.clone()), - provider_type_str, - session.is_streaming, - ) { - log::warn!("记录使用量失败: {e}"); - } +/// 健康检查 +pub async fn health_check() -> (StatusCode, Json) { + ( + StatusCode::OK, + Json(json!({ + "status": "healthy", + "timestamp": chrono::Utc::now().to_rfc3339(), + })), + ) } -/// 记录请求使用量(兼容旧接口) +/// 获取服务状态 +pub async fn get_status(State(state): State) -> Result, ProxyError> { + let status = state.status.read().await.clone(); + Ok(Json(status)) +} + +// ============================================================================ +// Claude API 处理器(包含格式转换逻辑) +// ============================================================================ + +/// 处理 /v1/messages 请求(Claude API) +/// +/// Claude 处理器包含独特的格式转换逻辑: +/// - 当使用 OpenRouter 等中转服务时,需要将 Anthropic 格式转换为 OpenAI 格式 +/// - 响应需要从 OpenAI 格式转回 Anthropic 格式 +pub async fn handle_messages( + State(state): State, + headers: axum::http::HeaderMap, + Json(body): Json, +) -> Result { + let ctx = + RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?; + + // 检查是否需要格式转换(OpenRouter 等中转服务) + let adapter = get_adapter(&AppType::Claude); + let needs_transform = adapter.needs_transform(&ctx.provider); + + let is_stream = body + .get("stream") + .and_then(|s| s.as_bool()) + .unwrap_or(false); + + log::info!( + "[Claude] Provider: {}, needs_transform: {}, is_stream: {}", + ctx.provider.name, + needs_transform, + is_stream + ); + + // 转发请求 + let forwarder = ctx.create_forwarder(&state); + let response = forwarder + .forward_with_retry(&AppType::Claude, "/v1/messages", body.clone(), headers) + .await?; + + let status = response.status(); + log::info!("[Claude] 上游响应状态: {status}"); + + // Claude 特有:格式转换处理 + if needs_transform { + return handle_claude_transform(response, &ctx, &state, &body, is_stream).await; + } + + // 通用响应处理(透传模式) + process_response(response, &ctx, &state, &CLAUDE_PARSER_CONFIG).await +} + +/// Claude 格式转换处理(独有逻辑) +/// +/// 处理 OpenRouter 等需要格式转换的中转服务 +async fn handle_claude_transform( + response: reqwest::Response, + ctx: &RequestContext, + state: &ProxyState, + _original_body: &Value, + is_stream: bool, +) -> Result { + let status = response.status(); + + if is_stream { + // 流式响应转换 (OpenAI SSE → Anthropic SSE) + log::info!("[Claude] 开始流式响应转换 (OpenAI SSE → Anthropic SSE)"); + + let stream = response.bytes_stream(); + let sse_stream = create_anthropic_sse_stream(stream); + + // 创建使用量收集器 + let usage_collector = { + let state = state.clone(); + let provider_id = ctx.provider.id.clone(); + let model = ctx.request_model.clone(); + let status_code = status.as_u16(); + let start_time = ctx.start_time; + + SseUsageCollector::new(start_time, move |events, first_token_ms| { + if let Some(usage) = TokenUsage::from_claude_stream_events(&events) { + let latency_ms = start_time.elapsed().as_millis() as u64; + let state = state.clone(); + let provider_id = provider_id.clone(); + let model = model.clone(); + + tokio::spawn(async move { + log_usage( + &state, + &provider_id, + "claude", + &model, + usage, + latency_ms, + first_token_ms, + true, + status_code, + ) + .await; + }); + } else { + log::debug!("[Claude] OpenRouter 流式响应缺少 usage 统计,跳过消费记录"); + } + }) + }; + + let logged_stream = create_logged_passthrough_stream( + sse_stream, + "Claude/OpenRouter", + Some(usage_collector), + ); + + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + "Content-Type", + axum::http::HeaderValue::from_static("text/event-stream"), + ); + headers.insert( + "Cache-Control", + axum::http::HeaderValue::from_static("no-cache"), + ); + headers.insert( + "Connection", + axum::http::HeaderValue::from_static("keep-alive"), + ); + + let body = axum::body::Body::from_stream(logged_stream); + log::info!("[Claude] ====== 请求结束 (流式转换) ======"); + return Ok((headers, body).into_response()); + } + + // 非流式响应转换 (OpenAI → Anthropic) + log::info!("[Claude] 开始转换响应 (OpenAI → Anthropic)"); + + let response_headers = response.headers().clone(); + + let body_bytes = response.bytes().await.map_err(|e| { + log::error!("[Claude] 读取响应体失败: {e}"); + ProxyError::ForwardFailed(format!("Failed to read response body: {e}")) + })?; + + let body_str = String::from_utf8_lossy(&body_bytes); + log::info!("[Claude] OpenAI 响应长度: {} bytes", body_bytes.len()); + log::debug!("[Claude] OpenAI 原始响应: {body_str}"); + + let openai_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| { + log::error!("[Claude] 解析 OpenAI 响应失败: {e}, body: {body_str}"); + ProxyError::TransformError(format!("Failed to parse OpenAI response: {e}")) + })?; + + log::info!("[Claude] 解析 OpenAI 响应成功"); + log::info!( + "[Claude] <<< OpenAI 响应 JSON:\n{}", + serde_json::to_string_pretty(&openai_response).unwrap_or_default() + ); + + let anthropic_response = transform::openai_to_anthropic(openai_response).map_err(|e| { + log::error!("[Claude] 转换响应失败: {e}"); + e + })?; + + log::info!("[Claude] 转换响应成功"); + log::info!( + "[Claude] <<< Anthropic 响应 JSON:\n{}", + serde_json::to_string_pretty(&anthropic_response).unwrap_or_default() + ); + + // 记录使用量 + if let Some(usage) = TokenUsage::from_claude_response(&anthropic_response) { + let model = anthropic_response + .get("model") + .and_then(|m| m.as_str()) + .unwrap_or("unknown"); + let latency_ms = ctx.latency_ms(); + + tokio::spawn({ + let state = state.clone(); + let provider_id = ctx.provider.id.clone(); + let model = model.to_string(); + async move { + log_usage( + &state, + &provider_id, + "claude", + &model, + usage, + latency_ms, + None, + false, + status.as_u16(), + ) + .await; + } + }); + } + + log::info!("[Claude] ====== 请求结束 ======"); + + // 构建响应 + let mut builder = axum::response::Response::builder().status(status); + + for (key, value) in response_headers.iter() { + if key.as_str().to_lowercase() != "content-length" + && key.as_str().to_lowercase() != "transfer-encoding" + { + builder = builder.header(key, value); + } + } + + builder = builder.header("content-type", "application/json"); + + let response_body = serde_json::to_vec(&anthropic_response).map_err(|e| { + log::error!("[Claude] 序列化响应失败: {e}"); + ProxyError::TransformError(format!("Failed to serialize response: {e}")) + })?; + + log::info!( + "[Claude] 返回转换后的响应, 长度: {} bytes", + response_body.len() + ); + + let body = axum::body::Body::from(response_body); + Ok(builder.body(body).unwrap()) +} + +// ============================================================================ +// Codex API 处理器 +// ============================================================================ + +/// 处理 /v1/chat/completions 请求(OpenAI Chat Completions API - Codex CLI) +pub async fn handle_chat_completions( + State(state): State, + headers: axum::http::HeaderMap, + Json(body): Json, +) -> Result { + log::info!("[Codex] ====== /v1/chat/completions 请求开始 ======"); + + let ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?; + + let is_stream = body + .get("stream") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + log::info!( + "[Codex] 请求模型: {}, 流式: {}", + ctx.request_model, + is_stream + ); + + let forwarder = ctx.create_forwarder(&state); + let response = forwarder + .forward_with_retry(&AppType::Codex, "/v1/chat/completions", body, headers) + .await?; + + log::info!("[Codex] 上游响应状态: {}", response.status()); + + process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await +} + +/// 处理 /v1/responses 请求(OpenAI Responses API - Codex CLI 透传) +pub async fn handle_responses( + State(state): State, + headers: axum::http::HeaderMap, + Json(body): Json, +) -> Result { + let ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?; + + let forwarder = ctx.create_forwarder(&state); + let response = forwarder + .forward_with_retry(&AppType::Codex, "/v1/responses", body, headers) + .await?; + + log::info!("[Codex] 上游响应状态: {}", response.status()); + + process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await +} + +// ============================================================================ +// Gemini API 处理器 +// ============================================================================ + +/// 处理 Gemini API 请求(透传,包括查询参数) +pub async fn handle_gemini( + State(state): State, + uri: axum::http::Uri, + headers: axum::http::HeaderMap, + Json(body): Json, +) -> Result { + // Gemini 的模型名称在 URI 中 + let ctx = RequestContext::new(&state, &body, AppType::Gemini, "Gemini", "gemini") + .await? + .with_model_from_uri(&uri); + + // 提取完整的路径和查询参数 + let endpoint = uri + .path_and_query() + .map(|pq| pq.as_str()) + .unwrap_or(uri.path()); + + log::info!("[Gemini] 请求端点: {}", endpoint); + + let forwarder = ctx.create_forwarder(&state); + let response = forwarder + .forward_with_retry(&AppType::Gemini, endpoint, body, headers) + .await?; + + log::info!("[Gemini] 上游响应状态: {}", response.status()); + + process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await +} + +// ============================================================================ +// 使用量记录(保留用于 Claude 转换逻辑) +// ============================================================================ + +/// 记录请求使用量 #[allow(clippy::too_many_arguments)] async fn log_usage( state: &ProxyState, @@ -94,6 +376,8 @@ async fn log_usage( is_streaming: bool, status_code: u16, ) { + use super::usage::logger::UsageLogger; + let logger = UsageLogger::new(&state.db); // 获取 provider 的 cost_multiplier @@ -131,999 +415,3 @@ async fn log_usage( log::warn!("记录使用量失败: {e}"); } } - -type UsageCallbackWithTiming = Arc, Option) + Send + Sync + 'static>; - -#[derive(Clone)] -struct SseUsageCollector { - inner: Arc, -} - -struct SseUsageCollectorInner { - events: Mutex>, - first_event_time: Mutex>, - start_time: std::time::Instant, - on_complete: UsageCallbackWithTiming, - finished: AtomicBool, -} - -impl SseUsageCollector { - fn new( - start_time: std::time::Instant, - callback: impl Fn(Vec, Option) + Send + Sync + 'static, - ) -> Self { - let on_complete: UsageCallbackWithTiming = Arc::new(callback); - Self { - inner: Arc::new(SseUsageCollectorInner { - events: Mutex::new(Vec::new()), - first_event_time: Mutex::new(None), - start_time, - on_complete, - finished: AtomicBool::new(false), - }), - } - } - - async fn push(&self, event: Value) { - // 记录首个事件时间 - { - let mut first_time = self.inner.first_event_time.lock().await; - if first_time.is_none() { - *first_time = Some(std::time::Instant::now()); - } - } - let mut events = self.inner.events.lock().await; - events.push(event); - } - - async fn finish(&self) { - if self.inner.finished.swap(true, Ordering::SeqCst) { - return; - } - - let events = { - let mut guard = self.inner.events.lock().await; - std::mem::take(&mut *guard) - }; - - let first_token_ms = { - let first_time = self.inner.first_event_time.lock().await; - first_time.map(|t| (t - self.inner.start_time).as_millis() as u64) - }; - - (self.inner.on_complete)(events, first_token_ms); - } -} - -/// 创建带日志记录的透传流 -fn create_logged_passthrough_stream( - stream: impl Stream> + Send + 'static, - tag: &'static str, - usage_collector: Option, -) -> impl Stream> + Send { - async_stream::stream! { - let mut buffer = String::new(); - let mut collector = usage_collector; - - tokio::pin!(stream); - - while let Some(chunk) = stream.next().await { - match chunk { - Ok(bytes) => { - let text = String::from_utf8_lossy(&bytes); - buffer.push_str(&text); - - // 尝试解析并记录完整的 SSE 事件 - while let Some(pos) = buffer.find("\n\n") { - let event_text = buffer[..pos].to_string(); - buffer = buffer[pos + 2..].to_string(); - - if !event_text.trim().is_empty() { - // 提取 data 部分并尝试解析为 JSON - for line in event_text.lines() { - if let Some(data) = line.strip_prefix("data: ") { - if data.trim() != "[DONE]" { - if let Ok(json_value) = serde_json::from_str::(data) { - if let Some(c) = &collector { - c.push(json_value.clone()).await; - } - log::info!( - "[{}] <<< SSE 事件:\n{}", - tag, - serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| data.to_string()) - ); - } else { - log::info!("[{tag}] <<< SSE 数据: {data}"); - } - } else { - log::info!("[{tag}] <<< SSE: [DONE]"); - } - } - } - } - } - - yield Ok(bytes); - } - Err(e) => { - log::error!("[{tag}] 流错误: {e}"); - yield Err(std::io::Error::other(e.to_string())); - break; - } - } - } - - log::info!("[{}] ====== 流结束 ======", tag); - - if let Some(c) = collector.take() { - c.finish().await; - } - } -} - -/// 健康检查 -pub async fn health_check() -> (StatusCode, Json) { - ( - StatusCode::OK, - Json(json!({ - "status": "healthy", - "timestamp": chrono::Utc::now().to_rfc3339(), - })), - ) -} - -/// 获取服务状态 -pub async fn get_status(State(state): State) -> Result, ProxyError> { - let status = state.status.read().await.clone(); - Ok(Json(status)) -} - -/// 处理 /v1/messages 请求(Claude API) -pub async fn handle_messages( - State(state): State, - headers: axum::http::HeaderMap, - Json(body): Json, -) -> Result { - let start_time = std::time::Instant::now(); - - let config = state.config.read().await.clone(); - let request_model = body - .get("model") - .and_then(|m| m.as_str()) - .unwrap_or("unknown") - .to_string(); - - // 选择目标 Provider - let router = super::router::ProviderRouter::new(state.db.clone()); - let failed_ids = Vec::new(); - let provider = router - .select_provider(&AppType::Claude, &failed_ids) - .await?; - - // 检查是否需要转换(OpenRouter) - let adapter = get_adapter(&AppType::Claude); - let needs_transform = adapter.needs_transform(&provider); - - // 检查是否是流式请求 - let is_stream = body - .get("stream") - .and_then(|s| s.as_bool()) - .unwrap_or(false); - - log::info!( - "[Claude] Provider: {}, needs_transform: {}, is_stream: {}", - provider.name, - needs_transform, - is_stream - ); - - let forwarder = RequestForwarder::new( - state.db.clone(), - config.request_timeout, - config.max_retries, - state.status.clone(), - state.current_providers.clone(), - ); - - let response = forwarder - .forward_with_retry(&AppType::Claude, "/v1/messages", body, headers) - .await?; - - let status = response.status(); - log::info!("[Claude] 上游响应状态: {status}"); - - // 如果需要转换 - if needs_transform { - if is_stream { - // 流式响应转换 - log::info!("[Claude] 开始流式响应转换 (OpenAI SSE → Anthropic SSE)"); - - let stream = response.bytes_stream(); - let sse_stream = super::providers::streaming::create_anthropic_sse_stream(stream); - - let usage_collector = { - let state = state.clone(); - let provider_id = provider.id.clone(); - let model = request_model.clone(); - let status_code = status.as_u16(); - let start_time_clone = start_time; - SseUsageCollector::new(start_time, move |events, first_token_ms| { - if let Some(usage) = TokenUsage::from_claude_stream_events(&events) { - let latency_ms = start_time_clone.elapsed().as_millis() as u64; - let state = state.clone(); - let provider_id = provider_id.clone(); - let model = model.clone(); - tokio::spawn(async move { - log_usage( - &state, - &provider_id, - "claude", - &model, - usage, - latency_ms, - first_token_ms, - true, // is_streaming - status_code, - ) - .await; - }); - } else { - log::debug!("[Claude] OpenRouter 流式响应缺少 usage 统计,跳过消费记录"); - } - }) - }; - - let logged_stream = create_logged_passthrough_stream( - sse_stream, - "Claude/OpenRouter", - Some(usage_collector), - ); - - let mut headers = axum::http::HeaderMap::new(); - headers.insert( - "Content-Type", - axum::http::HeaderValue::from_static("text/event-stream"), - ); - headers.insert( - "Cache-Control", - axum::http::HeaderValue::from_static("no-cache"), - ); - headers.insert( - "Connection", - axum::http::HeaderValue::from_static("keep-alive"), - ); - - let body = axum::body::Body::from_stream(logged_stream); - log::info!("[Claude] ====== 请求结束 (流式转换) ======"); - return Ok((headers, body).into_response()); - } else { - // 非流式响应转换 - log::info!("[Claude] 开始转换响应 (OpenAI → Anthropic)"); - - let response_headers = response.headers().clone(); - - // 读取响应体 - let body_bytes = response.bytes().await.map_err(|e| { - log::error!("[Claude] 读取响应体失败: {e}"); - ProxyError::ForwardFailed(format!("Failed to read response body: {e}")) - })?; - - let body_str = String::from_utf8_lossy(&body_bytes); - log::info!("[Claude] OpenAI 响应长度: {} bytes", body_bytes.len()); - log::debug!("[Claude] OpenAI 原始响应: {body_str}"); - - // 解析并转换 - let openai_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| { - log::error!("[Claude] 解析 OpenAI 响应失败: {e}, body: {body_str}"); - ProxyError::TransformError(format!("Failed to parse OpenAI response: {e}")) - })?; - - log::info!("[Claude] 解析 OpenAI 响应成功"); - log::info!( - "[Claude] <<< OpenAI 响应 JSON:\n{}", - serde_json::to_string_pretty(&openai_response).unwrap_or_default() - ); - - let anthropic_response = - transform::openai_to_anthropic(openai_response).map_err(|e| { - log::error!("[Claude] 转换响应失败: {e}"); - e - })?; - - log::info!("[Claude] 转换响应成功"); - log::info!( - "[Claude] <<< Anthropic 响应 JSON:\n{}", - serde_json::to_string_pretty(&anthropic_response).unwrap_or_default() - ); - - // 记录使用量 - if let Some(usage) = TokenUsage::from_claude_response(&anthropic_response) { - let model = anthropic_response - .get("model") - .and_then(|m| m.as_str()) - .unwrap_or("unknown"); - let latency_ms = start_time.elapsed().as_millis() as u64; - - tokio::spawn({ - let state = state.clone(); - let provider_id = provider.id.clone(); - let model = model.to_string(); - async move { - log_usage( - &state, - &provider_id, - "claude", - &model, - usage, - latency_ms, - None, - false, - status.as_u16(), - ) - .await; - } - }); - } - - log::info!("[Claude] ====== 请求结束 ======"); - - // 构建响应 - let mut builder = axum::response::Response::builder().status(status); - - // 复制响应头(排除 content-length,因为内容已改变) - for (key, value) in response_headers.iter() { - if key.as_str().to_lowercase() != "content-length" - && key.as_str().to_lowercase() != "transfer-encoding" - { - builder = builder.header(key, value); - } - } - - builder = builder.header("content-type", "application/json"); - - let response_body = serde_json::to_vec(&anthropic_response).map_err(|e| { - log::error!("[Claude] 序列化响应失败: {e}"); - ProxyError::TransformError(format!("Failed to serialize response: {e}")) - })?; - - log::info!( - "[Claude] 返回转换后的响应, 长度: {} bytes", - response_body.len() - ); - - let body = axum::body::Body::from(response_body); - return Ok(builder.body(body).unwrap()); - } - } - - // 透传响应(直连 Anthropic) - log::info!("[Claude] 透传响应模式"); - - // 检查是否流式响应 - let content_type = response - .headers() - .get("content-type") - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - let is_sse = content_type.contains("text/event-stream"); - - if is_sse { - // 流式透传:使用包装流记录 SSE 事件 - log::info!("[Claude] 流式透传响应 (SSE)"); - let mut builder = axum::response::Response::builder().status(status); - - for (key, value) in response.headers() { - builder = builder.header(key, value); - } - - let stream = response - .bytes_stream() - .map(|chunk| chunk.map_err(|e| std::io::Error::other(e.to_string()))); - let usage_collector = { - let state = state.clone(); - let provider_id = provider.id.clone(); - let model = request_model.clone(); - let status_code = status.as_u16(); - let start_time_clone = start_time; - SseUsageCollector::new(start_time, move |events, first_token_ms| { - if let Some(usage) = TokenUsage::from_claude_stream_events(&events) { - let latency_ms = start_time_clone.elapsed().as_millis() as u64; - let state = state.clone(); - let provider_id = provider_id.clone(); - let model = model.clone(); - tokio::spawn(async move { - log_usage( - &state, - &provider_id, - "claude", - &model, - usage, - latency_ms, - first_token_ms, - true, - status_code, - ) - .await; - }); - } else { - log::debug!("[Claude] 流式响应缺少 usage 统计,跳过消费记录"); - } - }) - }; - let logged_stream = - create_logged_passthrough_stream(stream, "Claude", Some(usage_collector)); - - let body = axum::body::Body::from_stream(logged_stream); - log::info!("[Claude] ====== 请求结束 (流式) ======"); - Ok(builder.body(body).unwrap()) - } else { - // 非流式透传:读取完整响应并记录 - let response_headers = response.headers().clone(); - let status = response.status(); - - let body_bytes = response.bytes().await.map_err(|e| { - log::error!("[Claude] 读取透传响应失败: {e}"); - ProxyError::ForwardFailed(format!("Failed to read response body: {e}")) - })?; - - // 记录响应 JSON - if let Ok(json_value) = serde_json::from_slice::(&body_bytes) { - log::info!( - "[Claude] <<< Anthropic 透传响应 JSON:\n{}", - serde_json::to_string_pretty(&json_value).unwrap_or_default() - ); - - // 记录使用量 - if let Some(usage) = TokenUsage::from_claude_response(&json_value) { - let model = json_value - .get("model") - .and_then(|m| m.as_str()) - .unwrap_or("unknown"); - let latency_ms = start_time.elapsed().as_millis() as u64; - - tokio::spawn({ - let state = state.clone(); - let provider_id = provider.id.clone(); - let model = model.to_string(); - async move { - log_usage( - &state, - &provider_id, - "claude", - &model, - usage, - latency_ms, - None, - false, - status.as_u16(), - ) - .await; - } - }); - } - } else { - log::info!( - "[Claude] <<< 透传响应 (非 JSON): {} bytes", - body_bytes.len() - ); - } - log::info!("[Claude] ====== 请求结束 ======"); - - let mut builder = axum::response::Response::builder().status(status); - for (key, value) in response_headers.iter() { - builder = builder.header(key, value); - } - - let body = axum::body::Body::from(body_bytes); - Ok(builder.body(body).unwrap()) - } -} - -/// 处理 Gemini API 请求(透传,包括查询参数) -pub async fn handle_gemini( - State(state): State, - uri: axum::http::Uri, - headers: axum::http::HeaderMap, - Json(body): Json, -) -> Result { - let start_time = std::time::Instant::now(); - - let config = state.config.read().await.clone(); - - // 选择目标 Provider - let router = super::router::ProviderRouter::new(state.db.clone()); - let failed_ids = Vec::new(); - let provider = router - .select_provider(&AppType::Gemini, &failed_ids) - .await?; - - let forwarder = RequestForwarder::new( - state.db.clone(), - config.request_timeout, - config.max_retries, - state.status.clone(), - state.current_providers.clone(), - ); - - // 提取完整的路径和查询参数 - let endpoint = uri - .path_and_query() - .map(|pq| pq.as_str()) - .unwrap_or(uri.path()); - let gemini_model = endpoint - .split('/') - .find(|s| s.starts_with("models/")) - .and_then(|s| s.strip_prefix("models/")) - .map(|s| s.split(':').next().unwrap_or(s)) - .unwrap_or("unknown") - .to_string(); - - log::info!("[Gemini] 请求端点: {endpoint}"); - - let response = forwarder - .forward_with_retry(&AppType::Gemini, endpoint, body, headers) - .await?; - - let status = response.status(); - log::info!("[Gemini] 上游响应状态: {status}"); - - // 检查是否流式响应 - let content_type = response - .headers() - .get("content-type") - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - let is_sse = content_type.contains("text/event-stream"); - - if is_sse { - // 流式透传 - log::info!("[Gemini] 流式透传响应 (SSE)"); - let mut builder = axum::response::Response::builder().status(status); - - for (key, value) in response.headers() { - builder = builder.header(key, value); - } - - let stream = response - .bytes_stream() - .map(|chunk| chunk.map_err(|e| std::io::Error::other(e.to_string()))); - let usage_collector = { - let state = state.clone(); - let provider_id = provider.id.clone(); - let fallback_model = gemini_model.clone(); - let status_code = status.as_u16(); - let start_time_clone = start_time; - SseUsageCollector::new(start_time, move |events, first_token_ms| { - if let Some(usage) = TokenUsage::from_gemini_stream_chunks(&events) { - // 优先使用响应中的实际模型名称,否则使用从 URI 提取的模型名称 - let model = usage - .model - .clone() - .unwrap_or_else(|| fallback_model.clone()); - let latency_ms = start_time_clone.elapsed().as_millis() as u64; - let state = state.clone(); - let provider_id = provider_id.clone(); - tokio::spawn(async move { - log_usage( - &state, - &provider_id, - "gemini", - &model, - usage, - latency_ms, - first_token_ms, - true, - status_code, - ) - .await; - }); - } else { - log::debug!("[Gemini] 流式响应缺少 usage 统计,跳过消费记录"); - } - }) - }; - let logged_stream = - create_logged_passthrough_stream(stream, "Gemini", Some(usage_collector)); - - let body = axum::body::Body::from_stream(logged_stream); - Ok(builder.body(body).unwrap()) - } else { - // 非流式透传 - let response_headers = response.headers().clone(); - let status = response.status(); - - let body_bytes = response.bytes().await.map_err(|e| { - log::error!("[Gemini] 读取响应失败: {e}"); - ProxyError::ForwardFailed(format!("Failed to read response body: {e}")) - })?; - - // 记录响应 JSON - if let Ok(json_value) = serde_json::from_slice::(&body_bytes) { - log::info!( - "[Gemini] <<< 响应 JSON:\n{}", - serde_json::to_string_pretty(&json_value).unwrap_or_default() - ); - - // 记录使用量 - if let Some(usage) = TokenUsage::from_gemini_response(&json_value) { - // 优先使用响应中的实际模型名称,否则使用从 URI 提取的模型名称 - let model = usage.model.clone().unwrap_or_else(|| gemini_model.clone()); - let latency_ms = start_time.elapsed().as_millis() as u64; - tokio::spawn({ - let state = state.clone(); - let provider_id = provider.id.clone(); - async move { - log_usage( - &state, - &provider_id, - "gemini", - &model, - usage, - latency_ms, - None, - false, - status.as_u16(), - ) - .await; - } - }); - } - } else { - log::info!("[Gemini] <<< 响应 (非 JSON): {} bytes", body_bytes.len()); - } - log::info!("[Gemini] ====== 请求结束 ======"); - - let mut builder = axum::response::Response::builder().status(status); - for (key, value) in response_headers.iter() { - builder = builder.header(key, value); - } - - let body = axum::body::Body::from(body_bytes); - Ok(builder.body(body).unwrap()) - } -} - -/// 处理 /v1/responses 请求(OpenAI Responses API - Codex CLI 透传) -pub async fn handle_responses( - State(state): State, - headers: axum::http::HeaderMap, - Json(body): Json, -) -> Result { - let start_time = std::time::Instant::now(); - - let config = state.config.read().await.clone(); - let request_model = body - .get("model") - .and_then(|m| m.as_str()) - .unwrap_or("unknown") - .to_string(); - - // 选择目标 Provider - let router = super::router::ProviderRouter::new(state.db.clone()); - let failed_ids = Vec::new(); - let provider = router.select_provider(&AppType::Codex, &failed_ids).await?; - - let forwarder = RequestForwarder::new( - state.db.clone(), - config.request_timeout, - config.max_retries, - state.status.clone(), - state.current_providers.clone(), - ); - - let response = forwarder - .forward_with_retry(&AppType::Codex, "/v1/responses", body, headers) - .await?; - - let status = response.status(); - log::info!("[Codex] 上游响应状态: {status}"); - - // 检查是否流式响应 - let content_type = response - .headers() - .get("content-type") - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - let is_sse = content_type.contains("text/event-stream"); - - if is_sse { - // 流式透传 - log::info!("[Codex] 流式透传响应 (SSE)"); - let mut builder = axum::response::Response::builder().status(status); - - for (key, value) in response.headers() { - builder = builder.header(key, value); - } - - let stream = response - .bytes_stream() - .map(|chunk| chunk.map_err(|e| std::io::Error::other(e.to_string()))); - let usage_collector = { - let state = state.clone(); - let provider_id = provider.id.clone(); - let request_model = request_model.clone(); - let status_code = status.as_u16(); - let start_time_clone = start_time; - SseUsageCollector::new(start_time, move |events, first_token_ms| { - if let Some(usage) = TokenUsage::from_codex_stream_events(&events) { - // 尝试从事件中提取模型,回退到请求模型 - let model = events - .iter() - .find_map(|e| { - if e.get("type")?.as_str()? == "response.completed" { - e.get("response")?.get("model")?.as_str() - } else { - None - } - }) - .unwrap_or(&request_model) - .to_string(); - let latency_ms = start_time_clone.elapsed().as_millis() as u64; - - let state = state.clone(); - let provider_id = provider_id.clone(); - tokio::spawn(async move { - log_usage( - &state, - &provider_id, - "codex", - &model, - usage, - latency_ms, - first_token_ms, - true, - status_code, - ) - .await; - }); - } else { - log::debug!("[Codex] 流式响应缺少 usage 统计,跳过消费记录"); - } - }) - }; - let logged_stream = - create_logged_passthrough_stream(stream, "Codex", Some(usage_collector)); - - let body = axum::body::Body::from_stream(logged_stream); - Ok(builder.body(body).unwrap()) - } else { - // 非流式透传 - let response_headers = response.headers().clone(); - let status = response.status(); - - let body_bytes = response.bytes().await.map_err(|e| { - log::error!("[Codex] 读取响应失败: {e}"); - ProxyError::ForwardFailed(format!("Failed to read response body: {e}")) - })?; - - // 记录响应 JSON - if let Ok(json_value) = serde_json::from_slice::(&body_bytes) { - log::info!( - "[Codex] <<< 响应 JSON:\n{}", - serde_json::to_string_pretty(&json_value).unwrap_or_default() - ); - - // 记录使用量 - if let Some(usage) = TokenUsage::from_codex_response(&json_value) { - let model = json_value - .get("model") - .and_then(|m| m.as_str()) - .unwrap_or("unknown"); - let latency_ms = start_time.elapsed().as_millis() as u64; - - log::info!( - "[Codex] 解析到 usage: input={}, output={}", - usage.input_tokens, - usage.output_tokens - ); - - tokio::spawn({ - let state = state.clone(); - let provider_id = provider.id.clone(); - let model = model.to_string(); - async move { - log_usage( - &state, - &provider_id, - "codex", - &model, - usage, - latency_ms, - None, - false, - status.as_u16(), - ) - .await; - } - }); - } else { - log::warn!("[Codex] 未能解析 usage 信息,跳过记录"); - } - } else { - log::info!("[Codex] <<< 响应 (非 JSON): {} bytes", body_bytes.len()); - } - log::info!("[Codex] ====== 请求结束 ======"); - - let mut builder = axum::response::Response::builder().status(status); - for (key, value) in response_headers.iter() { - builder = builder.header(key, value); - } - - let body = axum::body::Body::from(body_bytes); - Ok(builder.body(body).unwrap()) - } -} - -/// 处理 /v1/chat/completions 请求(OpenAI Chat Completions API - Codex CLI) -pub async fn handle_chat_completions( - State(state): State, - headers: axum::http::HeaderMap, - Json(body): Json, -) -> Result { - let start_time = std::time::Instant::now(); - log::info!("[Codex] ====== /v1/chat/completions 请求开始 ======"); - - let config = state.config.read().await.clone(); - let request_model = body - .get("model") - .and_then(|m| m.as_str()) - .unwrap_or("unknown") - .to_string(); - let is_stream = body - .get("stream") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - log::info!("[Codex] 请求模型: {request_model}, 流式: {is_stream}"); - - // 选择目标 Provider - let router = super::router::ProviderRouter::new(state.db.clone()); - let failed_ids = Vec::new(); - let provider = router.select_provider(&AppType::Codex, &failed_ids).await?; - - log::info!("[Codex] 选择 Provider: {}", provider.id); - - let forwarder = RequestForwarder::new( - state.db.clone(), - config.request_timeout, - config.max_retries, - state.status.clone(), - state.current_providers.clone(), - ); - - let response = forwarder - .forward_with_retry(&AppType::Codex, "/v1/chat/completions", body, headers) - .await?; - - let status = response.status(); - log::info!("[Codex] 上游响应状态: {status}"); - - // 检查是否流式响应 - let content_type = response - .headers() - .get("content-type") - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - let is_sse = content_type.contains("text/event-stream"); - - if is_sse { - // 流式透传 - log::info!("[Codex] 流式透传响应 (SSE)"); - let mut builder = axum::response::Response::builder().status(status); - - for (key, value) in response.headers() { - builder = builder.header(key, value); - } - - let stream = response - .bytes_stream() - .map(|chunk| chunk.map_err(|e| std::io::Error::other(e.to_string()))); - - let usage_collector = { - let state = state.clone(); - let provider_id = provider.id.clone(); - let request_model = request_model.clone(); - let status_code = status.as_u16(); - let start_time_clone = start_time; - SseUsageCollector::new(start_time, move |events, first_token_ms| { - if let Some(usage) = TokenUsage::from_openai_stream_events(&events) { - let model = events - .iter() - .find_map(|e| e.get("model")?.as_str()) - .unwrap_or(&request_model) - .to_string(); - let latency_ms = start_time_clone.elapsed().as_millis() as u64; - - let state = state.clone(); - let provider_id = provider_id.clone(); - tokio::spawn(async move { - log_usage( - &state, - &provider_id, - "codex", - &model, - usage, - latency_ms, - first_token_ms, - true, - status_code, - ) - .await; - }); - } else { - log::debug!("[Codex] 流式响应缺少 usage 统计,跳过消费记录"); - } - }) - }; - let logged_stream = - create_logged_passthrough_stream(stream, "Codex", Some(usage_collector)); - - let body = axum::body::Body::from_stream(logged_stream); - Ok(builder.body(body).unwrap()) - } else { - // 非流式透传 - let response_headers = response.headers().clone(); - let status = response.status(); - - let body_bytes = response.bytes().await.map_err(|e| { - log::error!("[Codex] 读取响应失败: {e}"); - ProxyError::ForwardFailed(format!("Failed to read response body: {e}")) - })?; - - // 记录响应 JSON - if let Ok(json_value) = serde_json::from_slice::(&body_bytes) { - log::info!( - "[Codex] <<< 响应 JSON:\n{}", - serde_json::to_string_pretty(&json_value).unwrap_or_default() - ); - - // 记录使用量 (OpenAI 格式: prompt_tokens, completion_tokens) - if let Some(usage) = TokenUsage::from_openai_response(&json_value) { - let model = json_value - .get("model") - .and_then(|m| m.as_str()) - .unwrap_or("unknown"); - let latency_ms = start_time.elapsed().as_millis() as u64; - - log::info!( - "[Codex] 解析到 usage: input={}, output={}", - usage.input_tokens, - usage.output_tokens - ); - - tokio::spawn({ - let state = state.clone(); - let provider_id = provider.id.clone(); - let model = model.to_string(); - async move { - log_usage( - &state, - &provider_id, - "codex", - &model, - usage, - latency_ms, - None, - false, - status.as_u16(), - ) - .await; - } - }); - } else { - log::warn!("[Codex] 未能解析 usage 信息,跳过记录"); - } - } else { - log::info!("[Codex] <<< 响应 (非 JSON): {} bytes", body_bytes.len()); - } - log::info!("[Codex] ====== 请求结束 ======"); - - let mut builder = axum::response::Response::builder().status(status); - for (key, value) in response_headers.iter() { - builder = builder.header(key, value); - } - - let body = axum::body::Body::from(body_bytes); - Ok(builder.body(body).unwrap()) - } -} diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index c23d277c1..5b052f0f7 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -5,11 +5,14 @@ pub mod circuit_breaker; pub mod error; mod forwarder; +pub mod handler_config; +pub mod handler_context; mod handlers; mod health; pub mod provider_router; pub mod providers; pub mod response_handler; +pub mod response_processor; mod router; pub(crate) mod server; pub mod session; diff --git a/src-tauri/src/proxy/response_processor.rs b/src-tauri/src/proxy/response_processor.rs new file mode 100644 index 000000000..38b59faeb --- /dev/null +++ b/src-tauri/src/proxy/response_processor.rs @@ -0,0 +1,411 @@ +//! 响应处理器模块 +//! +//! 统一处理流式和非流式 API 响应 + +use super::{ + handler_config::UsageParserConfig, handler_context::RequestContext, server::ProxyState, + usage::parser::TokenUsage, ProxyError, +}; +use axum::response::Response; +use bytes::Bytes; +use futures::stream::{Stream, StreamExt}; +use rust_decimal::Decimal; +use serde_json::Value; +use std::{ + str::FromStr, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, +}; +use tokio::sync::Mutex; + +// ============================================================================ +// 公共接口 +// ============================================================================ + +/// 检测响应是否为 SSE 流式响应 +#[inline] +pub fn is_sse_response(response: &reqwest::Response) -> bool { + response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .map(|ct| ct.contains("text/event-stream")) + .unwrap_or(false) +} + +/// 处理流式响应 +pub async fn handle_streaming( + response: reqwest::Response, + ctx: &RequestContext, + state: &ProxyState, + parser_config: &UsageParserConfig, +) -> Response { + log::info!("[{}] 流式透传响应 (SSE)", ctx.tag); + + let status = response.status(); + let mut builder = axum::response::Response::builder().status(status); + + // 复制响应头 + for (key, value) in response.headers() { + builder = builder.header(key, value); + } + + // 创建字节流 + let stream = response + .bytes_stream() + .map(|chunk| chunk.map_err(|e| std::io::Error::other(e.to_string()))); + + // 创建使用量收集器 + let usage_collector = create_usage_collector(ctx, state, status.as_u16(), parser_config); + + // 创建带日志的透传流 + let logged_stream = create_logged_passthrough_stream(stream, ctx.tag, Some(usage_collector)); + + let body = axum::body::Body::from_stream(logged_stream); + builder.body(body).unwrap() +} + +/// 处理非流式响应 +pub async fn handle_non_streaming( + response: reqwest::Response, + ctx: &RequestContext, + state: &ProxyState, + parser_config: &UsageParserConfig, +) -> Result { + let response_headers = response.headers().clone(); + let status = response.status(); + + // 读取响应体 + let body_bytes = response.bytes().await.map_err(|e| { + log::error!("[{}] 读取响应失败: {e}", ctx.tag); + ProxyError::ForwardFailed(format!("Failed to read response body: {e}")) + })?; + + // 解析并记录使用量 + if let Ok(json_value) = serde_json::from_slice::(&body_bytes) { + log::info!( + "[{}] <<< 响应 JSON:\n{}", + ctx.tag, + serde_json::to_string_pretty(&json_value).unwrap_or_default() + ); + + // 解析使用量 + if let Some(usage) = (parser_config.response_parser)(&json_value) { + let model = json_value + .get("model") + .and_then(|m| m.as_str()) + .unwrap_or(&ctx.request_model); + + spawn_log_usage(state, ctx, usage, model, status.as_u16(), false); + } else { + log::debug!( + "[{}] 未能解析 usage 信息,跳过记录", + parser_config.app_type_str + ); + } + } else { + log::info!( + "[{}] <<< 响应 (非 JSON): {} bytes", + ctx.tag, + body_bytes.len() + ); + } + + log::info!("[{}] ====== 请求结束 ======", ctx.tag); + + // 构建响应 + let mut builder = axum::response::Response::builder().status(status); + for (key, value) in response_headers.iter() { + builder = builder.header(key, value); + } + + let body = axum::body::Body::from(body_bytes); + Ok(builder.body(body).unwrap()) +} + +/// 通用响应处理入口 +/// +/// 根据响应类型自动选择流式或非流式处理 +pub async fn process_response( + response: reqwest::Response, + ctx: &RequestContext, + state: &ProxyState, + parser_config: &UsageParserConfig, +) -> Result { + if is_sse_response(&response) { + Ok(handle_streaming(response, ctx, state, parser_config).await) + } else { + handle_non_streaming(response, ctx, state, parser_config).await + } +} + +// ============================================================================ +// SSE 使用量收集器 +// ============================================================================ + +type UsageCallbackWithTiming = Arc, Option) + Send + Sync + 'static>; + +/// SSE 使用量收集器 +#[derive(Clone)] +pub struct SseUsageCollector { + inner: Arc, +} + +struct SseUsageCollectorInner { + events: Mutex>, + first_event_time: Mutex>, + start_time: std::time::Instant, + on_complete: UsageCallbackWithTiming, + finished: AtomicBool, +} + +impl SseUsageCollector { + /// 创建新的使用量收集器 + pub fn new( + start_time: std::time::Instant, + callback: impl Fn(Vec, Option) + Send + Sync + 'static, + ) -> Self { + let on_complete: UsageCallbackWithTiming = Arc::new(callback); + Self { + inner: Arc::new(SseUsageCollectorInner { + events: Mutex::new(Vec::new()), + first_event_time: Mutex::new(None), + start_time, + on_complete, + finished: AtomicBool::new(false), + }), + } + } + + /// 推送 SSE 事件 + pub async fn push(&self, event: Value) { + // 记录首个事件时间 + { + let mut first_time = self.inner.first_event_time.lock().await; + if first_time.is_none() { + *first_time = Some(std::time::Instant::now()); + } + } + let mut events = self.inner.events.lock().await; + events.push(event); + } + + /// 完成收集并触发回调 + pub async fn finish(&self) { + if self.inner.finished.swap(true, Ordering::SeqCst) { + return; + } + + let events = { + let mut guard = self.inner.events.lock().await; + std::mem::take(&mut *guard) + }; + + let first_token_ms = { + let first_time = self.inner.first_event_time.lock().await; + first_time.map(|t| (t - self.inner.start_time).as_millis() as u64) + }; + + (self.inner.on_complete)(events, first_token_ms); + } +} + +// ============================================================================ +// 内部辅助函数 +// ============================================================================ + +/// 创建使用量收集器 +fn create_usage_collector( + ctx: &RequestContext, + state: &ProxyState, + status_code: u16, + parser_config: &UsageParserConfig, +) -> SseUsageCollector { + let state = state.clone(); + let provider_id = ctx.provider.id.clone(); + let request_model = ctx.request_model.clone(); + let app_type_str = parser_config.app_type_str; + let tag = ctx.tag; + let start_time = ctx.start_time; + let stream_parser = parser_config.stream_parser; + let model_extractor = parser_config.model_extractor; + + SseUsageCollector::new(start_time, move |events, first_token_ms| { + if let Some(usage) = stream_parser(&events) { + let model = model_extractor(&events, &request_model); + let latency_ms = start_time.elapsed().as_millis() as u64; + + let state = state.clone(); + let provider_id = provider_id.clone(); + + tokio::spawn(async move { + log_usage_internal( + &state, + &provider_id, + app_type_str, + &model, + usage, + latency_ms, + first_token_ms, + true, // is_streaming + status_code, + ) + .await; + }); + } else { + log::debug!("[{tag}] 流式响应缺少 usage 统计,跳过消费记录"); + } + }) +} + +/// 异步记录使用量 +fn spawn_log_usage( + state: &ProxyState, + ctx: &RequestContext, + usage: TokenUsage, + model: &str, + status_code: u16, + is_streaming: bool, +) { + let state = state.clone(); + let provider_id = ctx.provider.id.clone(); + let app_type_str = ctx.app_type_str.to_string(); + let model = model.to_string(); + let latency_ms = ctx.latency_ms(); + + tokio::spawn(async move { + log_usage_internal( + &state, + &provider_id, + &app_type_str, + &model, + usage, + latency_ms, + None, + is_streaming, + status_code, + ) + .await; + }); +} + +/// 内部使用量记录函数 +#[allow(clippy::too_many_arguments)] +async fn log_usage_internal( + state: &ProxyState, + provider_id: &str, + app_type: &str, + model: &str, + usage: TokenUsage, + latency_ms: u64, + first_token_ms: Option, + is_streaming: bool, + status_code: u16, +) { + use super::usage::logger::UsageLogger; + + let logger = UsageLogger::new(&state.db); + + // 获取 provider 的 cost_multiplier + let multiplier = match state.db.get_provider_by_id(provider_id, app_type) { + Ok(Some(p)) => { + if let Some(meta) = p.meta { + if let Some(cm) = meta.cost_multiplier { + Decimal::from_str(&cm).unwrap_or(Decimal::from(1)) + } else { + Decimal::from(1) + } + } else { + Decimal::from(1) + } + } + _ => Decimal::from(1), + }; + + let request_id = uuid::Uuid::new_v4().to_string(); + + if let Err(e) = logger.log_with_calculation( + request_id, + provider_id.to_string(), + app_type.to_string(), + model.to_string(), + usage, + multiplier, + latency_ms, + first_token_ms, + status_code, + None, + None, // provider_type + is_streaming, + ) { + log::warn!("记录使用量失败: {e}"); + } +} + +/// 创建带日志记录的透传流 +pub fn create_logged_passthrough_stream( + stream: impl Stream> + Send + 'static, + tag: &'static str, + usage_collector: Option, +) -> impl Stream> + Send { + async_stream::stream! { + let mut buffer = String::new(); + let mut collector = usage_collector; + + tokio::pin!(stream); + + while let Some(chunk) = stream.next().await { + match chunk { + Ok(bytes) => { + let text = String::from_utf8_lossy(&bytes); + buffer.push_str(&text); + + // 尝试解析并记录完整的 SSE 事件 + while let Some(pos) = buffer.find("\n\n") { + let event_text = buffer[..pos].to_string(); + buffer = buffer[pos + 2..].to_string(); + + if !event_text.trim().is_empty() { + // 提取 data 部分并尝试解析为 JSON + for line in event_text.lines() { + if let Some(data) = line.strip_prefix("data: ") { + if data.trim() != "[DONE]" { + if let Ok(json_value) = serde_json::from_str::(data) { + if let Some(c) = &collector { + c.push(json_value.clone()).await; + } + log::info!( + "[{}] <<< SSE 事件:\n{}", + tag, + serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| data.to_string()) + ); + } else { + log::info!("[{tag}] <<< SSE 数据: {data}"); + } + } else { + log::info!("[{tag}] <<< SSE: [DONE]"); + } + } + } + } + } + + yield Ok(bytes); + } + Err(e) => { + log::error!("[{tag}] 流错误: {e}"); + yield Err(std::io::Error::other(e.to_string())); + break; + } + } + } + + log::info!("[{}] ====== 流结束 ======", tag); + + if let Some(c) = collector.take() { + c.finish().await; + } + } +} From c42a0dccaff4de165333c175109b328ba75a10d5 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 12 Dec 2025 10:43:01 +0800 Subject: [PATCH 04/18] fix(proxy): auto-recover live config after abnormal exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the app crashes or is force-killed while proxy mode is active, the live config files remain pointing to the dead proxy server with placeholder tokens, causing CLI tools to fail. This change adds startup detection: - Check `live_takeover_active` flag on app launch - If flag is true but proxy is not running → abnormal exit detected - Automatically restore live configs from database backup - Clear takeover flag and delete backups The recovery runs before auto-start, ensuring correct sequence even when proxy auto-start is enabled. --- src-tauri/src/lib.rs | 25 ++++++++++++++++++++++++- src-tauri/src/services/proxy.rs | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e0faff175..f04dbfe14 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -522,10 +522,33 @@ pub fn run() { } } - // 自动启动代理服务器 + // 异常退出恢复 + 自动启动代理服务器 let app_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { let state = app_handle.state::(); + + // 1. 检测异常退出并恢复 Live 配置 + match state.db.is_live_takeover_active().await { + Ok(true) => { + // 接管标志为 true 但代理未运行 → 上次异常退出 + if !state.proxy_service.is_running().await { + log::warn!("检测到上次异常退出,正在恢复 Live 配置..."); + if let Err(e) = state.proxy_service.recover_from_crash().await { + log::error!("恢复 Live 配置失败: {e}"); + } else { + log::info!("Live 配置已从异常退出中恢复"); + } + } + } + Ok(false) => { + // 正常状态,无需恢复 + } + Err(e) => { + log::error!("检查接管状态失败: {e}"); + } + } + + // 2. 自动启动代理服务器(如果配置为启用) match state.db.get_proxy_config().await { Ok(config) => { if config.enabled { diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index aff8bbc00..45d55f313 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -435,6 +435,30 @@ impl ProxyService { .map_err(|e| format!("检查接管状态失败: {e}")) } + /// 从异常退出中恢复(启动时调用) + /// + /// 检测到 live_takeover_active=true 但代理未运行时调用此方法。 + /// 会恢复 Live 配置、清除接管标志、删除备份。 + pub async fn recover_from_crash(&self) -> Result<(), String> { + // 1. 恢复 Live 配置 + self.restore_live_configs().await?; + + // 2. 清除接管标志 + self.db + .set_live_takeover_active(false) + .await + .map_err(|e| format!("清除接管状态失败: {e}"))?; + + // 3. 删除备份 + self.db + .delete_all_live_backups() + .await + .map_err(|e| format!("删除备份失败: {e}"))?; + + log::info!("已从异常退出中恢复 Live 配置"); + Ok(()) + } + /// 从供应商配置更新 Live 备份(用于代理模式下的热切换) /// /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份, From 5d424b138369b302b1912d47a23d6697ea9d827e Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 12 Dec 2025 16:13:07 +0800 Subject: [PATCH 05/18] feat(proxy): implement independent failover queue management Add a new failover queue system that operates independently from provider sortIndex, allowing users to configure failover order per app type. Backend changes: - Add failover_queue table to schema.rs for persistent storage - Create dao/failover.rs with CRUD operations for queue management - Add Tauri commands for queue operations (get, add, remove, reorder, toggle) - Refactor provider_router.rs select_providers() to use failover queue: - Current provider always takes first priority - Queue providers ordered by queue_order as fallback - Only providers with open circuit breakers are included Frontend changes: - Add FailoverQueueItem type to proxy.ts - Extend failover.ts API with queue management methods - Add React Query hooks for queue data fetching and mutations - Create FailoverQueueManager component with drag-and-drop reordering - Integrate queue management into SettingsPage under "Auto Failover" - Add i18n translations for zh and en locales --- src-tauri/src/commands/failover.rs | 84 ++++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/database/dao/failover.rs | 248 +++++++++++ src-tauri/src/database/dao/mod.rs | 3 + src-tauri/src/database/mod.rs | 3 + src-tauri/src/database/schema.rs | 24 + src-tauri/src/lib.rs | 7 + src-tauri/src/proxy/provider_router.rs | 106 +++-- src/components/proxy/FailoverQueueManager.tsx | 413 ++++++++++++++++++ src/components/settings/SettingsPage.tsx | 56 ++- src/i18n/locales/en.json | 45 ++ src/i18n/locales/zh.json | 45 ++ src/lib/api/failover.ts | 50 +++ src/lib/query/failover.ts | 150 ++++++- src/types/proxy.ts | 9 + 15 files changed, 1198 insertions(+), 47 deletions(-) create mode 100644 src-tauri/src/commands/failover.rs create mode 100644 src-tauri/src/database/dao/failover.rs create mode 100644 src/components/proxy/FailoverQueueManager.tsx diff --git a/src-tauri/src/commands/failover.rs b/src-tauri/src/commands/failover.rs new file mode 100644 index 000000000..ddceeef13 --- /dev/null +++ b/src-tauri/src/commands/failover.rs @@ -0,0 +1,84 @@ +//! 故障转移队列命令 +//! +//! 管理代理模式下的故障转移队列 + +use crate::database::FailoverQueueItem; +use crate::provider::Provider; +use crate::store::AppState; + +/// 获取故障转移队列 +#[tauri::command] +pub async fn get_failover_queue( + state: tauri::State<'_, AppState>, + app_type: String, +) -> Result, String> { + state + .db + .get_failover_queue(&app_type) + .map_err(|e| e.to_string()) +} + +/// 获取可添加到故障转移队列的供应商(不在队列中的) +#[tauri::command] +pub async fn get_available_providers_for_failover( + state: tauri::State<'_, AppState>, + app_type: String, +) -> Result, String> { + state + .db + .get_available_providers_for_failover(&app_type) + .map_err(|e| e.to_string()) +} + +/// 添加供应商到故障转移队列 +#[tauri::command] +pub async fn add_to_failover_queue( + state: tauri::State<'_, AppState>, + app_type: String, + provider_id: String, +) -> Result<(), String> { + state + .db + .add_to_failover_queue(&app_type, &provider_id) + .map_err(|e| e.to_string()) +} + +/// 从故障转移队列移除供应商 +#[tauri::command] +pub async fn remove_from_failover_queue( + state: tauri::State<'_, AppState>, + app_type: String, + provider_id: String, +) -> Result<(), String> { + state + .db + .remove_from_failover_queue(&app_type, &provider_id) + .map_err(|e| e.to_string()) +} + +/// 重新排序故障转移队列 +#[tauri::command] +pub async fn reorder_failover_queue( + state: tauri::State<'_, AppState>, + app_type: String, + provider_ids: Vec, +) -> Result<(), String> { + state + .db + .reorder_failover_queue(&app_type, &provider_ids) + .map_err(|e| e.to_string()) +} + +/// 设置故障转移队列项的启用状态 +#[tauri::command] +pub async fn set_failover_item_enabled( + state: tauri::State<'_, AppState>, + app_type: String, + provider_id: String, + enabled: bool, +) -> Result<(), String> { + state + .db + .set_failover_item_enabled(&app_type, &provider_id, enabled) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 9b7e87b98..85dfb3adc 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -3,6 +3,7 @@ mod config; mod deeplink; mod env; +mod failover; mod import_export; mod mcp; mod misc; @@ -18,6 +19,7 @@ mod usage; pub use config::*; pub use deeplink::*; pub use env::*; +pub use failover::*; pub use import_export::*; pub use mcp::*; pub use misc::*; diff --git a/src-tauri/src/database/dao/failover.rs b/src-tauri/src/database/dao/failover.rs new file mode 100644 index 000000000..66e24f92d --- /dev/null +++ b/src-tauri/src/database/dao/failover.rs @@ -0,0 +1,248 @@ +//! 故障转移队列 DAO +//! +//! 管理代理模式下的故障转移队列 + +use crate::database::{lock_conn, Database}; +use crate::error::AppError; +use crate::provider::Provider; +use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// 故障转移队列条目 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FailoverQueueItem { + pub provider_id: String, + pub provider_name: String, + pub queue_order: i32, + pub enabled: bool, + pub created_at: i64, +} + +impl Database { + /// 获取故障转移队列(按 queue_order 排序) + pub fn get_failover_queue(&self, app_type: &str) -> Result, AppError> { + let conn = lock_conn!(self.conn); + + let mut stmt = conn + .prepare( + "SELECT fq.provider_id, p.name, fq.queue_order, fq.enabled, fq.created_at + FROM failover_queue fq + JOIN providers p ON fq.provider_id = p.id AND fq.app_type = p.app_type + WHERE fq.app_type = ?1 + ORDER BY fq.queue_order ASC", + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + let items = stmt + .query_map([app_type], |row| { + Ok(FailoverQueueItem { + provider_id: row.get(0)?, + provider_name: row.get(1)?, + queue_order: row.get(2)?, + enabled: row.get(3)?, + created_at: row.get(4)?, + }) + }) + .map_err(|e| AppError::Database(e.to_string()))? + .collect::, _>>() + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(items) + } + + /// 获取故障转移队列中的供应商(完整 Provider 信息,按顺序) + pub fn get_failover_providers(&self, app_type: &str) -> Result, AppError> { + let queue = self.get_failover_queue(app_type)?; + let all_providers = self.get_all_providers(app_type)?; + + let mut result = Vec::new(); + for item in queue { + if item.enabled { + if let Some(provider) = all_providers.get(&item.provider_id) { + result.push(provider.clone()); + } + } + } + + Ok(result) + } + + /// 添加供应商到故障转移队列末尾 + pub fn add_to_failover_queue( + &self, + app_type: &str, + provider_id: &str, + ) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + + // 获取当前最大 queue_order + let max_order: i32 = conn + .query_row( + "SELECT COALESCE(MAX(queue_order), 0) FROM failover_queue WHERE app_type = ?1", + [app_type], + |row| row.get(0), + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + conn.execute( + "INSERT OR IGNORE INTO failover_queue (app_type, provider_id, queue_order, enabled, created_at) + VALUES (?1, ?2, ?3, 1, ?4)", + rusqlite::params![app_type, provider_id, max_order + 1, now], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(()) + } + + /// 从故障转移队列中移除供应商 + pub fn remove_from_failover_queue( + &self, + app_type: &str, + provider_id: &str, + ) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + + // 获取被删除项的 queue_order + let removed_order: Option = conn + .query_row( + "SELECT queue_order FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2", + [app_type, provider_id], + |row| row.get(0), + ) + .ok(); + + // 删除该项 + conn.execute( + "DELETE FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2", + [app_type, provider_id], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 重新排序后面的项(填补空隙) + if let Some(order) = removed_order { + conn.execute( + "UPDATE failover_queue + SET queue_order = queue_order - 1 + WHERE app_type = ?1 AND queue_order > ?2", + rusqlite::params![app_type, order], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + } + + Ok(()) + } + + /// 重新排序故障转移队列 + /// provider_ids: 按新顺序排列的 provider_id 列表 + pub fn reorder_failover_queue( + &self, + app_type: &str, + provider_ids: &[String], + ) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + + // 使用事务确保原子性 + conn.execute("BEGIN TRANSACTION", []) + .map_err(|e| AppError::Database(e.to_string()))?; + + let result = (|| { + for (index, provider_id) in provider_ids.iter().enumerate() { + conn.execute( + "UPDATE failover_queue + SET queue_order = ?3 + WHERE app_type = ?1 AND provider_id = ?2", + rusqlite::params![app_type, provider_id, (index + 1) as i32], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + } + Ok(()) + })(); + + match result { + Ok(_) => { + conn.execute("COMMIT", []) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + Err(e) => { + conn.execute("ROLLBACK", []).ok(); + Err(e) + } + } + } + + /// 设置故障转移队列中供应商的启用状态 + pub fn set_failover_item_enabled( + &self, + app_type: &str, + provider_id: &str, + enabled: bool, + ) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + + conn.execute( + "UPDATE failover_queue SET enabled = ?3 WHERE app_type = ?1 AND provider_id = ?2", + rusqlite::params![app_type, provider_id, enabled], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(()) + } + + /// 清空故障转移队列 + pub fn clear_failover_queue(&self, app_type: &str) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + + conn.execute( + "DELETE FROM failover_queue WHERE app_type = ?1", + [app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(()) + } + + /// 检查供应商是否在故障转移队列中 + pub fn is_in_failover_queue( + &self, + app_type: &str, + provider_id: &str, + ) -> Result { + let conn = lock_conn!(self.conn); + + let count: i32 = conn + .query_row( + "SELECT COUNT(*) FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2", + [app_type, provider_id], + |row| row.get(0), + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(count > 0) + } + + /// 获取可添加到故障转移队列的供应商(不在队列中的) + pub fn get_available_providers_for_failover( + &self, + app_type: &str, + ) -> Result, AppError> { + let all_providers = self.get_all_providers(app_type)?; + let queue = self.get_failover_queue(app_type)?; + + let queue_ids: std::collections::HashSet<_> = + queue.iter().map(|item| &item.provider_id).collect(); + + let available: Vec = all_providers + .into_values() + .filter(|p| !queue_ids.contains(&p.id)) + .collect(); + + Ok(available) + } +} diff --git a/src-tauri/src/database/dao/mod.rs b/src-tauri/src/database/dao/mod.rs index a3b6ca471..c759c2a86 100644 --- a/src-tauri/src/database/dao/mod.rs +++ b/src-tauri/src/database/dao/mod.rs @@ -2,6 +2,7 @@ //! //! Database access operations for each domain +pub mod failover; pub mod mcp; pub mod prompts; pub mod providers; @@ -11,3 +12,5 @@ pub mod skills; pub mod stream_check; // 所有 DAO 方法都通过 Database impl 提供,无需单独导出 +// 导出 FailoverQueueItem 供外部使用 +pub use failover::FailoverQueueItem; diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 2786a20b9..73a4877a4 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -31,6 +31,9 @@ mod schema; #[cfg(test)] mod tests; +// DAO 类型导出供外部使用 +pub use dao::FailoverQueueItem; + use crate::config::get_app_config_dir; use crate::error::AppError; use rusqlite::Connection; diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 3dbdca557..deab11aae 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -319,6 +319,30 @@ impl Database { [], ); + // 14. Failover Queue 表 (故障转移队列) + conn.execute( + "CREATE TABLE IF NOT EXISTS failover_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + app_type TEXT NOT NULL, + provider_id TEXT NOT NULL, + queue_order INTEGER NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + UNIQUE (app_type, provider_id), + FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + // 为故障转移队列创建索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_failover_queue_order + ON failover_queue(app_type, queue_order)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f04dbfe14..6a5874f7f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -686,6 +686,13 @@ pub fn run() { commands::get_circuit_breaker_config, commands::update_circuit_breaker_config, commands::get_circuit_breaker_stats, + // Failover queue management + commands::get_failover_queue, + commands::get_available_providers_for_failover, + commands::add_to_failover_queue, + commands::remove_from_failover_queue, + commands::reorder_failover_queue, + commands::set_failover_item_enabled, // Usage statistics commands::get_usage_summary, commands::get_usage_trends, diff --git a/src-tauri/src/proxy/provider_router.rs b/src-tauri/src/proxy/provider_router.rs index 8e4483414..6866fc1c5 100644 --- a/src-tauri/src/proxy/provider_router.rs +++ b/src-tauri/src/proxy/provider_router.rs @@ -28,44 +28,92 @@ impl ProviderRouter { } /// 选择可用的供应商(支持故障转移) - /// 返回按优先级排序的可用供应商列表 + /// + /// 返回按优先级排序的可用供应商列表: + /// 1. 当前供应商(is_current=true)始终第一位 + /// 2. 故障转移队列中的其他供应商(按 queue_order 排序) + /// 3. 只返回熔断器未打开的供应商 pub async fn select_providers(&self, app_type: &str) -> Result, AppError> { - // 直接获取当前选中的供应商(基于 is_current 字段) - let current_id = self - .db - .get_current_provider(app_type)? - .ok_or_else(|| AppError::Config(format!("No current provider for {}", app_type)))?; + let mut result = Vec::new(); + let all_providers = self.db.get_all_providers(app_type)?; - let providers = self.db.get_all_providers(app_type)?; - let provider = providers - .get(¤t_id) - .ok_or_else(|| AppError::Config(format!("Current provider {} not found", current_id)))? - .clone(); + // 1. 当前供应商始终第一位 + if let Some(current_id) = self.db.get_current_provider(app_type)? { + if let Some(current) = all_providers.get(¤t_id) { + let circuit_key = format!("{}:{}", app_type, current.id); + let breaker = self.get_or_create_circuit_breaker(&circuit_key).await; - log::info!( - "[{}] Selected current provider: {} ({})", - app_type, - provider.name, - provider.id - ); + if breaker.allow_request().await { + log::info!( + "[{}] Current provider available: {} ({})", + app_type, + current.name, + current.id + ); + result.push(current.clone()); + } else { + log::warn!( + "[{}] Current provider {} circuit breaker open, checking failover queue", + app_type, + current.name + ); + } + } + } - // 检查熔断器状态 - let circuit_key = format!("{}:{}", app_type, provider.id); - let breaker = self.get_or_create_circuit_breaker(&circuit_key).await; + // 2. 获取故障转移队列中的供应商 + let queue = self.db.get_failover_queue(app_type)?; - if !breaker.allow_request().await { - log::warn!( - "Provider {} is unavailable (circuit breaker open)", - provider.id - ); + for item in queue { + // 跳过已添加的当前供应商 + if result.iter().any(|p| p.id == item.provider_id) { + continue; + } + + // 跳过禁用的队列项 + if !item.enabled { + continue; + } + + // 获取供应商信息 + if let Some(provider) = all_providers.get(&item.provider_id) { + // 检查熔断器状态 + let circuit_key = format!("{}:{}", app_type, provider.id); + let breaker = self.get_or_create_circuit_breaker(&circuit_key).await; + + if breaker.allow_request().await { + log::info!( + "[{}] Failover provider available: {} ({}) at queue position {}", + app_type, + provider.name, + provider.id, + item.queue_order + ); + result.push(provider.clone()); + } else { + log::debug!( + "[{}] Failover provider {} circuit breaker open, skipping", + app_type, + provider.name + ); + } + } + } + + if result.is_empty() { return Err(AppError::Config(format!( - "Current provider {} is unavailable (circuit breaker open)", - provider.name + "No available provider for {} (all circuit breakers open or no providers configured)", + app_type ))); } - // 返回单个供应商(保留 Vec 接口以兼容现有代码) - Ok(vec![provider]) + log::info!( + "[{}] Failover chain: {} provider(s) available", + app_type, + result.len() + ); + + Ok(result) } /// 记录供应商请求结果 diff --git a/src/components/proxy/FailoverQueueManager.tsx b/src/components/proxy/FailoverQueueManager.tsx new file mode 100644 index 000000000..dc9a36d10 --- /dev/null +++ b/src/components/proxy/FailoverQueueManager.tsx @@ -0,0 +1,413 @@ +/** + * 故障转移队列管理组件 + * + * 允许用户管理代理模式下的故障转移队列,支持: + * - 拖拽排序 + * - 添加/移除供应商 + * - 启用/禁用队列项 + */ + +import { useState, useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { CSS } from "@dnd-kit/utilities"; +import { DndContext, closestCenter } from "@dnd-kit/core"; +import { + SortableContext, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; +import { toast } from "sonner"; +import { + GripVertical, + Plus, + Trash2, + Loader2, + Info, + AlertTriangle, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import type { FailoverQueueItem } from "@/types/proxy"; +import type { AppId } from "@/lib/api"; +import { + useFailoverQueue, + useAvailableProvidersForFailover, + useAddToFailoverQueue, + useRemoveFromFailoverQueue, + useReorderFailoverQueue, + useSetFailoverItemEnabled, +} from "@/lib/query/failover"; + +interface FailoverQueueManagerProps { + appType: AppId; + disabled?: boolean; +} + +export function FailoverQueueManager({ + appType, + disabled = false, +}: FailoverQueueManagerProps) { + const { t } = useTranslation(); + const [selectedProviderId, setSelectedProviderId] = useState(""); + + // 查询数据 + const { + data: queue, + isLoading: isQueueLoading, + error: queueError, + } = useFailoverQueue(appType); + const { + data: availableProviders, + isLoading: isProvidersLoading, + } = useAvailableProvidersForFailover(appType); + + // Mutations + const addToQueue = useAddToFailoverQueue(); + const removeFromQueue = useRemoveFromFailoverQueue(); + const reorderQueue = useReorderFailoverQueue(); + const setItemEnabled = useSetFailoverItemEnabled(); + + // 拖拽配置 + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + // 排序后的队列 + const sortedQueue = useMemo(() => { + if (!queue) return []; + return [...queue].sort((a, b) => a.queueOrder - b.queueOrder); + }, [queue]); + + // 处理拖拽结束 + const handleDragEnd = useCallback( + async (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id || !sortedQueue) return; + + const oldIndex = sortedQueue.findIndex( + (item) => item.providerId === active.id, + ); + const newIndex = sortedQueue.findIndex( + (item) => item.providerId === over.id, + ); + + if (oldIndex === -1 || newIndex === -1) return; + + const reordered = arrayMove(sortedQueue, oldIndex, newIndex); + const providerIds = reordered.map((item) => item.providerId); + + try { + await reorderQueue.mutateAsync({ appType, providerIds }); + toast.success( + t("proxy.failoverQueue.reorderSuccess", "队列顺序已更新"), + ); + } catch (error) { + toast.error( + t("proxy.failoverQueue.reorderFailed", "更新顺序失败") + + ": " + + String(error), + ); + } + }, + [sortedQueue, appType, reorderQueue, t], + ); + + // 添加供应商到队列 + const handleAddProvider = async () => { + if (!selectedProviderId) return; + + try { + await addToQueue.mutateAsync({ + appType, + providerId: selectedProviderId, + }); + setSelectedProviderId(""); + toast.success(t("proxy.failoverQueue.addSuccess", "已添加到故障转移队列")); + } catch (error) { + toast.error( + t("proxy.failoverQueue.addFailed", "添加失败") + ": " + String(error), + ); + } + }; + + // 从队列移除供应商 + const handleRemoveProvider = async (providerId: string) => { + try { + await removeFromQueue.mutateAsync({ appType, providerId }); + toast.success( + t("proxy.failoverQueue.removeSuccess", "已从故障转移队列移除"), + ); + } catch (error) { + toast.error( + t("proxy.failoverQueue.removeFailed", "移除失败") + ": " + String(error), + ); + } + }; + + // 切换启用状态 + const handleToggleEnabled = async (providerId: string, enabled: boolean) => { + try { + await setItemEnabled.mutateAsync({ appType, providerId, enabled }); + } catch (error) { + toast.error( + t("proxy.failoverQueue.toggleFailed", "状态更新失败") + + ": " + + String(error), + ); + } + }; + + if (isQueueLoading) { + return ( +
+ +
+ ); + } + + if (queueError) { + return ( + + + {String(queueError)} + + ); + } + + return ( +
+ {/* 说明信息 */} + + + + {t( + "proxy.failoverQueue.info", + "当前激活的供应商始终优先。当请求失败时,系统会按队列顺序依次尝试其他供应商。", + )} + + + + {/* 添加供应商 */} +
+ + +
+ + {/* 队列列表 */} + {sortedQueue.length === 0 ? ( +
+

+ {t( + "proxy.failoverQueue.empty", + "故障转移队列为空。添加供应商以启用自动故障转移。", + )} +

+
+ ) : ( + + item.providerId)} + strategy={verticalListSortingStrategy} + > +
+ {sortedQueue.map((item, index) => ( + + ))} +
+
+
+ )} + + {/* 队列说明 */} + {sortedQueue.length > 0 && ( +

+ {t( + "proxy.failoverQueue.dragHint", + "拖拽供应商可调整故障转移顺序,序号越小优先级越高。", + )} +

+ )} +
+ ); +} + +interface SortableQueueItemProps { + item: FailoverQueueItem; + index: number; + disabled: boolean; + onToggleEnabled: (providerId: string, enabled: boolean) => void; + onRemove: (providerId: string) => void; + isRemoving: boolean; + isToggling: boolean; +} + +function SortableQueueItem({ + item, + index, + disabled, + onToggleEnabled, + onRemove, + isRemoving, + isToggling, +}: SortableQueueItemProps) { + const { t } = useTranslation(); + const { + setNodeRef, + attributes, + listeners, + transform, + transition, + isDragging, + } = useSortable({ id: item.providerId, disabled }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ {/* 拖拽手柄 */} + + + {/* 序号 */} +
+ {index + 1} +
+ + {/* 供应商名称 */} +
+ + {item.providerName} + +
+ + {/* 启用开关 */} + + onToggleEnabled(item.providerId, checked) + } + disabled={disabled || isToggling} + aria-label={t("proxy.failoverQueue.toggleEnabled", "启用/禁用")} + /> + + {/* 删除按钮 */} + +
+ ); +} diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 42ece5a2e..dc2016a21 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -35,6 +35,7 @@ import { ProxyPanel } from "@/components/proxy"; import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel"; import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel"; import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel"; +import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager"; import { UsageDashboard } from "@/components/usage/UsageDashboard"; import { useSettings } from "@/hooks/useSettings"; import { useImportExport } from "@/hooks/useImportExport"; @@ -354,7 +355,7 @@ export function SettingsPage({ 自动故障转移

- 配置自动故障转移和熔断策略 + 配置故障转移队列和熔断策略

@@ -373,10 +374,55 @@ export function SettingsPage({ - +
+ {/* 故障转移队列管理 */} +
+
+

+ {t("proxy.failoverQueue.title", "故障转移队列")} +

+

+ {t( + "proxy.failoverQueue.description", + "管理各应用的供应商故障转移顺序", + )} +

+
+ + + Claude + Codex + Gemini + + + + + + + + + + + +
+ + {/* 熔断器配置 */} +
+ +
+
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 92820c5ab..24b785791 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -840,5 +840,50 @@ }, "agents": { "title": "Agents" + }, + "proxy": { + "failoverQueue": { + "title": "Failover Queue", + "description": "Manage failover order for each app's providers", + "info": "The current active provider always takes priority. When requests fail, the system will try other providers in queue order.", + "selectProvider": "Select a provider to add to queue", + "noAvailableProviders": "No providers available to add", + "empty": "Failover queue is empty. Add providers to enable automatic failover.", + "dragHint": "Drag providers to adjust failover order. Lower numbers have higher priority.", + "toggleEnabled": "Enable/Disable", + "addSuccess": "Added to failover queue", + "addFailed": "Failed to add", + "removeSuccess": "Removed from failover queue", + "removeFailed": "Failed to remove", + "reorderSuccess": "Queue order updated", + "reorderFailed": "Failed to update order", + "toggleFailed": "Failed to update status" + }, + "autoFailover": { + "info": "When multiple proxy targets are enabled, the system will try them in priority order. When a provider fails consecutively reaching the threshold, the circuit breaker will open and skip that provider.", + "configSaved": "Auto failover config saved", + "configSaveFailed": "Failed to save", + "retrySettings": "Retry & Timeout Settings", + "failureThreshold": "Failure Threshold", + "failureThresholdHint": "Open circuit breaker after this many consecutive failures (recommended: 3-10)", + "timeout": "Recovery Wait Time (seconds)", + "timeoutHint": "Wait this long before trying to recover after circuit opens (recommended: 30-120)", + "circuitBreakerSettings": "Circuit Breaker Advanced Settings", + "successThreshold": "Recovery Success Threshold", + "successThresholdHint": "Close circuit breaker after this many successes in half-open state", + "errorRate": "Error Rate Threshold (%)", + "errorRateHint": "Open circuit breaker when error rate exceeds this value", + "minRequests": "Minimum Requests", + "minRequestsHint": "Minimum requests before calculating error rate", + "explanationTitle": "How It Works", + "failureThresholdLabel": "Failure Threshold", + "failureThresholdExplain": "Circuit breaker opens after this many consecutive failures, making the provider temporarily unavailable", + "timeoutLabel": "Recovery Wait Time", + "timeoutExplain": "After circuit opens, wait this long before trying half-open state", + "successThresholdLabel": "Recovery Success Threshold", + "successThresholdExplain": "In half-open state, close circuit breaker after this many successes, making provider available again", + "errorRateLabel": "Error Rate Threshold", + "errorRateExplain": "Open circuit breaker when error rate exceeds this value, even if failure threshold not reached" + } } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 6eacfbfde..8809e4744 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -840,5 +840,50 @@ }, "agents": { "title": "智能体" + }, + "proxy": { + "failoverQueue": { + "title": "故障转移队列", + "description": "管理各应用的供应商故障转移顺序", + "info": "当前激活的供应商始终优先。当请求失败时,系统会按队列顺序依次尝试其他供应商。", + "selectProvider": "选择供应商添加到队列", + "noAvailableProviders": "没有可添加的供应商", + "empty": "故障转移队列为空。添加供应商以启用自动故障转移。", + "dragHint": "拖拽供应商可调整故障转移顺序,序号越小优先级越高。", + "toggleEnabled": "启用/禁用", + "addSuccess": "已添加到故障转移队列", + "addFailed": "添加失败", + "removeSuccess": "已从故障转移队列移除", + "removeFailed": "移除失败", + "reorderSuccess": "队列顺序已更新", + "reorderFailed": "更新顺序失败", + "toggleFailed": "状态更新失败" + }, + "autoFailover": { + "info": "当启用多个代理目标时,系统会按优先级顺序依次尝试。当某个供应商连续失败达到阈值时,熔断器会自动打开,跳过该供应商。", + "configSaved": "自动故障转移配置已保存", + "configSaveFailed": "保存失败", + "retrySettings": "重试与超时设置", + "failureThreshold": "失败阈值", + "failureThresholdHint": "连续失败多少次后打开熔断器(建议: 3-10)", + "timeout": "恢复等待时间(秒)", + "timeoutHint": "熔断器打开后,等待多久后尝试恢复(建议: 30-120)", + "circuitBreakerSettings": "熔断器高级设置", + "successThreshold": "恢复成功阈值", + "successThresholdHint": "半开状态下成功多少次后关闭熔断器", + "errorRate": "错误率阈值 (%)", + "errorRateHint": "错误率超过此值时打开熔断器", + "minRequests": "最小请求数", + "minRequestsHint": "计算错误率前的最小请求数", + "explanationTitle": "工作原理", + "failureThresholdLabel": "失败阈值", + "failureThresholdExplain": "连续失败达到此次数时,熔断器打开,该供应商暂时不可用", + "timeoutLabel": "恢复等待时间", + "timeoutExplain": "熔断器打开后,等待此时间后尝试半开状态", + "successThresholdLabel": "恢复成功阈值", + "successThresholdExplain": "半开状态下,成功达到此次数时关闭熔断器,供应商恢复可用", + "errorRateLabel": "错误率阈值", + "errorRateExplain": "错误率超过此值时,即使未达到失败阈值也会打开熔断器" + } } } diff --git a/src/lib/api/failover.ts b/src/lib/api/failover.ts index aed49451a..d6c03d0a5 100644 --- a/src/lib/api/failover.ts +++ b/src/lib/api/failover.ts @@ -3,6 +3,7 @@ import type { ProviderHealth, CircuitBreakerConfig, CircuitBreakerStats, + FailoverQueueItem, } from "@/types/proxy"; export interface Provider { @@ -21,6 +22,8 @@ export interface Provider { } export const failoverApi = { + // ========== 旧版代理目标 API(保留向后兼容)========== + // 获取代理目标列表 async getProxyTargets(appType: string): Promise { return invoke("get_proxy_targets", { appType }); @@ -35,6 +38,8 @@ export const failoverApi = { return invoke("set_proxy_target", { providerId, appType, enabled }); }, + // ========== 熔断器 API ========== + // 获取供应商健康状态 async getProviderHealth( providerId: string, @@ -70,4 +75,49 @@ export const failoverApi = { ): Promise { return invoke("get_circuit_breaker_stats", { providerId, appType }); }, + + // ========== 故障转移队列 API(新) ========== + + // 获取故障转移队列 + async getFailoverQueue(appType: string): Promise { + return invoke("get_failover_queue", { appType }); + }, + + // 获取可添加到队列的供应商(不在队列中的) + async getAvailableProvidersForFailover(appType: string): Promise { + return invoke("get_available_providers_for_failover", { appType }); + }, + + // 添加供应商到故障转移队列 + async addToFailoverQueue( + appType: string, + providerId: string, + ): Promise { + return invoke("add_to_failover_queue", { appType, providerId }); + }, + + // 从故障转移队列移除供应商 + async removeFromFailoverQueue( + appType: string, + providerId: string, + ): Promise { + return invoke("remove_from_failover_queue", { appType, providerId }); + }, + + // 重新排序故障转移队列 + async reorderFailoverQueue( + appType: string, + providerIds: string[], + ): Promise { + return invoke("reorder_failover_queue", { appType, providerIds }); + }, + + // 设置故障转移队列项的启用状态 + async setFailoverItemEnabled( + appType: string, + providerId: string, + enabled: boolean, + ): Promise { + return invoke("set_failover_item_enabled", { appType, providerId, enabled }); + }, }; diff --git a/src/lib/query/failover.ts b/src/lib/query/failover.ts index 7c9aea6c3..acfaef56a 100644 --- a/src/lib/query/failover.ts +++ b/src/lib/query/failover.ts @@ -1,6 +1,8 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { failoverApi } from "@/lib/api/failover"; +// ========== 旧版代理目标 Hooks(保留向后兼容)========== + /** * 获取代理目标列表 */ @@ -12,19 +14,6 @@ export function useProxyTargets(appType: string) { }); } -/** - * 获取供应商健康状态 - */ -export function useProviderHealth(providerId: string, appType: string) { - return useQuery({ - queryKey: ["providerHealth", providerId, appType], - queryFn: () => failoverApi.getProviderHealth(providerId, appType), - enabled: !!providerId && !!appType, - refetchInterval: 5000, // 每 5 秒刷新一次 - retry: false, - }); -} - /** * 设置代理目标 */ @@ -56,6 +45,21 @@ export function useSetProxyTarget() { }); } +// ========== 熔断器 Hooks ========== + +/** + * 获取供应商健康状态 + */ +export function useProviderHealth(providerId: string, appType: string) { + return useQuery({ + queryKey: ["providerHealth", providerId, appType], + queryFn: () => failoverApi.getProviderHealth(providerId, appType), + enabled: !!providerId && !!appType, + refetchInterval: 5000, // 每 5 秒刷新一次 + retry: false, + }); +} + /** * 重置熔断器 */ @@ -114,3 +118,123 @@ export function useCircuitBreakerStats(providerId: string, appType: string) { refetchInterval: 5000, // 每 5 秒刷新一次 }); } + +// ========== 故障转移队列 Hooks(新) ========== + +/** + * 获取故障转移队列 + */ +export function useFailoverQueue(appType: string) { + return useQuery({ + queryKey: ["failoverQueue", appType], + queryFn: () => failoverApi.getFailoverQueue(appType), + enabled: !!appType, + }); +} + +/** + * 获取可添加到队列的供应商 + */ +export function useAvailableProvidersForFailover(appType: string) { + return useQuery({ + queryKey: ["availableProvidersForFailover", appType], + queryFn: () => failoverApi.getAvailableProvidersForFailover(appType), + enabled: !!appType, + }); +} + +/** + * 添加供应商到故障转移队列 + */ +export function useAddToFailoverQueue() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + appType, + providerId, + }: { + appType: string; + providerId: string; + }) => failoverApi.addToFailoverQueue(appType, providerId), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: ["failoverQueue", variables.appType], + }); + queryClient.invalidateQueries({ + queryKey: ["availableProvidersForFailover", variables.appType], + }); + }, + }); +} + +/** + * 从故障转移队列移除供应商 + */ +export function useRemoveFromFailoverQueue() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + appType, + providerId, + }: { + appType: string; + providerId: string; + }) => failoverApi.removeFromFailoverQueue(appType, providerId), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: ["failoverQueue", variables.appType], + }); + queryClient.invalidateQueries({ + queryKey: ["availableProvidersForFailover", variables.appType], + }); + }, + }); +} + +/** + * 重新排序故障转移队列 + */ +export function useReorderFailoverQueue() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + appType, + providerIds, + }: { + appType: string; + providerIds: string[]; + }) => failoverApi.reorderFailoverQueue(appType, providerIds), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: ["failoverQueue", variables.appType], + }); + }, + }); +} + +/** + * 设置故障转移队列项的启用状态 + */ +export function useSetFailoverItemEnabled() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + appType, + providerId, + enabled, + }: { + appType: string; + providerId: string; + enabled: boolean; + }) => failoverApi.setFailoverItemEnabled(appType, providerId, enabled), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: ["failoverQueue", variables.appType], + }); + }, + }); +} diff --git a/src/types/proxy.ts b/src/types/proxy.ts index 133295c2e..393e0e662 100644 --- a/src/types/proxy.ts +++ b/src/types/proxy.ts @@ -93,3 +93,12 @@ export interface ProxyUsageRecord { error: string | null; timestamp: string; } + +// 故障转移队列条目 +export interface FailoverQueueItem { + providerId: string; + providerName: string; + queueOrder: number; + enabled: boolean; + createdAt: number; +} From 5a5ca2a989c2e3942d81dc08bfdda57c14242a54 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 13 Dec 2025 22:47:49 +0800 Subject: [PATCH 06/18] fix(proxy): resolve circuit breaker state persistence and HalfOpen deadlock This commit addresses several critical issues in the failover system: **Circuit breaker state persistence (previous fix)** - Promote ProviderRouter to ProxyState for cross-request state sharing - Remove redundant router.rs module - Fix 429 errors to be retryable (rate limiting should try other providers) **Hot-update circuit breaker config** - Add update_circuit_breaker_configs() to ProxyServer and ProxyService - Connect update_circuit_breaker_config command to running circuit breakers - Add reset_provider_circuit_breaker() for manual breaker reset **Fix HalfOpen deadlock bug** - Change half_open_requests from cumulative count to in-flight count - Release quota in record_success()/record_failure() when in HalfOpen state - Prevents permanent deadlock when success_threshold > 1 **Fix duplicate select_providers() call** - Store providers list in RequestContext, pass to forward_with_retry() - Avoid consuming HalfOpen quota twice per request - Single call to select_providers() per request lifecycle **Add per-provider retry with exponential backoff** - Implement forward_with_provider_retry() with configurable max_retries - Backoff delays: 100ms, 200ms, 400ms, etc. --- src-tauri/src/commands/proxy.rs | 22 ++++-- src-tauri/src/database/dao/proxy.rs | 30 ++++++-- src-tauri/src/proxy/circuit_breaker.rs | 82 ++++++++++++++++---- src-tauri/src/proxy/forwarder.rs | 101 ++++++++++++++++++++----- src-tauri/src/proxy/handler_context.rs | 41 +++++++--- src-tauri/src/proxy/handlers.rs | 32 +++++++- src-tauri/src/proxy/mod.rs | 1 - src-tauri/src/proxy/provider_router.rs | 46 ++++++++--- src-tauri/src/proxy/router.rs | 70 ----------------- src-tauri/src/proxy/server.rs | 26 ++++++- src-tauri/src/services/proxy.rs | 37 +++++++++ 11 files changed, 347 insertions(+), 141 deletions(-) delete mode 100644 src-tauri/src/proxy/router.rs diff --git a/src-tauri/src/commands/proxy.rs b/src-tauri/src/commands/proxy.rs index 9c9a34212..b74e1afe6 100644 --- a/src-tauri/src/commands/proxy.rs +++ b/src-tauri/src/commands/proxy.rs @@ -130,15 +130,17 @@ pub async fn reset_circuit_breaker( provider_id: String, app_type: String, ) -> Result<(), String> { - // 重置数据库健康状态 + // 1. 重置数据库健康状态 let db = &state.db; db.update_provider_health(&provider_id, &app_type, true, None) .await .map_err(|e| e.to_string())?; - // 注意:熔断器状态在内存中,重启代理服务器后会重置 - // 如果代理服务器正在运行,需要通知它重置熔断器 - // 目前先通过数据库重置健康状态,熔断器会在下次超时后自动尝试半开 + // 2. 如果代理正在运行,重置内存中的熔断器状态 + state + .proxy_service + .reset_provider_circuit_breaker(&provider_id, &app_type) + .await?; Ok(()) } @@ -161,9 +163,19 @@ pub async fn update_circuit_breaker_config( config: CircuitBreakerConfig, ) -> Result<(), String> { let db = &state.db; + + // 1. 更新数据库配置 db.update_circuit_breaker_config(&config) .await - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + + // 2. 如果代理正在运行,热更新内存中的熔断器配置 + state + .proxy_service + .update_circuit_breaker_configs(config) + .await?; + + Ok(()) } /// 获取熔断器统计信息(仅当代理服务器运行时) diff --git a/src-tauri/src/database/dao/proxy.rs b/src-tauri/src/database/dao/proxy.rs index 961760c42..0303481cf 100644 --- a/src-tauri/src/database/dao/proxy.rs +++ b/src-tauri/src/database/dao/proxy.rs @@ -129,12 +129,31 @@ impl Database { } /// 更新Provider健康状态 + /// + /// 使用默认阈值(5)判断是否健康,建议使用 `update_provider_health_with_threshold` 传入配置的阈值 pub async fn update_provider_health( &self, provider_id: &str, app_type: &str, success: bool, error_msg: Option, + ) -> Result<(), AppError> { + // 默认阈值与 CircuitBreakerConfig::default() 保持一致 + self.update_provider_health_with_threshold(provider_id, app_type, success, error_msg, 5) + .await + } + + /// 更新Provider健康状态(带阈值参数) + /// + /// # Arguments + /// * `failure_threshold` - 连续失败多少次后标记为不健康 + pub async fn update_provider_health_with_threshold( + &self, + provider_id: &str, + app_type: &str, + success: bool, + error_msg: Option, + failure_threshold: u32, ) -> Result<(), AppError> { let conn = lock_conn!(self.conn); @@ -142,7 +161,7 @@ impl Database { // 先查询当前状态 let current = conn.query_row( - "SELECT consecutive_failures FROM provider_health + "SELECT consecutive_failures FROM provider_health WHERE provider_id = ?1 AND app_type = ?2", rusqlite::params![provider_id, app_type], |row| Ok(row.get::<_, i64>(0)? as u32), @@ -154,7 +173,8 @@ impl Database { } else { // 失败:增加失败计数 let failures = current.unwrap_or(0) + 1; - let healthy = if failures >= 3 { 0 } else { 1 }; + // 使用传入的阈值而非硬编码 + let healthy = if failures >= failure_threshold { 0 } else { 1 }; (healthy, failures) }; @@ -169,10 +189,10 @@ impl Database { "INSERT OR REPLACE INTO provider_health (provider_id, app_type, is_healthy, consecutive_failures, last_success_at, last_failure_at, last_error, updated_at) - VALUES (?1, ?2, ?3, ?4, - COALESCE(?5, (SELECT last_success_at FROM provider_health + VALUES (?1, ?2, ?3, ?4, + COALESCE(?5, (SELECT last_success_at FROM provider_health WHERE provider_id = ?1 AND app_type = ?2)), - COALESCE(?6, (SELECT last_failure_at FROM provider_health + COALESCE(?6, (SELECT last_failure_at FROM provider_health WHERE provider_id = ?1 AND app_type = ?2)), ?7, ?8)", rusqlite::params![ diff --git a/src-tauri/src/proxy/circuit_breaker.rs b/src-tauri/src/proxy/circuit_breaker.rs index acedef87d..3bdd9df85 100644 --- a/src-tauri/src/proxy/circuit_breaker.rs +++ b/src-tauri/src/proxy/circuit_breaker.rs @@ -72,8 +72,10 @@ pub struct CircuitBreaker { failed_requests: Arc, /// 上次打开时间 last_opened_at: Arc>>, - /// 配置 - config: CircuitBreakerConfig, + /// 配置(支持热更新) + config: Arc>, + /// 半开状态已放行的请求数(用于限流) + half_open_requests: Arc, } impl CircuitBreaker { @@ -86,20 +88,29 @@ impl CircuitBreaker { total_requests: Arc::new(AtomicU32::new(0)), failed_requests: Arc::new(AtomicU32::new(0)), last_opened_at: Arc::new(RwLock::new(None)), - config, + config: Arc::new(RwLock::new(config)), + half_open_requests: Arc::new(AtomicU32::new(0)), } } + /// 更新熔断器配置(热更新,不重置状态) + pub async fn update_config(&self, new_config: CircuitBreakerConfig) { + *self.config.write().await = new_config; + log::debug!("Circuit breaker config updated"); + } + /// 检查是否允许请求通过 pub async fn allow_request(&self) -> bool { let state = *self.state.read().await; + let config = self.config.read().await; match state { CircuitState::Closed => true, CircuitState::Open => { // 检查是否应该尝试半开 if let Some(opened_at) = *self.last_opened_at.read().await { - if opened_at.elapsed().as_secs() >= self.config.timeout_seconds { + if opened_at.elapsed().as_secs() >= config.timeout_seconds { + drop(config); // 释放读锁再转换状态 log::info!( "Circuit breaker transitioning from Open to HalfOpen (timeout reached)" ); @@ -109,13 +120,36 @@ impl CircuitBreaker { } false } - CircuitState::HalfOpen => true, + CircuitState::HalfOpen => { + // 半开状态限流:只允许有限请求通过进行探测 + // 默认最多允许 1 个请求(可在配置中扩展) + let max_half_open_requests = 1u32; + let current = self.half_open_requests.fetch_add(1, Ordering::SeqCst); + + if current < max_half_open_requests { + log::debug!( + "Circuit breaker HalfOpen: allowing probe request ({}/{})", + current + 1, + max_half_open_requests + ); + true + } else { + // 超过限额,回退计数,拒绝请求 + self.half_open_requests.fetch_sub(1, Ordering::SeqCst); + log::debug!( + "Circuit breaker HalfOpen: rejecting request (limit reached: {})", + max_half_open_requests + ); + false + } + } } } /// 记录成功 pub async fn record_success(&self) { let state = *self.state.read().await; + let config = self.config.read().await; // 重置失败计数 self.consecutive_failures.store(0, Ordering::SeqCst); @@ -123,14 +157,18 @@ impl CircuitBreaker { match state { CircuitState::HalfOpen => { + // 释放 in-flight 名额(探测请求结束) + self.half_open_requests.fetch_sub(1, Ordering::SeqCst); + let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1; log::debug!( "Circuit breaker HalfOpen: {} consecutive successes (threshold: {})", successes, - self.config.success_threshold + config.success_threshold ); - if successes >= self.config.success_threshold { + if successes >= config.success_threshold { + drop(config); // 释放读锁再转换状态 log::info!("Circuit breaker transitioning from HalfOpen to Closed (success threshold reached)"); self.transition_to_closed().await; } @@ -145,6 +183,7 @@ impl CircuitBreaker { /// 记录失败 pub async fn record_failure(&self) { let state = *self.state.read().await; + let config = self.config.read().await; // 更新计数器 let failures = self.consecutive_failures.fetch_add(1, Ordering::SeqCst) + 1; @@ -158,26 +197,38 @@ impl CircuitBreaker { "Circuit breaker {:?}: {} consecutive failures (threshold: {})", state, failures, - self.config.failure_threshold + config.failure_threshold ); // 检查是否应该打开熔断器 match state { - CircuitState::Closed | CircuitState::HalfOpen => { + CircuitState::HalfOpen => { + // 释放 in-flight 名额(探测请求结束) + self.half_open_requests.fetch_sub(1, Ordering::SeqCst); + + // HalfOpen 状态下失败,立即转为 Open + log::warn!( + "Circuit breaker HalfOpen probe failed, transitioning to Open" + ); + drop(config); + self.transition_to_open().await; + } + CircuitState::Closed => { // 检查连续失败次数 - if failures >= self.config.failure_threshold { + if failures >= config.failure_threshold { log::warn!( "Circuit breaker opening due to {} consecutive failures (threshold: {})", failures, - self.config.failure_threshold + config.failure_threshold ); + drop(config); // 释放读锁再转换状态 self.transition_to_open().await; } else { // 检查错误率 let total = self.total_requests.load(Ordering::SeqCst); let failed = self.failed_requests.load(Ordering::SeqCst); - if total >= self.config.min_requests { + if total >= config.min_requests { let error_rate = failed as f64 / total as f64; log::debug!( "Circuit breaker error rate: {:.2}% ({}/{} requests)", @@ -186,12 +237,13 @@ impl CircuitBreaker { total ); - if error_rate >= self.config.error_rate_threshold { + if error_rate >= config.error_rate_threshold { log::warn!( "Circuit breaker opening due to high error rate: {:.2}% (threshold: {:.2}%)", error_rate * 100.0, - self.config.error_rate_threshold * 100.0 + config.error_rate_threshold * 100.0 ); + drop(config); // 释放读锁再转换状态 self.transition_to_open().await; } } @@ -237,6 +289,8 @@ impl CircuitBreaker { async fn transition_to_half_open(&self) { *self.state.write().await = CircuitState::HalfOpen; self.consecutive_successes.store(0, Ordering::SeqCst); + // 重置半开状态的请求限流计数 + self.half_open_requests.store(0, Ordering::SeqCst); } /// 转换到关闭状态 diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index b4226ba00..385557132 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -4,12 +4,12 @@ use super::{ error::*, - provider_router::ProviderRouter as NewProviderRouter, + provider_router::ProviderRouter, providers::{get_adapter, ProviderAdapter}, types::ProxyStatus, ProxyError, }; -use crate::{app_config::AppType, database::Database, provider::Provider}; +use crate::{app_config::AppType, provider::Provider}; use reqwest::{Client, Response}; use serde_json::Value; use std::sync::Arc; @@ -18,8 +18,9 @@ use tokio::sync::RwLock; pub struct RequestForwarder { client: Client, - router: Arc, - #[allow(dead_code)] + /// 共享的 ProviderRouter(持有熔断器状态) + router: Arc, + /// 单个 Provider 内的最大重试次数 max_retries: u8, status: Arc>, current_providers: Arc>>, @@ -27,7 +28,7 @@ pub struct RequestForwarder { impl RequestForwarder { pub fn new( - db: Arc, + router: Arc, timeout_secs: u64, max_retries: u8, status: Arc>, @@ -44,32 +45,85 @@ impl RequestForwarder { Self { client, - router: Arc::new(NewProviderRouter::new(db)), + router, max_retries, status, current_providers, } } + /// 对单个 Provider 执行请求(带重试) + /// + /// 在同一个 Provider 上最多重试 max_retries 次,使用指数退避 + async fn forward_with_provider_retry( + &self, + provider: &Provider, + endpoint: &str, + body: &Value, + headers: &axum::http::HeaderMap, + adapter: &dyn ProviderAdapter, + ) -> Result { + let mut last_error = None; + + for attempt in 0..=self.max_retries { + if attempt > 0 { + // 指数退避:100ms, 200ms, 400ms, ... + let delay_ms = 100 * 2u64.pow(attempt as u32 - 1); + log::info!( + "[{}] 重试第 {}/{} 次(等待 {}ms)", + adapter.name(), + attempt, + self.max_retries, + delay_ms + ); + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + } + + match self.forward(provider, endpoint, body, headers, adapter).await { + Ok(response) => return Ok(response), + Err(e) => { + let category = self.categorize_proxy_error(&e); + + // 只有可重试的错误才继续重试 + if category == ErrorCategory::NonRetryable { + return Err(e); + } + + log::debug!( + "[{}] Provider {} 第 {} 次请求失败: {}", + adapter.name(), + provider.name, + attempt + 1, + e + ); + last_error = Some(e); + } + } + } + + Err(last_error.unwrap_or(ProxyError::MaxRetriesExceeded)) + } + /// 转发请求(带故障转移) + /// + /// # Arguments + /// * `app_type` - 应用类型 + /// * `endpoint` - API 端点 + /// * `body` - 请求体 + /// * `headers` - 请求头 + /// * `providers` - 已选择的 Provider 列表(由 RequestContext 提供,避免重复调用 select_providers) pub async fn forward_with_retry( &self, app_type: &AppType, endpoint: &str, body: Value, headers: axum::http::HeaderMap, + providers: Vec, ) -> Result { // 获取适配器 let adapter = get_adapter(app_type); let app_type_str = app_type.as_str(); - // 使用新的 ProviderRouter 选择所有可用供应商 - let providers = self - .router - .select_providers(app_type_str) - .await - .map_err(|e| ProxyError::DatabaseError(e.to_string()))?; - if providers.is_empty() { return Err(ProxyError::NoAvailableProvider); } @@ -108,9 +162,9 @@ impl RequestForwarder { let start = Instant::now(); - // 转发请求 + // 转发请求(带单 Provider 内重试) match self - .forward(provider, endpoint, &body, &headers, adapter.as_ref()) + .forward_with_provider_retry(provider, endpoint, &body, &headers, adapter.as_ref()) .await { Ok(response) => { @@ -373,17 +427,22 @@ impl RequestForwarder { } /// 分类ProxyError + /// + /// 决定哪些错误应该触发故障转移到下一个 Provider fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory { match error { ProxyError::Timeout(_) => ErrorCategory::Retryable, ProxyError::ForwardFailed(_) => ErrorCategory::Retryable, ProxyError::UpstreamError { status, .. } => { - if *status >= 500 { - ErrorCategory::Retryable - } else if *status >= 400 && *status < 500 { - ErrorCategory::NonRetryable - } else { - ErrorCategory::Retryable + match *status { + // 速率限制 - 应该尝试其他 Provider + 429 => ErrorCategory::Retryable, + // 请求超时 + 408 => ErrorCategory::Retryable, + // 服务器错误 + s if s >= 500 => ErrorCategory::Retryable, + // 其他 4xx 错误(认证失败、参数错误等)不应重试 + _ => ErrorCategory::NonRetryable, } } ProxyError::ProviderUnhealthy(_) => ErrorCategory::Retryable, diff --git a/src-tauri/src/proxy/handler_context.rs b/src-tauri/src/proxy/handler_context.rs index 08fbbf260..8748d0976 100644 --- a/src-tauri/src/proxy/handler_context.rs +++ b/src-tauri/src/proxy/handler_context.rs @@ -5,8 +5,7 @@ use crate::app_config::AppType; use crate::provider::Provider; use crate::proxy::{ - forwarder::RequestForwarder, router::ProviderRouter, server::ProxyState, types::ProxyConfig, - ProxyError, + forwarder::RequestForwarder, server::ProxyState, types::ProxyConfig, ProxyError, }; use std::time::Instant; @@ -15,7 +14,7 @@ use std::time::Instant; /// 贯穿整个请求生命周期,包含: /// - 计时信息 /// - 代理配置 -/// - 选中的 Provider +/// - 选中的 Provider 列表(用于故障转移) /// - 请求模型名称 /// - 日志标签 pub struct RequestContext { @@ -23,8 +22,10 @@ pub struct RequestContext { pub start_time: Instant, /// 代理配置快照 pub config: ProxyConfig, - /// 选中的 Provider + /// 选中的 Provider(故障转移链的第一个) pub provider: Provider, + /// 完整的 Provider 列表(用于故障转移) + providers: Vec, /// 请求中的模型名称 pub request_model: String, /// 日志标签(如 "Claude"、"Codex"、"Gemini") @@ -65,21 +66,32 @@ impl RequestContext { .unwrap_or("unknown") .to_string(); - // Provider 选择 - let router = ProviderRouter::new(state.db.clone()); - let provider = router.select_provider(&app_type, &[]).await?; + // 使用共享的 ProviderRouter 选择 Provider(熔断器状态跨请求保持) + // 注意:只在这里调用一次,结果传递给 forwarder,避免重复消耗 HalfOpen 名额 + let providers = state + .provider_router + .select_providers(app_type_str) + .await + .map_err(|e| ProxyError::DatabaseError(e.to_string()))?; + + let provider = providers + .first() + .cloned() + .ok_or(ProxyError::NoAvailableProvider)?; log::info!( - "[{}] Provider: {}, model: {}", + "[{}] Provider: {}, model: {}, failover chain: {} providers", tag, provider.name, - request_model + request_model, + providers.len() ); Ok(Self { start_time, config, provider, + providers, request_model, tag, app_type_str, @@ -110,9 +122,11 @@ impl RequestContext { } /// 创建 RequestForwarder + /// + /// 使用共享的 ProviderRouter,确保熔断器状态跨请求保持 pub fn create_forwarder(&self, state: &ProxyState) -> RequestForwarder { RequestForwarder::new( - state.db.clone(), + state.provider_router.clone(), self.config.request_timeout, self.config.max_retries, state.status.clone(), @@ -120,6 +134,13 @@ impl RequestContext { ) } + /// 获取 Provider 列表(用于故障转移) + /// + /// 返回在创建上下文时已选择的 providers,避免重复调用 select_providers() + pub fn get_providers(&self) -> Vec { + self.providers.clone() + } + /// 计算请求延迟(毫秒) #[inline] pub fn latency_ms(&self) -> u64 { diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 6e4704cae..ccbd1c5d0 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -84,7 +84,13 @@ pub async fn handle_messages( // 转发请求 let forwarder = ctx.create_forwarder(&state); let response = forwarder - .forward_with_retry(&AppType::Claude, "/v1/messages", body.clone(), headers) + .forward_with_retry( + &AppType::Claude, + "/v1/messages", + body.clone(), + headers, + ctx.get_providers(), + ) .await?; let status = response.status(); @@ -299,7 +305,13 @@ pub async fn handle_chat_completions( let forwarder = ctx.create_forwarder(&state); let response = forwarder - .forward_with_retry(&AppType::Codex, "/v1/chat/completions", body, headers) + .forward_with_retry( + &AppType::Codex, + "/v1/chat/completions", + body, + headers, + ctx.get_providers(), + ) .await?; log::info!("[Codex] 上游响应状态: {}", response.status()); @@ -317,7 +329,13 @@ pub async fn handle_responses( let forwarder = ctx.create_forwarder(&state); let response = forwarder - .forward_with_retry(&AppType::Codex, "/v1/responses", body, headers) + .forward_with_retry( + &AppType::Codex, + "/v1/responses", + body, + headers, + ctx.get_providers(), + ) .await?; log::info!("[Codex] 上游响应状态: {}", response.status()); @@ -351,7 +369,13 @@ pub async fn handle_gemini( let forwarder = ctx.create_forwarder(&state); let response = forwarder - .forward_with_retry(&AppType::Gemini, endpoint, body, headers) + .forward_with_retry( + &AppType::Gemini, + endpoint, + body, + headers, + ctx.get_providers(), + ) .await?; log::info!("[Gemini] 上游响应状态: {}", response.status()); diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index 5b052f0f7..d068d3205 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -13,7 +13,6 @@ pub mod provider_router; pub mod providers; pub mod response_handler; pub mod response_processor; -mod router; pub(crate) mod server; pub mod session; pub(crate) mod types; diff --git a/src-tauri/src/proxy/provider_router.rs b/src-tauri/src/proxy/provider_router.rs index 6866fc1c5..c3bb237a4 100644 --- a/src-tauri/src/proxy/provider_router.rs +++ b/src-tauri/src/proxy/provider_router.rs @@ -5,7 +5,7 @@ use crate::database::Database; use crate::error::AppError; use crate::provider::Provider; -use crate::proxy::circuit_breaker::CircuitBreaker; +use crate::proxy::circuit_breaker::{CircuitBreaker, CircuitBreakerConfig}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; @@ -124,7 +124,11 @@ impl ProviderRouter { success: bool, error_msg: Option, ) -> Result<(), AppError> { - // 1. 更新熔断器状态 + // 1. 获取熔断器配置(用于更新健康状态和判断是否禁用) + let config = self.db.get_circuit_breaker_config().await.ok(); + let failure_threshold = config.map(|c| c.failure_threshold).unwrap_or(5); + + // 2. 更新熔断器状态 let circuit_key = format!("{app_type}:{provider_id}"); let breaker = self.get_or_create_circuit_breaker(&circuit_key).await; @@ -140,19 +144,21 @@ impl ProviderRouter { ); } - // 2. 更新数据库健康状态 + // 3. 更新数据库健康状态(使用配置的阈值) self.db - .update_provider_health(provider_id, app_type, success, error_msg.clone()) + .update_provider_health_with_threshold( + provider_id, + app_type, + success, + error_msg.clone(), + failure_threshold, + ) .await?; - // 3. 如果连续失败达到熔断阈值,自动禁用代理目标 + // 4. 如果连续失败达到熔断阈值,自动禁用代理目标 if !success { let health = self.db.get_provider_health(provider_id, app_type).await?; - // 获取熔断器配置 - let config = self.db.get_circuit_breaker_config().await.ok(); - let failure_threshold = config.map(|c| c.failure_threshold).unwrap_or(5); - // 如果连续失败达到阈值,自动关闭该供应商的代理开关 if health.consecutive_failures >= failure_threshold { log::warn!( @@ -171,7 +177,6 @@ impl ProviderRouter { } /// 重置熔断器(手动恢复) - #[allow(dead_code)] pub async fn reset_circuit_breaker(&self, circuit_key: &str) { let breakers = self.circuit_breakers.read().await; if let Some(breaker) = breakers.get(circuit_key) { @@ -180,6 +185,27 @@ impl ProviderRouter { } } + /// 重置指定供应商的熔断器 + pub async fn reset_provider_breaker(&self, provider_id: &str, app_type: &str) { + let circuit_key = format!("{app_type}:{provider_id}"); + self.reset_circuit_breaker(&circuit_key).await; + } + + /// 更新所有熔断器的配置(热更新) + /// + /// 当用户在 UI 中修改熔断器配置后调用此方法, + /// 所有现有的熔断器会立即使用新配置 + pub async fn update_all_configs(&self, config: CircuitBreakerConfig) { + let breakers = self.circuit_breakers.read().await; + let count = breakers.len(); + + for breaker in breakers.values() { + breaker.update_config(config.clone()).await; + } + + log::info!("已更新 {} 个熔断器的配置", count); + } + /// 获取熔断器状态 #[allow(dead_code)] pub async fn get_circuit_breaker_stats( diff --git a/src-tauri/src/proxy/router.rs b/src-tauri/src/proxy/router.rs deleted file mode 100644 index c59c99bf8..000000000 --- a/src-tauri/src/proxy/router.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! Provider路由器 -//! -//! 负责选择合适的Provider进行请求转发 - -use super::ProxyError; -use crate::{app_config::AppType, database::Database, provider::Provider}; -use std::sync::Arc; - -pub struct ProviderRouter { - db: Arc, -} - -impl ProviderRouter { - pub fn new(db: Arc) -> Self { - Self { db } - } - - /// 选择Provider(只使用标记为代理目标的 Provider) - pub async fn select_provider( - &self, - app_type: &AppType, - _failed_ids: &[String], - ) -> Result { - // 1. 获取 Proxy Target Provider ID - let proxy_target_id = self - .db - .get_proxy_target_provider(app_type.as_str()) - .map_err(|e| ProxyError::DatabaseError(e.to_string()))?; - - let target_id = proxy_target_id.ok_or_else(|| { - log::warn!("[{}] 未设置代理目标 Provider", app_type.as_str()); - ProxyError::NoAvailableProvider - })?; - - // 2. 获取所有 Provider - let providers = self - .db - .get_all_providers(app_type.as_str()) - .map_err(|e| ProxyError::DatabaseError(e.to_string()))?; - - // 3. 找到目标 Provider - let target = providers.get(&target_id).ok_or_else(|| { - log::warn!( - "[{}] 代理目标 Provider 不存在: {}", - app_type.as_str(), - target_id - ); - ProxyError::NoAvailableProvider - })?; - - log::info!( - "[{}] 使用代理目标 Provider: {}", - app_type.as_str(), - target.name - ); - Ok(target.clone()) - } - - /// 更新Provider健康状态(保留接口但不影响选择) - #[allow(dead_code)] - pub async fn update_health( - &self, - _provider: &Provider, - _app_type: &AppType, - _success: bool, - _error_msg: Option, - ) { - // 不再记录健康状态 - } -} diff --git a/src-tauri/src/proxy/server.rs b/src-tauri/src/proxy/server.rs index 7c60ecf3b..93a89099d 100644 --- a/src-tauri/src/proxy/server.rs +++ b/src-tauri/src/proxy/server.rs @@ -2,7 +2,7 @@ //! //! 基于Axum的HTTP服务器,处理代理请求 -use super::{handlers, types::*, ProxyError}; +use super::{handlers, provider_router::ProviderRouter, types::*, ProxyError}; use crate::database::Database; use axum::{ routing::{get, post}, @@ -23,6 +23,8 @@ pub struct ProxyState { pub start_time: Arc>>, /// 每个应用类型当前使用的 provider (app_type -> (provider_id, provider_name)) pub current_providers: Arc>>, + /// 共享的 ProviderRouter(持有熔断器状态,跨请求保持) + pub provider_router: Arc, } /// 代理HTTP服务器 @@ -36,12 +38,16 @@ pub struct ProxyServer { impl ProxyServer { pub fn new(config: ProxyConfig, db: Arc) -> Self { + // 创建共享的 ProviderRouter(熔断器状态将跨所有请求保持) + let provider_router = Arc::new(ProviderRouter::new(db.clone())); + let state = ProxyState { db, config: Arc::new(RwLock::new(config.clone())), status: Arc::new(RwLock::new(ProxyStatus::default())), start_time: Arc::new(RwLock::new(None)), current_providers: Arc::new(RwLock::new(std::collections::HashMap::new())), + provider_router, }; Self { @@ -192,4 +198,22 @@ impl ProxyServer { pub async fn apply_runtime_config(&self, config: &ProxyConfig) { *self.state.config.write().await = config.clone(); } + + /// 热更新熔断器配置 + /// + /// 将新配置应用到所有已创建的熔断器实例 + pub async fn update_circuit_breaker_configs( + &self, + config: super::circuit_breaker::CircuitBreakerConfig, + ) { + self.state.provider_router.update_all_configs(config).await; + } + + /// 重置指定 Provider 的熔断器 + pub async fn reset_provider_circuit_breaker(&self, provider_id: &str, app_type: &str) { + self.state + .provider_router + .reset_provider_breaker(provider_id, app_type) + .await; + } } diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 45d55f313..f31bade7a 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -681,4 +681,41 @@ impl ProxyService { pub async fn is_running(&self) -> bool { self.server.read().await.is_some() } + + /// 热更新熔断器配置 + /// + /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例 + pub async fn update_circuit_breaker_configs( + &self, + config: crate::proxy::CircuitBreakerConfig, + ) -> Result<(), String> { + if let Some(server) = self.server.read().await.as_ref() { + server.update_circuit_breaker_configs(config).await; + log::info!("已热更新运行中的熔断器配置"); + } else { + log::debug!("代理服务器未运行,熔断器配置将在下次启动时生效"); + } + Ok(()) + } + + /// 重置指定 Provider 的熔断器 + /// + /// 如果代理服务器正在运行,立即重置内存中的熔断器状态 + pub async fn reset_provider_circuit_breaker( + &self, + provider_id: &str, + app_type: &str, + ) -> Result<(), String> { + if let Some(server) = self.server.read().await.as_ref() { + server + .reset_provider_circuit_breaker(provider_id, app_type) + .await; + log::info!( + "已重置 Provider {} (app: {}) 的熔断器", + provider_id, + app_type + ); + } + Ok(()) + } } From bfe9bb6a0c65c48277a304bf1bc0c818001bbbcd Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 14 Dec 2025 20:38:04 +0800 Subject: [PATCH 07/18] fix(proxy): resolve HalfOpen counter underflow and config field inconsistencies - Fix HalfOpen counter underflow: increment half_open_requests when transitioning from Open to HalfOpen to prevent underflow in record_success/record_failure - Fix Gemini config field names: unify to GEMINI_API_KEY and GOOGLE_GEMINI_BASE_URL (removed GOOGLE_API_KEY and GEMINI_API_BASE) - Fix Codex proxy takeover: write base_url to config.toml instead of OPENAI_BASE_URL in auth.json (Codex CLI reads from config.toml) --- src-tauri/src/proxy/circuit_breaker.rs | 2 ++ src-tauri/src/proxy/providers/gemini.rs | 18 ++++------ src-tauri/src/proxy/providers/mod.rs | 6 ++-- src-tauri/src/services/proxy.rs | 45 ++++++++++++++++++------- 4 files changed, 45 insertions(+), 26 deletions(-) diff --git a/src-tauri/src/proxy/circuit_breaker.rs b/src-tauri/src/proxy/circuit_breaker.rs index 3bdd9df85..210922552 100644 --- a/src-tauri/src/proxy/circuit_breaker.rs +++ b/src-tauri/src/proxy/circuit_breaker.rs @@ -115,6 +115,8 @@ impl CircuitBreaker { "Circuit breaker transitioning from Open to HalfOpen (timeout reached)" ); self.transition_to_half_open().await; + // 增加计数,确保 record_success/record_failure 减计数时不会下溢 + self.half_open_requests.fetch_add(1, Ordering::SeqCst); return true; } } diff --git a/src-tauri/src/proxy/providers/gemini.rs b/src-tauri/src/proxy/providers/gemini.rs index b148f5a48..ae869de60 100644 --- a/src-tauri/src/proxy/providers/gemini.rs +++ b/src-tauri/src/proxy/providers/gemini.rs @@ -120,11 +120,7 @@ impl GeminiAdapter { /// 从 Provider 配置中提取原始 API Key fn extract_key_raw(&self, provider: &Provider) -> Option { if let Some(env) = provider.settings_config.get("env") { - // 优先使用 GOOGLE_GEMINI_API_KEY - if let Some(key) = env.get("GOOGLE_GEMINI_API_KEY").and_then(|v| v.as_str()) { - return Some(key.to_string()); - } - // 备选 GEMINI_API_KEY + // 使用 GEMINI_API_KEY if let Some(key) = env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) { return Some(key.to_string()); } @@ -276,7 +272,7 @@ mod tests { let adapter = GeminiAdapter::new(); let provider = create_provider(json!({ "env": { - "GOOGLE_GEMINI_API_KEY": "AIza-test-key-12345678" + "GEMINI_API_KEY": "AIza-test-key-12345678" } })); @@ -291,7 +287,7 @@ mod tests { let adapter = GeminiAdapter::new(); let provider = create_provider(json!({ "env": { - "GOOGLE_GEMINI_API_KEY": "ya29.test-access-token-12345" + "GEMINI_API_KEY": "ya29.test-access-token-12345" } })); @@ -308,7 +304,7 @@ mod tests { let adapter = GeminiAdapter::new(); let provider = create_provider(json!({ "env": { - "GOOGLE_GEMINI_API_KEY": "{\"access_token\":\"ya29.test-token\",\"refresh_token\":\"1//refresh\"}" + "GEMINI_API_KEY": "{\"access_token\":\"ya29.test-token\",\"refresh_token\":\"1//refresh\"}" } })); @@ -324,7 +320,7 @@ mod tests { // API Key let api_key_provider = create_provider(json!({ "env": { - "GOOGLE_GEMINI_API_KEY": "AIza-test-key" + "GEMINI_API_KEY": "AIza-test-key" } })); assert_eq!( @@ -335,7 +331,7 @@ mod tests { // OAuth access_token let oauth_provider = create_provider(json!({ "env": { - "GOOGLE_GEMINI_API_KEY": "ya29.test-token" + "GEMINI_API_KEY": "ya29.test-token" } })); assert_eq!( @@ -346,7 +342,7 @@ mod tests { // OAuth JSON let oauth_json_provider = create_provider(json!({ "env": { - "GOOGLE_GEMINI_API_KEY": "{\"access_token\":\"ya29.test\"}" + "GEMINI_API_KEY": "{\"access_token\":\"ya29.test\"}" } })); assert_eq!( diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index f41a03b88..e5c589afe 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -369,7 +369,7 @@ mod tests { fn test_from_app_type_gemini_api_key() { let provider = create_provider(json!({ "env": { - "GOOGLE_GEMINI_API_KEY": "AIza-test-key" + "GEMINI_API_KEY": "AIza-test-key" } })); @@ -381,7 +381,7 @@ mod tests { fn test_from_app_type_gemini_cli_oauth() { let provider = create_provider(json!({ "env": { - "GOOGLE_GEMINI_API_KEY": "ya29.test-access-token" + "GEMINI_API_KEY": "ya29.test-access-token" } })); @@ -393,7 +393,7 @@ mod tests { fn test_from_app_type_gemini_cli_json() { let provider = create_provider(json!({ "env": { - "GOOGLE_GEMINI_API_KEY": "{\"access_token\":\"ya29.test\",\"refresh_token\":\"1//test\"}" + "GEMINI_API_KEY": "{\"access_token\":\"ya29.test\",\"refresh_token\":\"1//test\"}" } })); diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index f31bade7a..e8fd8e880 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -210,13 +210,13 @@ impl ProxyService { } } - // Gemini: 同步 GOOGLE_API_KEY + // Gemini: 同步 GEMINI_API_KEY if let Ok(live_config) = self.read_gemini_live() { if let Some(provider_id) = self.db.get_current_provider("gemini").ok().flatten() { if let Ok(Some(mut provider)) = self.db.get_provider_by_id(&provider_id, "gemini") { // 从 live 配置提取 token if let Some(env) = live_config.get("env") { - if let Some(token) = env.get("GOOGLE_API_KEY").and_then(|v| v.as_str()) { + if let Some(token) = env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) { if !token.is_empty() { // 更新 provider 的 settings_config if let Some(env_obj) = provider @@ -224,10 +224,10 @@ impl ProxyService { .get_mut("env") .and_then(|v| v.as_object_mut()) { - env_obj.insert("GOOGLE_API_KEY".to_string(), json!(token)); + env_obj.insert("GEMINI_API_KEY".to_string(), json!(token)); } else { provider.settings_config["env"] = json!({ - "GOOGLE_API_KEY": token + "GEMINI_API_KEY": token }); } // 保存到数据库 @@ -368,27 +368,35 @@ impl ProxyService { log::info!("Claude Live 配置已接管,代理地址: {}", proxy_url); } - // Codex: 修改 OPENAI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token) + // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token) if let Ok(mut live_config) = self.read_codex_live() { + // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符) if let Some(auth) = live_config.get_mut("auth").and_then(|v| v.as_object_mut()) { - auth.insert("OPENAI_BASE_URL".to_string(), json!(&proxy_url)); - // 使用占位符,避免显示缺少 key 的警告 auth.insert("OPENAI_API_KEY".to_string(), json!("PROXY_MANAGED")); } + + // 2. 修改 config.toml 中的 base_url + let config_str = live_config + .get("config") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let updated_config = Self::update_toml_base_url(config_str, &proxy_url); + live_config["config"] = json!(updated_config); + self.write_codex_live(&live_config)?; log::info!("Codex Live 配置已接管,代理地址: {}", proxy_url); } - // Gemini: 修改 GEMINI_API_BASE,使用占位符替代真实 Token(代理会注入真实 Token) + // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token) if let Ok(mut live_config) = self.read_gemini_live() { if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) { - env.insert("GEMINI_API_BASE".to_string(), json!(&proxy_url)); + env.insert("GOOGLE_GEMINI_BASE_URL".to_string(), json!(&proxy_url)); // 使用占位符,避免显示缺少 key 的警告 - env.insert("GOOGLE_API_KEY".to_string(), json!("PROXY_MANAGED")); + env.insert("GEMINI_API_KEY".to_string(), json!("PROXY_MANAGED")); } else { live_config["env"] = json!({ - "GEMINI_API_BASE": &proxy_url, - "GOOGLE_API_KEY": "PROXY_MANAGED" + "GOOGLE_GEMINI_BASE_URL": &proxy_url, + "GEMINI_API_KEY": "PROXY_MANAGED" }); } self.write_gemini_live(&live_config)?; @@ -526,6 +534,19 @@ impl ProxyService { // ==================== Live 配置读写辅助方法 ==================== + /// 更新 TOML 字符串中的 base_url + fn update_toml_base_url(toml_str: &str, new_url: &str) -> String { + use toml_edit::DocumentMut; + + let mut doc = toml_str + .parse::() + .unwrap_or_else(|_| DocumentMut::new()); + + doc["base_url"] = toml_edit::value(new_url); + + doc.to_string() + } + fn read_claude_live(&self) -> Result { let path = get_claude_settings_path(); if !path.exists() { From c49cfa5ac58c8a76fec6e71ba0da638d87e31a4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E7=BE=BD?= <92211915+qyinter@users.noreply.github.com> Date: Sun, 14 Dec 2025 22:52:13 +0900 Subject: [PATCH 08/18] =?UTF-8?q?feat(deeplink):=20=E6=B7=B1=E9=93=BE?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=94=A8=E9=87=8F=E6=9F=A5=E8=AF=A2=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=20(#400)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 新增功能 - 深链导入支持用量查询配置参数: - `usageEnabled`: 是否启用用量查询 - `usageScript`: Base64 编码的用量查询脚本 - `usageApiKey`: 用量查询专用 API Key - `usageBaseUrl`: 用量查询专用 Base URL - `usageAccessToken`: 访问令牌(NewAPI 模板) - `usageUserId`: 用户 ID(NewAPI 模板) - `usageAutoInterval`: 自动查询间隔(分钟) ## 修改文件 - **mod.rs**: DeepLinkImportRequest 结构体添加用量查询字段 - **parser.rs**: 解析 URL 中的用量查询参数 - **provider.rs**: 构建 ProviderMeta 包含 UsageScript 配置 - **deeplink.ts**: 添加 TypeScript 类型定义 - **DeepLinkImportDialog.tsx**: 确认对话框显示用量查询配置 ## Bug 修复 - **formatters.ts**: 修复 formatJSON() 格式化时删除 "env" 键的问题 ## 深链格式示例 ``` ccswitch://v1/import?resource=provider&app=claude&name=xxx&usageEnabled=true&usageScript={base64}&usageAutoInterval=30 ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 --- src-tauri/src/deeplink/mod.rs | 23 ++++++ src-tauri/src/deeplink/parser.rs | 41 +++++++++++ src-tauri/src/deeplink/provider.rs | 58 ++++++++++++++- src/components/DeepLinkImportDialog.tsx | 96 ++++++++++++++++++++++--- src/lib/api/deeplink.ts | 13 +++- src/utils/formatters.ts | 7 +- 6 files changed, 222 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/deeplink/mod.rs b/src-tauri/src/deeplink/mod.rs index 5c1e1a72e..4111089e2 100644 --- a/src-tauri/src/deeplink/mod.rs +++ b/src-tauri/src/deeplink/mod.rs @@ -113,4 +113,27 @@ pub struct DeepLinkImportRequest { /// Remote config URL #[serde(skip_serializing_if = "Option::is_none")] pub config_url: Option, + + // ============ Usage script fields (v3.9+) ============ + /// Whether to enable usage query (default: true if usage_script is provided) + #[serde(skip_serializing_if = "Option::is_none")] + pub usage_enabled: Option, + /// Base64 encoded usage query script code + #[serde(skip_serializing_if = "Option::is_none")] + pub usage_script: Option, + /// Usage query API key (if different from provider API key) + #[serde(skip_serializing_if = "Option::is_none")] + pub usage_api_key: Option, + /// Usage query base URL (if different from provider endpoint) + #[serde(skip_serializing_if = "Option::is_none")] + pub usage_base_url: Option, + /// Usage query access token (for NewAPI template) + #[serde(skip_serializing_if = "Option::is_none")] + pub usage_access_token: Option, + /// Usage query user ID (for NewAPI template) + #[serde(skip_serializing_if = "Option::is_none")] + pub usage_user_id: Option, + /// Auto query interval in minutes (0 to disable) + #[serde(skip_serializing_if = "Option::is_none")] + pub usage_auto_interval: Option, } diff --git a/src-tauri/src/deeplink/parser.rs b/src-tauri/src/deeplink/parser.rs index 316af0032..61cf7b68f 100644 --- a/src-tauri/src/deeplink/parser.rs +++ b/src-tauri/src/deeplink/parser.rs @@ -122,6 +122,19 @@ fn parse_provider_deeplink( let config_url = params.get("configUrl").cloned(); let enabled = params.get("enabled").and_then(|v| v.parse::().ok()); + // Extract usage script fields (v3.9+) + let usage_enabled = params + .get("usageEnabled") + .and_then(|v| v.parse::().ok()); + let usage_script = params.get("usageScript").cloned(); + let usage_api_key = params.get("usageApiKey").cloned(); + let usage_base_url = params.get("usageBaseUrl").cloned(); + let usage_access_token = params.get("usageAccessToken").cloned(); + let usage_user_id = params.get("usageUserId").cloned(); + let usage_auto_interval = params + .get("usageAutoInterval") + .and_then(|v| v.parse::().ok()); + Ok(DeepLinkImportRequest { version, resource, @@ -146,6 +159,13 @@ fn parse_provider_deeplink( config, config_format, config_url, + usage_enabled, + usage_script, + usage_api_key, + usage_base_url, + usage_access_token, + usage_user_id, + usage_auto_interval, }) } @@ -206,6 +226,13 @@ fn parse_prompt_deeplink( config: None, config_format: None, config_url: None, + usage_enabled: None, + usage_script: None, + usage_api_key: None, + usage_base_url: None, + usage_access_token: None, + usage_user_id: None, + usage_auto_interval: None, }) } @@ -261,6 +288,13 @@ fn parse_mcp_deeplink( directory: None, branch: None, config_url: None, + usage_enabled: None, + usage_script: None, + usage_api_key: None, + usage_base_url: None, + usage_access_token: None, + usage_user_id: None, + usage_auto_interval: None, }) } @@ -309,5 +343,12 @@ fn parse_skill_deeplink( config: None, config_format: None, config_url: None, + usage_enabled: None, + usage_script: None, + usage_api_key: None, + usage_base_url: None, + usage_access_token: None, + usage_user_id: None, + usage_auto_interval: None, }) } diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 3b6cd8631..f1bac94cd 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -5,7 +5,7 @@ use super::utils::{decode_base64_param, infer_homepage_from_endpoint}; use super::DeepLinkImportRequest; use crate::error::AppError; -use crate::provider::Provider; +use crate::provider::{Provider, ProviderMeta, UsageScript}; use crate::services::ProviderService; use crate::store::AppState; use crate::AppType; @@ -117,6 +117,9 @@ pub(crate) fn build_provider_from_request( AppType::Gemini => build_gemini_settings(request), }; + // Build usage script configuration if provided + let meta = build_provider_meta(request)?; + let provider = Provider { id: String::new(), // Will be generated by caller name: request.name.clone().unwrap_or_default(), @@ -126,7 +129,7 @@ pub(crate) fn build_provider_from_request( created_at: None, sort_index: None, notes: request.notes.clone(), - meta: None, + meta, icon: request.icon.clone(), icon_color: None, is_proxy_target: None, @@ -135,6 +138,57 @@ pub(crate) fn build_provider_from_request( Ok(provider) } +/// Build provider meta with usage script configuration +fn build_provider_meta(request: &DeepLinkImportRequest) -> Result, AppError> { + // Check if any usage script fields are provided + if request.usage_script.is_none() + && request.usage_enabled.is_none() + && request.usage_api_key.is_none() + && request.usage_base_url.is_none() + && request.usage_access_token.is_none() + && request.usage_user_id.is_none() + && request.usage_auto_interval.is_none() + { + return Ok(None); + } + + // Decode usage script code if provided + let code = if let Some(script_b64) = &request.usage_script { + let decoded = decode_base64_param("usage_script", script_b64)?; + String::from_utf8(decoded) + .map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in usage_script: {e}")))? + } else { + String::new() + }; + + // Determine enabled state: explicit param > has code > false + let enabled = request.usage_enabled.unwrap_or(!code.is_empty()); + + // Build UsageScript - use provider's API key and endpoint as defaults + let usage_script = UsageScript { + enabled, + language: "javascript".to_string(), + code, + timeout: Some(10), + api_key: request + .usage_api_key + .clone() + .or_else(|| request.api_key.clone()), + base_url: request + .usage_base_url + .clone() + .or_else(|| request.endpoint.clone()), + access_token: request.usage_access_token.clone(), + user_id: request.usage_user_id.clone(), + auto_query_interval: request.usage_auto_interval, + }; + + Ok(Some(ProviderMeta { + usage_script: Some(usage_script), + ..Default::default() + })) +} + /// Build Claude settings configuration fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value { let mut env = serde_json::Map::new(); diff --git a/src/components/DeepLinkImportDialog.tsx b/src/components/DeepLinkImportDialog.tsx index 96035e38c..5f6f9cc66 100644 --- a/src/components/DeepLinkImportDialog.tsx +++ b/src/components/DeepLinkImportDialog.tsx @@ -32,7 +32,7 @@ export function DeepLinkImportDialog() { // 容错判断:MCP 导入结果可能缺少 type 字段 const isMcpImportResult = ( - value: unknown, + value: unknown ): value is { importedCount: number; importedIds: string[]; @@ -59,7 +59,7 @@ export function DeepLinkImportDialog() { if (event.payload.config || event.payload.configUrl) { try { const mergedRequest = await deeplinkApi.mergeDeeplinkConfig( - event.payload, + event.payload ); console.log("Config merged successfully:", mergedRequest); setRequest(mergedRequest); @@ -77,7 +77,7 @@ export function DeepLinkImportDialog() { } setIsOpen(true); - }, + } ); // Listen for deep link error events @@ -148,7 +148,7 @@ export function DeepLinkImportDialog() { window.dispatchEvent( new CustomEvent("prompt-imported", { detail: { app: request.app }, - }), + }) ); toast.success(t("deeplink.promptImportSuccess"), { description: t("deeplink.promptImportSuccessDescription", { @@ -279,7 +279,7 @@ export function DeepLinkImportDialog() { const maskValue = (key: string, value: string): string => { const sensitiveKeys = ["TOKEN", "KEY", "SECRET", "PASSWORD"]; const isSensitive = sensitiveKeys.some((k) => - key.toUpperCase().includes(k), + key.toUpperCase().includes(k) ); if (isSensitive && value.length > 8) { return `${value.substring(0, 8)}${"*".repeat(12)}`; @@ -521,7 +521,7 @@ export function DeepLinkImportDialog() { {maskValue(key, String(value))} - ), + ) )} )} @@ -548,7 +548,7 @@ export function DeepLinkImportDialog() { {maskValue(key, String(value))} - ), + ) )} )} @@ -584,7 +584,7 @@ export function DeepLinkImportDialog() { {maskValue(key, String(value))} - ), + ) )} )} @@ -605,6 +605,86 @@ export function DeepLinkImportDialog() { )} + {/* Usage Script Configuration (v3.9+) */} + {request.usageScript && ( +
+
+
+ {t("deeplink.usageScript", { + defaultValue: "用量查询", + })} +
+
+ + {request.usageEnabled !== false + ? t("deeplink.usageScriptEnabled", { + defaultValue: "已启用", + }) + : t("deeplink.usageScriptDisabled", { + defaultValue: "未启用", + })} + +
+
+ + {/* Usage API Key (if different from provider) */} + {request.usageApiKey && + request.usageApiKey !== request.apiKey && ( +
+
+ {t("deeplink.usageApiKey", { + defaultValue: "用量 API Key", + })} +
+
+ {request.usageApiKey.length > 4 + ? `${request.usageApiKey.substring(0, 4)}${"*".repeat(12)}` + : "****"} +
+
+ )} + + {/* Usage Base URL (if different from provider) */} + {request.usageBaseUrl && + request.usageBaseUrl !== request.endpoint && ( +
+
+ {t("deeplink.usageBaseUrl", { + defaultValue: "用量查询地址", + })} +
+
+ {request.usageBaseUrl} +
+
+ )} + + {/* Auto Query Interval */} + {request.usageAutoInterval && + request.usageAutoInterval > 0 && ( +
+
+ {t("deeplink.usageAutoInterval", { + defaultValue: "自动查询", + })} +
+
+ {t("deeplink.usageAutoIntervalValue", { + defaultValue: "每 {{minutes}} 分钟", + minutes: request.usageAutoInterval, + })} +
+
+ )} +
+ )} + {/* Warning */}
{t("deeplink.warning")} diff --git a/src/lib/api/deeplink.ts b/src/lib/api/deeplink.ts index 003c19f68..6b03175f9 100644 --- a/src/lib/api/deeplink.ts +++ b/src/lib/api/deeplink.ts @@ -38,6 +38,15 @@ export interface DeepLinkImportRequest { config?: string; configFormat?: string; configUrl?: string; + + // Usage script fields (v3.9+) + usageEnabled?: boolean; + usageScript?: string; + usageApiKey?: string; + usageBaseUrl?: string; + usageAccessToken?: string; + usageUserId?: string; + usageAutoInterval?: number; } export interface McpImportResult { @@ -77,7 +86,7 @@ export const deeplinkApi = { * @returns Merged deep link request with config fields populated */ mergeDeeplinkConfig: async ( - request: DeepLinkImportRequest, + request: DeepLinkImportRequest ): Promise => { return invoke("merge_deeplink_config", { request }); }, @@ -88,7 +97,7 @@ export const deeplinkApi = { * @returns Import result based on resource type */ importFromDeeplink: async ( - request: DeepLinkImportRequest, + request: DeepLinkImportRequest ): Promise => { return invoke("import_from_deeplink_unified", { request }); }, diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts index 0f5a52c62..04e23a951 100644 --- a/src/utils/formatters.ts +++ b/src/utils/formatters.ts @@ -1,6 +1,6 @@ /** * 格式化 JSON 字符串 - * @param value - 原始 JSON 字符串(支持带键名包装的格式) + * @param value - 原始 JSON 字符串 * @returns 格式化后的 JSON 字符串(2 空格缩进) * @throws 如果 JSON 格式无效 */ @@ -9,9 +9,8 @@ export function formatJSON(value: string): string { if (!trimmed) { return ""; } - // 使用智能解析器来处理可能的片段格式 - const result = parseSmartMcpJson(trimmed); - return result.formattedConfig; + const parsed = JSON.parse(trimmed); + return JSON.stringify(parsed, null, 2); } /** From 1172209f49a9db9bae1e1fa67a3bc0241e1fa99c Mon Sep 17 00:00:00 2001 From: Sirhexs <43758823+Sirhexs@users.noreply.github.com> Date: Mon, 15 Dec 2025 17:09:46 +0800 Subject: [PATCH 09/18] fix(usage): add fallback to provider config for usage credentials (#360) - Make usage script credential fields optional with provider config fallback - Optimize multi-plan card display: show plan count by default, expandable for details - Add hint text to explain credential fallback mechanism --- src-tauri/src/services/provider/usage.rs | 48 +++++++++- src/components/UsageScriptModal.tsx | 27 ++++-- src/components/providers/ProviderCard.tsx | 101 +++++++++++++++++++--- src/i18n/locales/en.json | 9 +- src/i18n/locales/ja.json | 9 +- src/i18n/locales/zh.json | 9 +- 6 files changed, 177 insertions(+), 26 deletions(-) diff --git a/src-tauri/src/services/provider/usage.rs b/src-tauri/src/services/provider/usage.rs index ee95c00c7..6ff94ca93 100644 --- a/src-tauri/src/services/provider/usage.rs +++ b/src-tauri/src/services/provider/usage.rs @@ -79,6 +79,34 @@ pub(crate) async fn execute_and_format_usage_result( } } +/// Extract API key from provider configuration +fn extract_api_key_from_provider(provider: &crate::provider::Provider) -> Option { + if let Some(env) = provider.settings_config.get("env") { + // Try multiple possible API key fields + env.get("ANTHROPIC_AUTH_TOKEN") + .or_else(|| env.get("ANTHROPIC_API_KEY")) + .or_else(|| env.get("OPENROUTER_API_KEY")) + .or_else(|| env.get("GOOGLE_API_KEY")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + } else { + None + } +} + +/// Extract base URL from provider configuration +fn extract_base_url_from_provider(provider: &crate::provider::Provider) -> Option { + if let Some(env) = provider.settings_config.get("env") { + // Try multiple possible base URL fields + env.get("ANTHROPIC_BASE_URL") + .or_else(|| env.get("GOOGLE_GEMINI_BASE_URL")) + .and_then(|v| v.as_str()) + .map(|s| s.trim_end_matches('/').to_string()) + } else { + None + } +} + /// Query provider usage (using saved script configuration) pub async fn query_usage( state: &AppState, @@ -114,12 +142,26 @@ pub async fn query_usage( )); } - // Get credentials directly from UsageScript, no longer extract from provider config + // Get credentials: prioritize UsageScript values, fallback to provider config + let api_key = usage_script + .api_key + .clone() + .filter(|k| !k.is_empty()) + .or_else(|| extract_api_key_from_provider(provider)) + .unwrap_or_default(); + + let base_url = usage_script + .base_url + .clone() + .filter(|u| !u.is_empty()) + .or_else(|| extract_base_url_from_provider(provider)) + .unwrap_or_default(); + ( usage_script.code.clone(), usage_script.timeout.unwrap_or(10), - usage_script.api_key.clone().unwrap_or_default(), - usage_script.base_url.clone().unwrap_or_default(), + api_key, + base_url, usage_script.access_token.clone(), usage_script.user_id.clone(), ) diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index bbda3c05e..b7ca6a107 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -400,15 +400,25 @@ const UsageScriptModal: React.FC = ({ {/* 凭证配置 */} {shouldShowCredentialsConfig && (
-

- {t("usageScript.credentialsConfig")} -

+
+

+ {t("usageScript.credentialsConfig")} +

+

+ {t("usageScript.credentialsHint")} +

+
{selectedTemplate === TEMPLATE_KEYS.GENERAL && ( <>
- +
= ({ onChange={(e) => setScript({ ...script, apiKey: e.target.value }) } - placeholder="sk-xxxxx" + placeholder={t("usageScript.apiKeyPlaceholder")} autoComplete="off" className="border-white/10" /> @@ -444,7 +454,10 @@ const UsageScriptModal: React.FC = ({
= ({ onChange={(e) => setScript({ ...script, baseUrl: e.target.value }) } - placeholder="https://api.example.com" + placeholder={t("usageScript.baseUrlPlaceholder")} autoComplete="off" className="border-white/10" /> diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index 2f8029b2e..a4167b711 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -1,5 +1,5 @@ -import { useMemo } from "react"; -import { GripVertical } from "lucide-react"; +import { useMemo, useState, useEffect } from "react"; +import { GripVertical, ChevronDown, ChevronUp } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { DraggableAttributes, @@ -17,6 +17,7 @@ import { useResetCircuitBreaker, } from "@/lib/query/failover"; import { toast } from "sonner"; +import { useUsageQuery } from "@/lib/query/queries"; interface DragHandleProps { attributes: DraggableAttributes; @@ -146,6 +147,29 @@ export function ProviderCard({ const usageEnabled = provider.meta?.usage_script?.enabled ?? false; + // 获取用量数据以判断是否有多套餐 + const autoQueryInterval = isCurrent + ? provider.meta?.usage_script?.autoQueryInterval || 0 + : 0; + + const { data: usage } = useUsageQuery(provider.id, appId, { + enabled: usageEnabled, + autoQueryInterval, + }); + + const hasMultiplePlans = + usage?.success && usage.data && usage.data.length > 1; + + // 多套餐默认展开 + const [isExpanded, setIsExpanded] = useState(false); + + // 当检测到多套餐时自动展开 + useEffect(() => { + if (hasMultiplePlans) { + setIsExpanded(true); + } + }, [hasMultiplePlans]); + const handleOpenWebsite = () => { if (!isClickableUrl) { return; @@ -247,19 +271,56 @@ export function ProviderCard({
-
-
- +
+ {/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */} +
+
+ {/* 多套餐时显示套餐数量,单套餐时显示详细信息 */} + {hasMultiplePlans ? ( +
+ + {t("usage.multiplePlans", { + count: usage?.data?.length || 0, + defaultValue: `${usage?.data?.length || 0} 个套餐`, + })} + +
+ ) : ( + + )} + {/* 展开/折叠按钮 - 仅在有多套餐时显示 */} + {hasMultiplePlans && ( + + )} +
-
+ {/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入 */} +
+ + {/* 展开的完整套餐列表 */} + {isExpanded && hasMultiplePlans && ( +
+ +
+ )}
); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 24b785791..d67eab03f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -365,7 +365,10 @@ "justNow": "Just now", "minutesAgo": "{{count}} min ago", "hoursAgo": "{{count}} hr ago", - "daysAgo": "{{count}} day ago" + "daysAgo": "{{count}} day ago", + "multiplePlans": "{{count}} plans", + "expand": "Expand", + "collapse": "Collapse" }, "usageScript": { "title": "Configure Usage Query", @@ -378,6 +381,10 @@ "templateGeneral": "General", "templateNewAPI": "NewAPI", "credentialsConfig": "Credentials", + "credentialsHint": "Leave empty to use provider config", + "optional": "optional", + "apiKeyPlaceholder": "Leave empty to use provider's API Key", + "baseUrlPlaceholder": "Leave empty to use provider's base URL", "baseUrl": "Base URL", "accessToken": "Access Token", "accessTokenPlaceholder": "Generate in 'Security Settings'", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 0fab2caa1..39f9252e8 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -365,7 +365,10 @@ "justNow": "たった今", "minutesAgo": "{{count}} 分前", "hoursAgo": "{{count}} 時間前", - "daysAgo": "{{count}} 日前" + "daysAgo": "{{count}} 日前", + "multiplePlans": "{{count}} プラン", + "expand": "展開", + "collapse": "折りたたむ" }, "usageScript": { "title": "利用状況を設定", @@ -378,6 +381,10 @@ "templateGeneral": "General", "templateNewAPI": "NewAPI", "credentialsConfig": "認証情報", + "credentialsHint": "空欄の場合はプロバイダー設定を使用", + "optional": "オプション", + "apiKeyPlaceholder": "空欄の場合はプロバイダーの API Key を使用", + "baseUrlPlaceholder": "空欄の場合はプロバイダーの Base URL を使用", "baseUrl": "Base URL", "accessToken": "Access Token", "accessTokenPlaceholder": "「Security Settings」で生成", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 8809e4744..a7485e356 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -365,7 +365,10 @@ "justNow": "刚刚", "minutesAgo": "{{count}} 分钟前", "hoursAgo": "{{count}} 小时前", - "daysAgo": "{{count}} 天前" + "daysAgo": "{{count}} 天前", + "multiplePlans": "{{count}} 个套餐", + "expand": "展开", + "collapse": "收起" }, "usageScript": { "title": "配置用量查询", @@ -378,6 +381,10 @@ "templateGeneral": "通用模板", "templateNewAPI": "NewAPI", "credentialsConfig": "凭证配置", + "credentialsHint": "留空则自动使用供应商配置", + "optional": "可选", + "apiKeyPlaceholder": "留空则使用供应商的 API Key", + "baseUrlPlaceholder": "留空则使用供应商的请求地址", "baseUrl": "请求地址", "accessToken": "访问令牌(在个人安全设置里获取)", "accessTokenPlaceholder": "在'安全设置'里生成", From 9196d07925a4ad14fcb935db1dbe02193c6ab59d Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 15 Dec 2025 16:28:00 +0800 Subject: [PATCH 10/18] feat(proxy): sync UI when failover succeeds Add FailoverSwitchManager to handle provider switching after successful failover. This ensures the UI reflects the actual provider in use: - Create failover_switch.rs with deduplication and async switching logic - Pass AppHandle through ProxyService -> ProxyServer -> RequestForwarder - Update is_current in database when failover succeeds - Emit provider-switched event for frontend refresh - Update tray menu and live backup synchronously The switching runs asynchronously via tokio::spawn to avoid blocking API responses while still providing immediate UI feedback. --- src-tauri/src/lib.rs | 5 + src-tauri/src/proxy/failover_switch.rs | 148 +++++++++++++++++++++++++ src-tauri/src/proxy/forwarder.rs | 23 ++++ src-tauri/src/proxy/handler_context.rs | 2 + src-tauri/src/proxy/mod.rs | 1 + src-tauri/src/proxy/server.rs | 19 +++- src-tauri/src/services/proxy.rs | 16 ++- 7 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 src-tauri/src/proxy/failover_switch.rs diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6a5874f7f..8a9e767f0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -332,6 +332,11 @@ pub fn run() { let app_state = AppState::new(db); + // 设置 AppHandle 用于代理故障转移时的 UI 更新 + app_state + .proxy_service + .set_app_handle(app.handle().clone()); + // ============================================================ // 按表独立判断的导入逻辑(各类数据独立检查,互不影响) // ============================================================ diff --git a/src-tauri/src/proxy/failover_switch.rs b/src-tauri/src/proxy/failover_switch.rs new file mode 100644 index 000000000..22d636e34 --- /dev/null +++ b/src-tauri/src/proxy/failover_switch.rs @@ -0,0 +1,148 @@ +//! 故障转移切换模块 +//! +//! 处理故障转移成功后的供应商切换逻辑,包括: +//! - 去重控制(避免多个请求同时触发) +//! - 数据库更新 +//! - 托盘菜单更新 +//! - 前端事件发射 +//! - Live 备份更新 + +use crate::database::Database; +use crate::error::AppError; +use std::collections::HashSet; +use std::str::FromStr; +use std::sync::Arc; +use tauri::{Emitter, Manager}; +use tokio::sync::RwLock; + +/// 故障转移切换管理器 +/// +/// 负责处理故障转移成功后的供应商切换,确保 UI 能够直观反映当前使用的供应商。 +#[derive(Clone)] +pub struct FailoverSwitchManager { + /// 正在处理中的切换(key = "app_type:provider_id") + pending_switches: Arc>>, + db: Arc, +} + +impl FailoverSwitchManager { + pub fn new(db: Arc) -> Self { + Self { + pending_switches: Arc::new(RwLock::new(HashSet::new())), + db, + } + } + + /// 尝试执行故障转移切换 + /// + /// 如果相同的切换已在进行中,则跳过;否则执行切换逻辑。 + /// + /// # Returns + /// - `Ok(true)` - 切换成功执行 + /// - `Ok(false)` - 切换已在进行中,跳过 + /// - `Err(e)` - 切换过程中发生错误 + pub async fn try_switch( + &self, + app_handle: Option<&tauri::AppHandle>, + app_type: &str, + provider_id: &str, + provider_name: &str, + ) -> Result { + let switch_key = format!("{}:{}", app_type, provider_id); + + // 去重检查:如果相同切换已在进行中,跳过 + { + let mut pending = self.pending_switches.write().await; + if pending.contains(&switch_key) { + log::debug!( + "[Failover] 切换已在进行中,跳过: {} -> {}", + app_type, + provider_id + ); + return Ok(false); + } + pending.insert(switch_key.clone()); + } + + // 执行切换(确保最后清理 pending 标记) + let result = self + .do_switch(app_handle, app_type, provider_id, provider_name) + .await; + + // 清理 pending 标记 + { + let mut pending = self.pending_switches.write().await; + pending.remove(&switch_key); + } + + result + } + + async fn do_switch( + &self, + app_handle: Option<&tauri::AppHandle>, + app_type: &str, + provider_id: &str, + provider_name: &str, + ) -> Result { + log::info!( + "[Failover] 开始切换供应商: {} -> {} ({})", + app_type, + provider_name, + provider_id + ); + + // 1. 更新数据库 is_current + self.db.set_current_provider(app_type, provider_id)?; + + // 2. 更新本地 settings(设备级) + let app_type_enum = crate::app_config::AppType::from_str(app_type) + .map_err(|_| AppError::Message(format!("无效的应用类型: {}", app_type)))?; + crate::settings::set_current_provider(&app_type_enum, Some(provider_id))?; + + // 3. 更新托盘菜单和发射事件 + if let Some(app) = app_handle { + // 更新托盘菜单 + if let Some(app_state) = app.try_state::() { + // 更新 Live 备份(确保代理停止时恢复正确配置) + if let Ok(Some(provider)) = self.db.get_provider_by_id(provider_id, app_type) { + if let Err(e) = app_state + .proxy_service + .update_live_backup_from_provider(app_type, &provider) + .await + { + log::warn!("[Failover] 更新 Live 备份失败: {e}"); + } + } + + // 重建托盘菜单 + if let Ok(new_menu) = crate::tray::create_tray_menu(app, app_state.inner()) { + if let Some(tray) = app.tray_by_id("main") { + if let Err(e) = tray.set_menu(Some(new_menu)) { + log::error!("[Failover] 更新托盘菜单失败: {e}"); + } + } + } + } + + // 发射事件到前端 + let event_data = serde_json::json!({ + "appType": app_type, + "providerId": provider_id, + "source": "failover" // 标识来源是故障转移 + }); + if let Err(e) = app.emit("provider-switched", event_data) { + log::error!("[Failover] 发射供应商切换事件失败: {e}"); + } + } + + log::info!( + "[Failover] 供应商切换完成: {} -> {} ({})", + app_type, + provider_name, + provider_id + ); + + Ok(true) + } +} diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 385557132..1cfa68c78 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -4,6 +4,7 @@ use super::{ error::*, + failover_switch::FailoverSwitchManager, provider_router::ProviderRouter, providers::{get_adapter, ProviderAdapter}, types::ProxyStatus, @@ -24,6 +25,10 @@ pub struct RequestForwarder { max_retries: u8, status: Arc>, current_providers: Arc>>, + /// 故障转移切换管理器 + failover_manager: Arc, + /// AppHandle,用于发射事件和更新托盘 + app_handle: Option, } impl RequestForwarder { @@ -33,6 +38,8 @@ impl RequestForwarder { max_retries: u8, status: Arc>, current_providers: Arc>>, + failover_manager: Arc, + app_handle: Option, ) -> Self { let mut client_builder = Client::builder(); if timeout_secs > 0 { @@ -49,6 +56,8 @@ impl RequestForwarder { max_retries, status, current_providers, + failover_manager, + app_handle, } } @@ -201,6 +210,20 @@ impl RequestForwarder { provider.name, latency ); + + // 异步触发供应商切换,更新 UI 和托盘菜单 + let fm = self.failover_manager.clone(); + let ah = self.app_handle.clone(); + let pid = provider.id.clone(); + let pname = provider.name.clone(); + let at = app_type_str.to_string(); + + tokio::spawn(async move { + if let Err(e) = fm.try_switch(ah.as_ref(), &at, &pid, &pname).await + { + log::error!("[Failover] 切换供应商失败: {e}"); + } + }); } // 重新计算成功率 if status.total_requests > 0 { diff --git a/src-tauri/src/proxy/handler_context.rs b/src-tauri/src/proxy/handler_context.rs index 8748d0976..e8ba7c214 100644 --- a/src-tauri/src/proxy/handler_context.rs +++ b/src-tauri/src/proxy/handler_context.rs @@ -131,6 +131,8 @@ impl RequestContext { self.config.max_retries, state.status.clone(), state.current_providers.clone(), + state.failover_manager.clone(), + state.app_handle.clone(), ) } diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index d068d3205..78d44d9da 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -4,6 +4,7 @@ pub mod circuit_breaker; pub mod error; +pub(crate) mod failover_switch; mod forwarder; pub mod handler_config; pub mod handler_context; diff --git a/src-tauri/src/proxy/server.rs b/src-tauri/src/proxy/server.rs index 93a89099d..11bfd8ed1 100644 --- a/src-tauri/src/proxy/server.rs +++ b/src-tauri/src/proxy/server.rs @@ -2,7 +2,10 @@ //! //! 基于Axum的HTTP服务器,处理代理请求 -use super::{handlers, provider_router::ProviderRouter, types::*, ProxyError}; +use super::{ + failover_switch::FailoverSwitchManager, handlers, provider_router::ProviderRouter, types::*, + ProxyError, +}; use crate::database::Database; use axum::{ routing::{get, post}, @@ -25,6 +28,10 @@ pub struct ProxyState { pub current_providers: Arc>>, /// 共享的 ProviderRouter(持有熔断器状态,跨请求保持) pub provider_router: Arc, + /// AppHandle,用于发射事件和更新托盘菜单 + pub app_handle: Option, + /// 故障转移切换管理器 + pub failover_manager: Arc, } /// 代理HTTP服务器 @@ -37,9 +44,15 @@ pub struct ProxyServer { } impl ProxyServer { - pub fn new(config: ProxyConfig, db: Arc) -> Self { + pub fn new( + config: ProxyConfig, + db: Arc, + app_handle: Option, + ) -> Self { // 创建共享的 ProviderRouter(熔断器状态将跨所有请求保持) let provider_router = Arc::new(ProviderRouter::new(db.clone())); + // 创建故障转移切换管理器 + let failover_manager = Arc::new(FailoverSwitchManager::new(db.clone())); let state = ProxyState { db, @@ -48,6 +61,8 @@ impl ProxyServer { start_time: Arc::new(RwLock::new(None)), current_providers: Arc::new(RwLock::new(std::collections::HashMap::new())), provider_router, + app_handle, + failover_manager, }; Self { diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index e8fd8e880..bb4fed5be 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -17,6 +17,8 @@ use tokio::sync::RwLock; pub struct ProxyService { db: Arc, server: Arc>>, + /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新 + app_handle: Arc>>, } impl ProxyService { @@ -24,9 +26,17 @@ impl ProxyService { Self { db, server: Arc::new(RwLock::new(None)), + app_handle: Arc::new(RwLock::new(None)), } } + /// 设置 AppHandle(在应用初始化时调用) + pub fn set_app_handle(&self, handle: tauri::AppHandle) { + futures::executor::block_on(async { + *self.app_handle.write().await = Some(handle); + }); + } + /// 启动代理服务器 pub async fn start(&self) -> Result { // 1. 获取配置 @@ -45,7 +55,8 @@ impl ProxyService { } // 4. 创建并启动服务器 - let server = ProxyServer::new(config.clone(), self.db.clone()); + let app_handle = self.app_handle.read().await.clone(); + let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle); let info = server .start() .await @@ -682,7 +693,8 @@ impl ProxyService { .map_err(|e| format!("重启前停止代理服务器失败: {e}"))?; } - let new_server = ProxyServer::new(new_config, self.db.clone()); + let app_handle = self.app_handle.read().await.clone(); + let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle); new_server .start() .await From 007813e09e78e236361513fcea4ccc601e2e10b6 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 15 Dec 2025 16:45:29 +0800 Subject: [PATCH 11/18] fix(proxy): retry failover for all HTTP errors including 4xx Previously, only 429, 408, and 5xx errors triggered failover to the next provider. Other 4xx errors (like 400) were considered non-retryable and caused immediate disconnection. This was problematic because different providers have different restrictions (e.g., "Do not use this API outside Claude Code CLI"), and a 400 error from one provider doesn't mean other providers will fail. Now all upstream HTTP errors trigger failover, allowing the system to try all configured providers before giving up. --- src-tauri/src/proxy/forwarder.rs | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 1cfa68c78..4eadb00c3 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -88,7 +88,10 @@ impl RequestForwarder { tokio::time::sleep(Duration::from_millis(delay_ms)).await; } - match self.forward(provider, endpoint, body, headers, adapter).await { + match self + .forward(provider, endpoint, body, headers, adapter) + .await + { Ok(response) => return Ok(response), Err(e) => { let category = self.categorize_proxy_error(&e); @@ -452,24 +455,22 @@ impl RequestForwarder { /// 分类ProxyError /// /// 决定哪些错误应该触发故障转移到下一个 Provider + /// + /// 设计原则:既然用户配置了多个供应商,就应该让所有供应商都尝试一遍。 + /// 只有明确是客户端中断的情况才不重试。 fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory { match error { + // 网络和上游错误:都应该尝试下一个供应商 ProxyError::Timeout(_) => ErrorCategory::Retryable, ProxyError::ForwardFailed(_) => ErrorCategory::Retryable, - ProxyError::UpstreamError { status, .. } => { - match *status { - // 速率限制 - 应该尝试其他 Provider - 429 => ErrorCategory::Retryable, - // 请求超时 - 408 => ErrorCategory::Retryable, - // 服务器错误 - s if s >= 500 => ErrorCategory::Retryable, - // 其他 4xx 错误(认证失败、参数错误等)不应重试 - _ => ErrorCategory::NonRetryable, - } - } ProxyError::ProviderUnhealthy(_) => ErrorCategory::Retryable, + // 上游 HTTP 错误:无论状态码如何,都尝试下一个供应商 + // 原因:不同供应商有不同的限制和认证,一个供应商的 4xx 错误 + // 不代表其他供应商也会失败 + ProxyError::UpstreamError { .. } => ErrorCategory::Retryable, + // 无可用供应商:所有供应商都试过了,无法重试 ProxyError::NoAvailableProvider => ErrorCategory::NonRetryable, + // 其他错误(配置错误、数据库错误等):不是供应商问题,无需重试 _ => ErrorCategory::NonRetryable, } } From e081c7560cd61302bef0ec05919577765b52c4e6 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 15 Dec 2025 22:52:58 +0800 Subject: [PATCH 12/18] fix(proxy): reset health badges when proxy stops Clear all provider_health records when stopping the proxy server, ensuring health badges reset to "healthy" state. This fixes the inconsistency where circuit breakers (in memory) would reset on stop but health badges (in database) would retain stale state. --- src-tauri/src/database/dao/proxy.rs | 11 +++++++++++ src-tauri/src/services/proxy.rs | 6 ++++++ 2 files changed, 17 insertions(+) diff --git a/src-tauri/src/database/dao/proxy.rs b/src-tauri/src/database/dao/proxy.rs index 0303481cf..1e6344b61 100644 --- a/src-tauri/src/database/dao/proxy.rs +++ b/src-tauri/src/database/dao/proxy.rs @@ -230,6 +230,17 @@ impl Database { Ok(()) } + /// 清空所有Provider健康状态(代理停止时调用) + pub async fn clear_all_provider_health(&self) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + + conn.execute("DELETE FROM provider_health", []) + .map_err(|e| AppError::Database(e.to_string()))?; + + log::debug!("Cleared all provider health records"); + Ok(()) + } + // ==================== Circuit Breaker Config ==================== /// 获取熔断器配置 diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index bb4fed5be..9c82481f3 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -306,6 +306,12 @@ impl ProxyService { .await .map_err(|e| format!("删除备份失败: {e}"))?; + // 5. 重置健康状态(让健康徽章恢复为正常) + self.db + .clear_all_provider_health() + .await + .map_err(|e| format!("重置健康状态失败: {e}"))?; + log::info!("代理已停止,Live 配置已恢复"); Ok(()) } From 5efc0cdd5e777bfdef7bcd5605df83a5cb3ea5c9 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 15 Dec 2025 22:57:41 +0800 Subject: [PATCH 13/18] fix(ui): prevent card jitter when health badge appears Increase min-height of the name row from 20px to 28px (min-h-7) to accommodate the health badge height, preventing layout shift when the badge dynamically appears. --- src/components/providers/ProviderCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index a4167b711..c4145d607 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -227,7 +227,7 @@ export function ProviderCard({
-
+

{provider.name}

From 6dcf26831786ef35944b10777e615e4854b19525 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 15 Dec 2025 23:12:16 +0800 Subject: [PATCH 14/18] chore: update aigocode partner logo --- assets/partners/logos/aigocode.png | Bin 34165 -> 38394 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/partners/logos/aigocode.png b/assets/partners/logos/aigocode.png index c9a30c7d4a009ba254210eb37e2ef4d890bc379e..6dd5965ac50b650d351396bcec86e7e6c79f09a0 100644 GIT binary patch literal 38394 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 #%15Ybhq#0*X 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 UKN0w!2>ee3{y#=Q;OFrF0#*i%Bme*a literal 34165 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 Date: Mon, 15 Dec 2025 23:12:26 +0800 Subject: [PATCH 15/18] fix(proxy): invalidate health cache when proxy stops The previous fix (e081c75) only cleared the database records but didn't invalidate the React Query cache. This caused stale health badges to appear when restarting the proxy. Now invalidates all providerHealth queries on stop, ensuring the frontend fetches fresh data from the database. --- src/hooks/useProxyStatus.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/hooks/useProxyStatus.ts b/src/hooks/useProxyStatus.ts index 12d7ac6a0..b0ba91830 100644 --- a/src/hooks/useProxyStatus.ts +++ b/src/hooks/useProxyStatus.ts @@ -65,6 +65,8 @@ export function useProxyStatus() { ); queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); queryClient.invalidateQueries({ queryKey: ["proxyTakeoverActive"] }); + // 清除所有供应商健康状态缓存(后端已清空数据库记录) + queryClient.invalidateQueries({ queryKey: ["providerHealth"] }); }, onError: (error: Error) => { const detail = extractErrorMessage(error) || "未知错误"; From 510a0134498051012501073b7707baf32251836b Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 16 Dec 2025 10:11:29 +0800 Subject: [PATCH 16/18] chore(i18n): remove restart prompt from provider switch notification Claude Code now supports hot reload, so users no longer need to restart the terminal after switching providers. Also removes unused `restartClaude` i18n string. --- src/i18n/locales/en.json | 5 +---- src/i18n/locales/ja.json | 5 +---- src/i18n/locales/zh.json | 5 +---- 3 files changed, 3 insertions(+), 12 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d67eab03f..946e23082 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -112,7 +112,7 @@ "providerAdded": "Provider added", "providerSaved": "Provider configuration saved", "providerDeleted": "Provider deleted successfully", - "switchSuccess": "Switch successful! Please restart {{appName}} terminal to take effect", + "switchSuccess": "Switch successful!", "switchFailedTitle": "Switch failed", "switchFailed": "Switch failed: {{error}}", "autoImported": "Default provider created from existing configuration", @@ -541,9 +541,6 @@ "env": "Environment (one per line, KEY=VALUE)", "envPlaceholder": "FOO=bar\nHELLO=world", "reset": "Reset", - "notice": { - "restartClaude": "Written. Restart Claude to take effect." - }, "msg": { "saved": "Saved", "deleted": "Deleted", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 39f9252e8..07f1a32ca 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -112,7 +112,7 @@ "providerAdded": "プロバイダーを追加しました", "providerSaved": "プロバイダー設定を保存しました", "providerDeleted": "プロバイダーを削除しました", - "switchSuccess": "切り替え成功! {{appName}} ターミナルを再起動すると反映されます", + "switchSuccess": "切り替え成功!", "switchFailedTitle": "切り替えに失敗しました", "switchFailed": "切り替えに失敗しました: {{error}}", "autoImported": "既存設定からデフォルトプロバイダーを自動作成しました", @@ -541,9 +541,6 @@ "env": "環境変数(1 行に 1 件、KEY=VALUE)", "envPlaceholder": "FOO=bar\nHELLO=world", "reset": "リセット", - "notice": { - "restartClaude": "書き込みました。Claude を再起動すると反映されます。" - }, "msg": { "saved": "保存しました", "deleted": "削除しました", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index a7485e356..ea505df0d 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -112,7 +112,7 @@ "providerAdded": "供应商已添加", "providerSaved": "供应商配置已保存", "providerDeleted": "供应商删除成功", - "switchSuccess": "切换成功!请重启 {{appName}} 终端以生效", + "switchSuccess": "切换成功!", "switchFailedTitle": "切换失败", "switchFailed": "切换失败:{{error}}", "autoImported": "已从现有配置创建默认供应商", @@ -541,9 +541,6 @@ "env": "环境变量 (一行一个,KEY=VALUE)", "envPlaceholder": "FOO=bar\nHELLO=world", "reset": "重置", - "notice": { - "restartClaude": "已写入配置,重启 Claude 生效" - }, "msg": { "saved": "已保存", "deleted": "已删除", From d4f33224c6e82db7c1c7d6a4dceee7c4b685940d Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 16 Dec 2025 10:56:39 +0800 Subject: [PATCH 17/18] fix(ui): add close button to all success toasts --- src/App.tsx | 3 ++- src/components/DeepLinkImportDialog.tsx | 21 ++++++++++------ src/components/JsonEditor.tsx | 4 ++- src/components/UsageScriptModal.tsx | 6 ++++- src/components/env/EnvWarningBanner.tsx | 1 + src/components/mcp/McpFormModal.tsx | 2 +- src/components/mcp/UnifiedMcpPanel.tsx | 2 +- src/components/providers/ProviderActions.tsx | 4 +-- src/components/providers/ProviderCard.tsx | 21 ++++++++++------ .../proxy/AutoFailoverConfigPanel.tsx | 1 + .../proxy/CircuitBreakerConfigPanel.tsx | 2 +- src/components/proxy/FailoverQueueManager.tsx | 25 ++++++++++--------- src/components/proxy/ProxyPanel.tsx | 2 +- src/components/settings/AboutSection.tsx | 2 +- src/components/settings/SettingsPage.tsx | 2 +- src/components/skills/SkillsPage.tsx | 13 +++++++--- src/components/usage/ModelTestConfigPanel.tsx | 4 ++- src/components/usage/PricingEditModal.tsx | 1 + src/hooks/useDragSort.ts | 1 + src/hooks/useImportExport.ts | 2 ++ src/hooks/usePromptActions.ts | 12 ++++----- src/hooks/useProviderActions.ts | 1 + src/hooks/useProxyConfig.ts | 2 +- src/hooks/useProxyStatus.ts | 2 ++ src/hooks/useSettings.ts | 1 + src/hooks/useStreamCheck.ts | 1 + src/lib/api/deeplink.ts | 4 +-- src/lib/api/failover.ts | 11 ++++---- src/lib/query/mutations.ts | 1 + 29 files changed, 98 insertions(+), 56 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 7d4a84d55..41efe2640 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -142,6 +142,7 @@ function App() { if (migrated) { toast.success( t("migration.success", { defaultValue: "配置迁移成功" }), + { closeButton: true }, ); } } catch (error) { @@ -422,7 +423,7 @@ function App() { "text-xl font-semibold transition-colors", isProxyRunning && isTakeoverActive ? "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300" - : "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300" + : "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300", )} > CC Switch diff --git a/src/components/DeepLinkImportDialog.tsx b/src/components/DeepLinkImportDialog.tsx index 5f6f9cc66..86e6b0e6f 100644 --- a/src/components/DeepLinkImportDialog.tsx +++ b/src/components/DeepLinkImportDialog.tsx @@ -32,7 +32,7 @@ export function DeepLinkImportDialog() { // 容错判断:MCP 导入结果可能缺少 type 字段 const isMcpImportResult = ( - value: unknown + value: unknown, ): value is { importedCount: number; importedIds: string[]; @@ -59,7 +59,7 @@ export function DeepLinkImportDialog() { if (event.payload.config || event.payload.configUrl) { try { const mergedRequest = await deeplinkApi.mergeDeeplinkConfig( - event.payload + event.payload, ); console.log("Config merged successfully:", mergedRequest); setRequest(mergedRequest); @@ -77,7 +77,7 @@ export function DeepLinkImportDialog() { } setIsOpen(true); - } + }, ); // Listen for deep link error events @@ -128,6 +128,7 @@ export function DeepLinkImportDialog() { description: t("deeplink.mcpImportSuccessDescription", { count: summary.importedCount, }), + closeButton: true, }); } }; @@ -142,18 +143,20 @@ export function DeepLinkImportDialog() { description: t("deeplink.importSuccessDescription", { name: request.name, }), + closeButton: true, }); } else if (result.type === "prompt") { // Prompts don't use React Query, trigger a custom event for refresh window.dispatchEvent( new CustomEvent("prompt-imported", { detail: { app: request.app }, - }) + }), ); toast.success(t("deeplink.promptImportSuccess"), { description: t("deeplink.promptImportSuccessDescription", { name: request.name, }), + closeButton: true, }); } else if (result.type === "mcp") { await refreshMcp(result); @@ -171,6 +174,7 @@ export function DeepLinkImportDialog() { description: t("deeplink.skillImportSuccessDescription", { repo: request.repo, }), + closeButton: true, }); } } else if (isMcpImportResult(result)) { @@ -185,6 +189,7 @@ export function DeepLinkImportDialog() { description: t("deeplink.importSuccessDescription", { name: request.name, }), + closeButton: true, }); } @@ -279,7 +284,7 @@ export function DeepLinkImportDialog() { const maskValue = (key: string, value: string): string => { const sensitiveKeys = ["TOKEN", "KEY", "SECRET", "PASSWORD"]; const isSensitive = sensitiveKeys.some((k) => - key.toUpperCase().includes(k) + key.toUpperCase().includes(k), ); if (isSensitive && value.length > 8) { return `${value.substring(0, 8)}${"*".repeat(12)}`; @@ -521,7 +526,7 @@ export function DeepLinkImportDialog() { {maskValue(key, String(value))}
- ) + ), )}
)} @@ -548,7 +553,7 @@ export function DeepLinkImportDialog() { {maskValue(key, String(value))}
- ) + ), )}
)} @@ -584,7 +589,7 @@ export function DeepLinkImportDialog() { {maskValue(key, String(value))}
- ) + ), )}
)} diff --git a/src/components/JsonEditor.tsx b/src/components/JsonEditor.tsx index 72972e578..860660252 100644 --- a/src/components/JsonEditor.tsx +++ b/src/components/JsonEditor.tsx @@ -234,7 +234,9 @@ const JsonEditor: React.FC = ({ try { const formatted = formatJSON(currentValue); onChange(formatted); - toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" })); + toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }), { + closeButton: true, + }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index b7ca6a107..c23dbb552 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -227,6 +227,7 @@ const UsageScriptModal: React.FC = ({ .join(", "); toast.success(`${t("usageScript.testSuccess")}${summary}`, { duration: 3000, + closeButton: true, }); } else { toast.error( @@ -259,7 +260,10 @@ const UsageScriptModal: React.FC = ({ printWidth: 80, }); setScript({ ...script, code: formatted.trim() }); - toast.success(t("usageScript.formatSuccess"), { duration: 1000 }); + toast.success(t("usageScript.formatSuccess"), { + duration: 1000, + closeButton: true, + }); } catch (error: any) { toast.error( `${t("usageScript.formatFailed")}: ${error?.message || t("jsonEditor.invalidJson")}`, diff --git a/src/components/env/EnvWarningBanner.tsx b/src/components/env/EnvWarningBanner.tsx index 7a2ec5d06..55c5621fa 100644 --- a/src/components/env/EnvWarningBanner.tsx +++ b/src/components/env/EnvWarningBanner.tsx @@ -79,6 +79,7 @@ export function EnvWarningBanner({ path: backupInfo.backupPath, }), duration: 5000, + closeButton: true, }); // 清空选择并通知父组件 diff --git a/src/components/mcp/McpFormModal.tsx b/src/components/mcp/McpFormModal.tsx index d75e5b301..5c608a4ff 100644 --- a/src/components/mcp/McpFormModal.tsx +++ b/src/components/mcp/McpFormModal.tsx @@ -391,7 +391,7 @@ const McpFormModal: React.FC = ({ } await upsertMutation.mutateAsync(entry); - toast.success(t("common.success")); + toast.success(t("common.success"), { closeButton: true }); await onSave(); } catch (error: any) { const detail = extractErrorMessage(error); diff --git a/src/components/mcp/UnifiedMcpPanel.tsx b/src/components/mcp/UnifiedMcpPanel.tsx index 0e5448cea..08f6c340b 100644 --- a/src/components/mcp/UnifiedMcpPanel.tsx +++ b/src/components/mcp/UnifiedMcpPanel.tsx @@ -99,7 +99,7 @@ const UnifiedMcpPanel = React.forwardRef< try { await deleteServerMutation.mutateAsync(id); setConfirmDialog(null); - toast.success(t("common.success")); + toast.success(t("common.success"), { closeButton: true }); } catch (error) { toast.error(t("common.error"), { description: String(error), diff --git a/src/components/providers/ProviderActions.tsx b/src/components/providers/ProviderActions.tsx index c649053cb..706582ccb 100644 --- a/src/components/providers/ProviderActions.tsx +++ b/src/components/providers/ProviderActions.tsx @@ -59,7 +59,7 @@ export function ProviderActions({ // 代理接管模式下启用按钮使用绿色 !isCurrent && isProxyTakeover && - "bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700" + "bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700", )} > {isCurrent ? ( @@ -158,7 +158,7 @@ export function ProviderActions({ className={cn( iconButtonClass, !isCurrent && "hover:text-red-500 dark:hover:text-red-400", - isCurrent && "opacity-40 cursor-not-allowed text-muted-foreground" + isCurrent && "opacity-40 cursor-not-allowed text-muted-foreground", )} > diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index c4145d607..81317139e 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -111,6 +111,7 @@ export function ProviderCard({ t("provider.circuitBreakerReset", { defaultValue: "熔断器已重置", }), + { closeButton: true }, ); } catch (error) { toast.error( @@ -183,7 +184,9 @@ export function ProviderCard({ "relative overflow-hidden rounded-xl border border-border p-4 transition-all duration-300", "bg-card text-card-foreground group", // 代理接管模式下 hover 使用绿色边框,否则使用蓝色 - isProxyTakeover ? "hover:border-emerald-500/50" : "hover:border-border-active", + isProxyTakeover + ? "hover:border-emerald-500/50" + : "hover:border-border-active", // 代理接管模式下当前供应商使用绿色边框 isProxyTakeover && isCurrent ? "border-emerald-500/60 shadow-sm shadow-emerald-500/10" @@ -194,12 +197,16 @@ export function ProviderCard({ "cursor-grabbing border-primary shadow-lg scale-105 z-10", )} > -
+
- {/* 重置熔断器按钮 - 代理目标启用时显示 */} - {/* TODO: 暂时隐藏,后续根据故障转移功能启用 */} - {/* {onResetCircuitBreaker && isProxyTarget && ( - - )} */} -
diff --git a/src/components/proxy/AutoFailoverConfigPanel.tsx b/src/components/proxy/AutoFailoverConfigPanel.tsx index c7d11a624..055fcf25f 100644 --- a/src/components/proxy/AutoFailoverConfigPanel.tsx +++ b/src/components/proxy/AutoFailoverConfigPanel.tsx @@ -101,7 +101,7 @@ export function AutoFailoverConfigPanel({ {t( "proxy.autoFailover.info", - "当启用多个代理目标时,系统会按优先级顺序依次尝试。当某个供应商连续失败达到阈值时,熔断器会自动打开,跳过该供应商。", + "当故障转移队列中配置了多个供应商时,系统会在请求失败时按优先级顺序依次尝试。当某个供应商连续失败达到阈值时,熔断器会打开并在一段时间内跳过该供应商。", )} diff --git a/src/components/proxy/ProxyPanel.tsx b/src/components/proxy/ProxyPanel.tsx index f49803adc..1d2c81d64 100644 --- a/src/components/proxy/ProxyPanel.tsx +++ b/src/components/proxy/ProxyPanel.tsx @@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button"; import { useProxyStatus } from "@/hooks/useProxyStatus"; import { ProxySettingsDialog } from "./ProxySettingsDialog"; import { toast } from "sonner"; -import { useProxyTargets } from "@/lib/query/failover"; +import { useFailoverQueue } from "@/lib/query/failover"; import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge"; import { useProviderHealth } from "@/lib/query/failover"; import type { ProxyStatus } from "@/types/proxy"; @@ -13,10 +13,11 @@ export function ProxyPanel() { const { status, isRunning } = useProxyStatus(); const [showSettings, setShowSettings] = useState(false); - // 获取所有三个应用类型的代理目标列表 - const { data: claudeTargets = [] } = useProxyTargets("claude"); - const { data: codexTargets = [] } = useProxyTargets("codex"); - const { data: geminiTargets = [] } = useProxyTargets("gemini"); + // 获取所有三个应用类型的故障转移队列(不包含当前供应商) + // 当前供应商始终优先,队列仅用于失败后的备用顺序 + const { data: claudeQueue = [] } = useFailoverQueue("claude"); + const { data: codexQueue = [] } = useFailoverQueue("codex"); + const { data: geminiQueue = [] } = useFailoverQueue("gemini"); const formatUptime = (seconds: number): string => { const hours = Math.floor(seconds / 3600); @@ -95,9 +96,9 @@ export function ProxyPanel() {
{/* 供应商队列 - 按应用类型分组展示 */} - {(claudeTargets.length > 0 || - codexTargets.length > 0 || - geminiTargets.length > 0) && ( + {(claudeQueue.length > 0 || + codexQueue.length > 0 || + geminiQueue.length > 0) && (
@@ -107,31 +108,49 @@ export function ProxyPanel() {
{/* Claude 队列 */} - {claudeTargets.length > 0 && ( + {claudeQueue.length > 0 && ( item.enabled) + .sort((a, b) => a.queueOrder - b.queueOrder) + .map((item) => ({ + id: item.providerId, + name: item.providerName, + }))} status={status} /> )} {/* Codex 队列 */} - {codexTargets.length > 0 && ( + {codexQueue.length > 0 && ( item.enabled) + .sort((a, b) => a.queueOrder - b.queueOrder) + .map((item) => ({ + id: item.providerId, + name: item.providerName, + }))} status={status} /> )} {/* Gemini 队列 */} - {geminiTargets.length > 0 && ( + {geminiQueue.length > 0 && ( item.enabled) + .sort((a, b) => a.queueOrder - b.queueOrder) + .map((item) => ({ + id: item.providerId, + name: item.providerName, + }))} status={status} /> )} diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 42c2d5cdd..1760382bf 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -7,7 +7,9 @@ import { Coins, Database, Server, + ChevronDown, } from "lucide-react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; import { toast } from "sonner"; import { Dialog, @@ -279,10 +281,10 @@ export function SettingsPage({ - -
+ +
@@ -294,27 +296,26 @@ export function SettingsPage({

-
e.stopPropagation()} + + + +
+ - - - {isRunning ? "运行中" : "已停止"} - - -
+ {isRunning ? "运行中" : "已停止"} + +
- +
@@ -344,10 +345,10 @@ export function SettingsPage({ - -
+ +
@@ -359,20 +360,16 @@ export function SettingsPage({

-
e.stopPropagation()} - > -
- {/* Removed status text as requested */} - -
-
+ +
+ +
+
- +
{/* 故障转移队列管理 */} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 946e23082..2226b977b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -864,7 +864,7 @@ "toggleFailed": "Failed to update status" }, "autoFailover": { - "info": "When multiple proxy targets are enabled, the system will try them in priority order. When a provider fails consecutively reaching the threshold, the circuit breaker will open and skip that provider.", + "info": "When the failover queue has multiple providers, the system will try them in priority order when requests fail. When a provider reaches the consecutive failure threshold, the circuit breaker will open and skip it temporarily.", "configSaved": "Auto failover config saved", "configSaveFailed": "Failed to save", "retrySettings": "Retry & Timeout Settings", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index ea505df0d..c63bbc74c 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -864,7 +864,7 @@ "toggleFailed": "状态更新失败" }, "autoFailover": { - "info": "当启用多个代理目标时,系统会按优先级顺序依次尝试。当某个供应商连续失败达到阈值时,熔断器会自动打开,跳过该供应商。", + "info": "当故障转移队列中配置了多个供应商时,系统会在请求失败时按优先级顺序依次尝试。当某个供应商连续失败达到阈值时,熔断器会打开并在一段时间内跳过该供应商。", "configSaved": "自动故障转移配置已保存", "configSaveFailed": "保存失败", "retrySettings": "重试与超时设置", diff --git a/src/lib/api/failover.ts b/src/lib/api/failover.ts index f6adc1ee1..58fc26e21 100644 --- a/src/lib/api/failover.ts +++ b/src/lib/api/failover.ts @@ -18,26 +18,9 @@ export interface Provider { meta?: unknown; icon?: string; iconColor?: string; - isProxyTarget?: boolean; } export const failoverApi = { - // ========== 旧版代理目标 API(保留向后兼容)========== - - // 获取代理目标列表 - async getProxyTargets(appType: string): Promise { - return invoke("get_proxy_targets", { appType }); - }, - - // 设置代理目标 - async setProxyTarget( - providerId: string, - appType: string, - enabled: boolean, - ): Promise { - return invoke("set_proxy_target", { providerId, appType, enabled }); - }, - // ========== 熔断器 API ========== // 获取供应商健康状态 diff --git a/src/lib/api/providers.ts b/src/lib/api/providers.ts index 4da270598..14c8e115a 100644 --- a/src/lib/api/providers.ts +++ b/src/lib/api/providers.ts @@ -38,10 +38,6 @@ export const providersApi = { return await invoke("switch_provider", { id, app: appId }); }, - async setProxyTarget(id: string, appId: AppId): Promise { - return await invoke("set_proxy_target_provider", { id, app: appId }); - }, - async importDefault(appId: AppId): Promise { return await invoke("import_default_config", { app: appId }); }, diff --git a/src/lib/query/failover.ts b/src/lib/query/failover.ts index acfaef56a..9f0c7d9fb 100644 --- a/src/lib/query/failover.ts +++ b/src/lib/query/failover.ts @@ -1,50 +1,6 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { failoverApi } from "@/lib/api/failover"; -// ========== 旧版代理目标 Hooks(保留向后兼容)========== - -/** - * 获取代理目标列表 - */ -export function useProxyTargets(appType: string) { - return useQuery({ - queryKey: ["proxyTargets", appType], - queryFn: () => failoverApi.getProxyTargets(appType), - enabled: !!appType, - }); -} - -/** - * 设置代理目标 - */ -export function useSetProxyTarget() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: ({ - providerId, - appType, - enabled, - }: { - providerId: string; - appType: string; - enabled: boolean; - }) => failoverApi.setProxyTarget(providerId, appType, enabled), - onSuccess: (_, variables) => { - // 刷新代理目标列表 - queryClient.invalidateQueries({ - queryKey: ["proxyTargets", variables.appType], - }); - // 刷新供应商列表 - queryClient.invalidateQueries({ queryKey: ["providers"] }); - // 刷新健康状态 - queryClient.invalidateQueries({ - queryKey: ["providerHealth", variables.providerId, variables.appType], - }); - }, - }); -} - // ========== 熔断器 Hooks ========== /** diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index db2b7de08..b5118680c 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -180,35 +180,6 @@ export const useSwitchProviderMutation = (appId: AppId) => { }); }; -export const useSetProxyTargetMutation = (appId: AppId) => { - const queryClient = useQueryClient(); - const { t } = useTranslation(); - - return useMutation({ - mutationFn: async (providerId: string) => { - return await providersApi.setProxyTarget(providerId, appId); - }, - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["providers", appId] }); - toast.success( - t("notifications.proxyTargetSet", { - defaultValue: "已设置代理目标", - }), - { closeButton: true }, - ); - }, - onError: (error: Error) => { - const detail = extractErrorMessage(error) || t("common.unknown"); - toast.error( - t("notifications.setProxyTargetFailed", { - defaultValue: "设置代理目标失败: {{error}}", - error: detail, - }), - ); - }, - }); -}; - export const useSaveSettingsMutation = () => { const queryClient = useQueryClient(); diff --git a/src/types.ts b/src/types.ts index 570d7a2d6..bdf46fcf9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,8 +23,6 @@ export interface Provider { // 图标配置 icon?: string; // 图标名称(如 "openai", "anthropic") iconColor?: string; // 图标颜色(Hex 格式,如 "#00A67E") - // 新增:是否为代理目标 - isProxyTarget?: boolean; } export interface AppConfig { diff --git a/tests/components/SettingsDialog.test.tsx b/tests/components/SettingsDialog.test.tsx index d27827e7c..22b33144f 100644 --- a/tests/components/SettingsDialog.test.tsx +++ b/tests/components/SettingsDialog.test.tsx @@ -19,6 +19,23 @@ vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: tMock }), })); +vi.mock("@/hooks/useProxyStatus", () => ({ + useProxyStatus: () => ({ + status: null, + isLoading: false, + isRunning: false, + isTakeoverActive: false, + startWithTakeover: vi.fn(), + stopWithRestore: vi.fn(), + switchProxyProvider: vi.fn(), + checkRunning: vi.fn(), + checkTakeoverActive: vi.fn(), + isStarting: false, + isStopping: false, + isPending: false, + }), +})); + interface SettingsMock { settings: any; isLoading: boolean; @@ -288,6 +305,7 @@ describe("SettingsPage Component", () => { }); fireEvent.click(screen.getByText("settings.tabAdvanced")); + fireEvent.click(screen.getByText("数据管理")); // 有文件时,点击导入按钮执行 importConfig fireEvent.click( @@ -363,6 +381,7 @@ describe("SettingsPage Component", () => { await waitFor(() => { expect(toastSuccessMock).toHaveBeenCalledWith( "settings.devModeRestartHint", + expect.objectContaining({ closeButton: true }), ); }); }); @@ -393,6 +412,7 @@ describe("SettingsPage Component", () => { render(); fireEvent.click(screen.getByText("settings.tabAdvanced")); + fireEvent.click(screen.getByText("配置文件目录")); fireEvent.click(screen.getByText("browse-directory")); expect(settingsMock.browseDirectory).toHaveBeenCalledWith("claude"); diff --git a/tests/hooks/useImportExport.extra.test.tsx b/tests/hooks/useImportExport.extra.test.tsx index aa5dd3c69..049794132 100644 --- a/tests/hooks/useImportExport.extra.test.tsx +++ b/tests/hooks/useImportExport.extra.test.tsx @@ -120,6 +120,7 @@ describe("useImportExport Hook (edge cases)", () => { expect(exportConfigMock).toHaveBeenCalledWith("/exports/config.json"); expect(toastSuccessMock).toHaveBeenCalledWith( expect.stringContaining("/final/config.json"), + expect.objectContaining({ closeButton: true }), ); }); }); diff --git a/tests/hooks/useImportExport.test.tsx b/tests/hooks/useImportExport.test.tsx index 4b2cfa0aa..030c228aa 100644 --- a/tests/hooks/useImportExport.test.tsx +++ b/tests/hooks/useImportExport.test.tsx @@ -180,6 +180,7 @@ describe("useImportExport Hook", () => { expect(exportConfigMock).toHaveBeenCalledWith("/export.json"); expect(toastSuccessMock).toHaveBeenCalledWith( expect.stringContaining("/backup/export.json"), + expect.objectContaining({ closeButton: true }), ); }); diff --git a/tests/integration/SettingsDialog.test.tsx b/tests/integration/SettingsDialog.test.tsx index 9b015b361..0ebd3eb09 100644 --- a/tests/integration/SettingsDialog.test.tsx +++ b/tests/integration/SettingsDialog.test.tsx @@ -150,6 +150,7 @@ describe("SettingsPage integration", () => { expect(screen.getByText("language:zh")).toBeInTheDocument(), ); fireEvent.click(screen.getByText("settings.tabAdvanced")); + fireEvent.click(screen.getByText("配置文件目录")); const appInput = await screen.findByPlaceholderText( "settings.browsePlaceholderApp", ); @@ -165,6 +166,7 @@ describe("SettingsPage integration", () => { ); fireEvent.click(screen.getByText("settings.tabAdvanced")); + fireEvent.click(screen.getByText("数据管理")); fireEvent.click(screen.getByText("settings.selectConfigFile")); await waitFor(() => expect(screen.getByTestId("selected-file").textContent).toContain( @@ -188,6 +190,7 @@ describe("SettingsPage integration", () => { ); fireEvent.click(screen.getByText("settings.tabAdvanced")); + fireEvent.click(screen.getByText("配置文件目录")); const appInput = await screen.findByPlaceholderText( "settings.browsePlaceholderApp", ); @@ -214,6 +217,7 @@ describe("SettingsPage integration", () => { ); fireEvent.click(screen.getByText("settings.tabAdvanced")); + fireEvent.click(screen.getByText("配置文件目录")); const browseButtons = screen.getAllByTitle("settings.browseDirectory"); const resetButtons = screen.getAllByTitle("settings.resetDefault"); @@ -253,6 +257,7 @@ describe("SettingsPage integration", () => { expect(screen.getByText("language:zh")).toBeInTheDocument(), ); fireEvent.click(screen.getByText("settings.tabAdvanced")); + fireEvent.click(screen.getByText("数据管理")); server.use( http.post("http://tauri.local/save_file_dialog", () => diff --git a/tests/msw/handlers.ts b/tests/msw/handlers.ts index 4672fe9e3..95937534b 100644 --- a/tests/msw/handlers.ts +++ b/tests/msw/handlers.ts @@ -236,4 +236,64 @@ export const handlers = [ http.post(`${TAURI_ENDPOINT}/sync_current_providers_live`, () => success({ success: true }), ), + + // Proxy status (for SettingsPage / ProxyPanel hooks) + http.post(`${TAURI_ENDPOINT}/get_proxy_status`, () => + success({ + running: false, + address: "127.0.0.1", + port: 0, + active_connections: 0, + total_requests: 0, + success_requests: 0, + failed_requests: 0, + success_rate: 0, + uptime_seconds: 0, + current_provider: null, + current_provider_id: null, + last_request_at: null, + last_error: null, + failover_count: 0, + active_targets: [], + }), + ), + + http.post(`${TAURI_ENDPOINT}/is_live_takeover_active`, () => success(false)), + + // Failover / circuit breaker defaults + http.post(`${TAURI_ENDPOINT}/get_failover_queue`, () => success([])), + http.post(`${TAURI_ENDPOINT}/get_available_providers_for_failover`, () => + success([]), + ), + http.post(`${TAURI_ENDPOINT}/add_to_failover_queue`, () => success(true)), + http.post(`${TAURI_ENDPOINT}/remove_from_failover_queue`, () => success(true)), + http.post(`${TAURI_ENDPOINT}/reorder_failover_queue`, () => success(true)), + http.post(`${TAURI_ENDPOINT}/set_failover_item_enabled`, () => success(true)), + + http.post(`${TAURI_ENDPOINT}/get_circuit_breaker_config`, () => + success({ + failureThreshold: 3, + successThreshold: 2, + timeoutSeconds: 60, + errorRateThreshold: 50, + minRequests: 5, + }), + ), + http.post(`${TAURI_ENDPOINT}/update_circuit_breaker_config`, () => + success(true), + ), + http.post(`${TAURI_ENDPOINT}/get_provider_health`, () => + success({ + provider_id: "mock-provider", + app_type: "claude", + is_healthy: true, + consecutive_failures: 0, + last_success_at: null, + last_failure_at: null, + last_error: null, + updated_at: new Date().toISOString(), + }), + ), + http.post(`${TAURI_ENDPOINT}/reset_circuit_breaker`, () => success(true)), + http.post(`${TAURI_ENDPOINT}/get_circuit_breaker_stats`, () => success(null)), ];