From b26e222e436a2b89ad2ea7c3d01cdb4cc2305e73 Mon Sep 17 00:00:00 2001 From: coso Date: Wed, 24 Dec 2025 21:09:47 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E7=AB=AF=E7=82=B9=20provider=5Ftype=20?= =?UTF-8?q?=E7=A1=AC=E7=BC=96=E7=A0=81=E4=B8=BA=20kiro=20=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - route_cmd.rs: get_available_routes 和 get_route_curl_examples 现在从配置读取 default_provider - provider_router.rs: 新增 with_default_provider 构造函数,支持动态默认 Provider - server/mod.rs: 路由列表 API 从 state.default_provider 读取默认 Provider Closes #41 --- package-lock.json | 22 +- src-tauri/src/commands/route_cmd.rs | 14 +- src-tauri/src/providers/kiro.rs | 12 + src-tauri/src/router/provider_router.rs | 24 +- .../src/server/handlers/kiro_credential.rs | 652 ++++++++++++++++++ src-tauri/src/server/handlers/mod.rs | 2 + src-tauri/src/server/handlers/websocket.rs | 26 + src-tauri/src/server/mod.rs | 35 +- src-tauri/src/services/kiro_event_service.rs | 410 +++++++++++ src-tauri/src/services/mod.rs | 1 + .../src/services/provider_pool_service.rs | 116 +++- src-tauri/src/services/token_cache_service.rs | 427 ++++++++++-- src-tauri/src/websocket/handler.rs | 14 + src-tauri/src/websocket/mod.rs | 5 +- src-tauri/src/websocket/types.rs | 81 +++ .../provider-pool/CredentialCard.tsx | 275 +++++++- src/lib/api/providerPool.ts | 128 ++++ 17 files changed, 2135 insertions(+), 109 deletions(-) create mode 100644 src-tauri/src/server/handlers/kiro_credential.rs create mode 100644 src-tauri/src/services/kiro_event_service.rs diff --git a/package-lock.json b/package-lock.json index 0c6187051..654f31b89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "proxycast", - "version": "0.15.0", + "version": "0.17.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "proxycast", - "version": "0.15.0", + "version": "0.17.4", "dependencies": { "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", @@ -97,7 +97,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2781,7 +2780,6 @@ "integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2799,7 +2797,6 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2811,7 +2808,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2851,7 +2847,6 @@ "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", @@ -3174,7 +3169,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3393,7 +3387,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3741,7 +3734,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4376,7 +4368,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -4855,7 +4846,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5078,7 +5068,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5091,7 +5080,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5584,7 +5572,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5660,7 +5647,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5773,7 +5759,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -5834,7 +5819,6 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", @@ -6391,7 +6375,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6405,7 +6388,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/src-tauri/src/commands/route_cmd.rs b/src-tauri/src/commands/route_cmd.rs index 4c94d868b..9df74a994 100644 --- a/src-tauri/src/commands/route_cmd.rs +++ b/src-tauri/src/commands/route_cmd.rs @@ -11,19 +11,20 @@ pub async fn get_available_routes( db: tauri::State<'_, DbConnection>, pool_service: tauri::State<'_, ProviderPoolServiceState>, ) -> Result { - // 获取配置中的服务器地址 + // 获取配置中的服务器地址和默认 Provider let config = config::load_config().unwrap_or_default(); let base_url = format!("http://{}:{}", config.server.host, config.server.port); + let default_provider = config.default_provider.clone(); let routes = pool_service .0 .get_available_routes(db.inner(), &base_url) .map_err(|e| e.to_string())?; - // 添加默认路由 + // 添加默认路由,使用配置中的默认 Provider let mut all_routes = vec![RouteInfo { selector: "default".to_string(), - provider_type: "kiro".to_string(), + provider_type: default_provider.clone(), credential_count: 1, endpoints: vec![ crate::models::route_model::RouteEndpoint { @@ -44,7 +45,7 @@ pub async fn get_available_routes( Ok(RouteListResponse { base_url, - default_provider: "kiro".to_string(), + default_provider, routes: all_routes, }) } @@ -58,6 +59,7 @@ pub async fn get_route_curl_examples( ) -> Result, String> { let config = config::load_config().unwrap_or_default(); let base_url = format!("http://{}:{}", config.server.host, config.server.port); + let default_provider = config.default_provider.clone(); let routes = pool_service .0 @@ -73,8 +75,8 @@ pub async fn get_route_curl_examples( match route { Some(r) => Ok(r.generate_curl_examples(api_key)), None => { - // 生成默认路由的示例 - let mut default_route = RouteInfo::new("default".to_string(), "kiro".to_string()); + // 生成默认路由的示例,使用配置中的默认 Provider + let mut default_route = RouteInfo::new("default".to_string(), default_provider); default_route.add_endpoint(&base_url, "claude"); default_route.add_endpoint(&base_url, "openai"); Ok(default_route.generate_curl_examples(api_key)) diff --git a/src-tauri/src/providers/kiro.rs b/src-tauri/src/providers/kiro.rs index 1fbfb0623..4e460a335 100644 --- a/src-tauri/src/providers/kiro.rs +++ b/src-tauri/src/providers/kiro.rs @@ -13,6 +13,7 @@ use std::path::PathBuf; /// 关键改进:每个凭证生成独立的 Machine ID,避免多账号共用同一指纹被检测 /// 优先级:profileArn > clientId > 系统硬件 ID /// +/// 防指纹固化:添加基于小时的时间变化因子,在保持稳定性的同时避免指纹完全固化 /// 这样每个 OAuth 凭证都有自己独立的指纹,模拟不同设备登录 pub fn generate_machine_id_from_credentials( profile_arn: Option<&str>, @@ -30,8 +31,19 @@ pub fn generate_machine_id_from_credentials( get_raw_machine_id().unwrap_or_else(|| "KIRO_DEFAULT_MACHINE".to_string()) }); + // 添加时间变化因子 - 按小时变化,避免指纹固化 + // 使用小时级别的时间戳,确保在同一小时内指纹稳定,但定期变化 + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + // 按小时分组,每小时指纹会轻微变化 + let hour_slot = now / 3600; + let mut hasher = Sha256::new(); hasher.update(unique_key.as_bytes()); + hasher.update(&hour_slot.to_le_bytes()); // 添加时间因子 let result = hasher.finalize(); format!("{:x}", result) } diff --git a/src-tauri/src/router/provider_router.rs b/src-tauri/src/router/provider_router.rs index a1454fd3a..125fc7110 100644 --- a/src-tauri/src/router/provider_router.rs +++ b/src-tauri/src/router/provider_router.rs @@ -44,12 +44,28 @@ impl RouteMatch { pub struct ProviderRouter { /// 路由注册表 registry: Arc>, + /// 默认 Provider 引用(从配置动态获取) + default_provider_ref: Arc>, } impl ProviderRouter { /// 创建新的路由器 pub fn new(registry: Arc>) -> Self { - Self { registry } + Self { + registry, + default_provider_ref: Arc::new(RwLock::new("kiro".to_string())), + } + } + + /// 创建带有默认 Provider 引用的路由器 + pub fn with_default_provider( + registry: Arc>, + default_provider_ref: Arc>, + ) -> Self { + Self { + registry, + default_provider_ref, + } } /// 解析请求路径 @@ -67,12 +83,13 @@ impl ProviderRouter { // /v1/messages ["v1", "messages"] => { let registry = self.registry.read().await; + let default_provider = self.default_provider_ref.read().await.clone(); let route = registry .enabled_routes() .into_iter() .find(|r| r.route_type == RouteType::Default) .cloned() - .unwrap_or_else(|| RegisteredRoute::default_route("kiro")); + .unwrap_or_else(|| RegisteredRoute::default_route(&default_provider)); Some(RouteMatch { route, @@ -84,12 +101,13 @@ impl ProviderRouter { // /v1/chat/completions ["v1", "chat", "completions"] => { let registry = self.registry.read().await; + let default_provider = self.default_provider_ref.read().await.clone(); let route = registry .enabled_routes() .into_iter() .find(|r| r.route_type == RouteType::Default) .cloned() - .unwrap_or_else(|| RegisteredRoute::default_route("kiro")); + .unwrap_or_else(|| RegisteredRoute::default_route(&default_provider)); Some(RouteMatch { route, diff --git a/src-tauri/src/server/handlers/kiro_credential.rs b/src-tauri/src/server/handlers/kiro_credential.rs new file mode 100644 index 000000000..d7a411afc --- /dev/null +++ b/src-tauri/src/server/handlers/kiro_credential.rs @@ -0,0 +1,652 @@ +//! Kiro凭证管理API处理器 +//! +//! 为kiro凭证池管理提供REST API端点,支持: +//! - 获取可用凭证列表 +//! - 智能选择凭证 +//! - 手动刷新凭证 +//! - 凭证状态查询 + +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::database::dao::provider_pool::ProviderPoolDao; +use crate::models::provider_pool_model::{CachedTokenInfo, PoolProviderType, ProviderCredential}; +use crate::server::AppState; + +/// 可用凭证信息 +#[derive(Debug, Clone, Serialize)] +pub struct AvailableCredential { + /// 凭证UUID + pub uuid: String, + /// 凭证名称 + pub name: String, + /// 是否可用 + pub available: bool, + /// Token过期时间 + pub expires_at: Option>, + /// 最后使用时间 + pub last_used: Option>, + /// 健康状态分数 (0-100) + pub health_score: f64, + /// 错误计数 + pub error_count: u32, + /// 最后错误信息 + pub last_error: Option, +} + +/// 获取可用凭证列表的响应 +#[derive(Debug, Serialize)] +pub struct AvailableCredentialsResponse { + /// 可用凭证列表 + pub credentials: Vec, + /// 总凭证数 + pub total: usize, + /// 可用凭证数 + pub available: usize, + /// 系统状态 + pub status: String, +} + +/// 选择凭证请求参数 +#[derive(Debug, Deserialize)] +pub struct SelectCredentialRequest { + /// 指定模型(可选) + pub model: Option, + /// 强制选择特定UUID(可选) + pub force_uuid: Option, +} + +/// 选择凭证响应 +#[derive(Debug, Serialize)] +pub struct SelectCredentialResponse { + /// 选中的凭证UUID + pub uuid: String, + /// 凭证名称 + pub name: String, + /// Access Token(脱敏显示) + pub access_token_preview: String, + /// Token过期时间 + pub expires_at: Option>, + /// 选择原因 + pub selection_reason: String, +} + +/// 刷新凭证响应 +#[derive(Debug, Serialize)] +pub struct RefreshCredentialResponse { + /// 凭证UUID + pub uuid: String, + /// 刷新是否成功 + pub success: bool, + /// 新的过期时间 + pub new_expires_at: Option>, + /// 刷新结果信息 + pub message: String, + /// 错误信息(如果有) + pub error: Option, +} + +/// API错误响应 +#[derive(Debug, Serialize)] +pub struct ApiError { + pub error: String, + pub message: String, + pub status_code: u16, +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let status = + StatusCode::from_u16(self.status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + (status, Json(self)).into_response() + } +} + +/// GET /api/kiro/credentials/available - 获取可用凭证列表 +pub async fn get_available_credentials( + State(state): State, + _headers: HeaderMap, +) -> Result, ApiError> { + tracing::info!("[KIRO_API] 获取可用凭证列表请求"); + + let db = &state.db.as_ref().ok_or_else(|| ApiError { + error: "database_unavailable".to_string(), + message: "数据库连接不可用".to_string(), + status_code: 503, + })?; + + let pool_service = &state.pool_service; + let token_cache = &state.token_cache; + + // 获取所有kiro凭证 + let credentials = { + let conn = db.lock().map_err(|e| ApiError { + error: "database_lock_error".to_string(), + message: format!("数据库锁定失败: {}", e), + status_code: 500, + })?; + + ProviderPoolDao::get_all(&conn) + .map_err(|e| ApiError { + error: "database_query_error".to_string(), + message: format!("查询凭证失败: {}", e), + status_code: 500, + })? + .into_iter() + .filter(|cred| cred.provider_type == PoolProviderType::Kiro) + .collect::>() + }; + + let mut available_credentials = Vec::new(); + let mut available_count = 0; + + for credential in &credentials { + // 获取凭证缓存状态 + let cache_status = token_cache + .get_cache_status(db, &credential.uuid) + .map_err(|e| ApiError { + error: "cache_query_error".to_string(), + message: format!("获取缓存状态失败: {}", e), + status_code: 500, + })?; + + // 计算健康状态分数 + let health_score = calculate_health_score(&credential, cache_status.as_ref()); + + let is_available = health_score > 50.0; // 健康分数大于50认为可用 + if is_available { + available_count += 1; + } + + let available_cred = AvailableCredential { + uuid: credential.uuid.clone(), + name: credential + .name + .clone() + .unwrap_or_else(|| "未命名".to_string()), + available: is_available, + expires_at: cache_status.as_ref().and_then(|c| c.expiry_time), + last_used: credential.last_used, + health_score, + error_count: cache_status + .as_ref() + .map(|c| c.refresh_error_count) + .unwrap_or(0), + last_error: cache_status.and_then(|c| c.last_refresh_error), + }; + + available_credentials.push(available_cred); + } + + // 按健康分数降序排列 + available_credentials.sort_by(|a, b| { + b.health_score + .partial_cmp(&a.health_score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + let response = AvailableCredentialsResponse { + credentials: available_credentials, + total: credentials.len(), + available: available_count, + status: if available_count > 0 { + "healthy".to_string() + } else { + "degraded".to_string() + }, + }; + + tracing::info!( + "[KIRO_API] 返回{}个凭证,其中{}个可用", + response.total, + response.available + ); + Ok(Json(response)) +} + +/// POST /api/kiro/credentials/select - 智能选择凭证 +pub async fn select_credential( + State(state): State, + _headers: HeaderMap, + Json(request): Json, +) -> Result, ApiError> { + tracing::info!( + "[KIRO_API] 选择凭证请求,模型: {:?}, 强制UUID: {:?}", + request.model, + request.force_uuid + ); + + let db = &state.db.as_ref().ok_or_else(|| ApiError { + error: "database_unavailable".to_string(), + message: "数据库连接不可用".to_string(), + status_code: 503, + })?; + + let selected_credential = if let Some(ref force_uuid) = request.force_uuid { + // 强制选择指定UUID + let conn = db.lock().map_err(|e| ApiError { + error: "database_lock_error".to_string(), + message: format!("数据库锁定失败: {}", e), + status_code: 500, + })?; + + ProviderPoolDao::get_by_uuid(&conn, &force_uuid) + .map_err(|e| ApiError { + error: "database_query_error".to_string(), + message: format!("查询凭证失败: {}", e), + status_code: 500, + })? + .ok_or_else(|| ApiError { + error: "credential_not_found".to_string(), + message: format!("未找到UUID为{}的凭证", force_uuid), + status_code: 404, + })? + } else { + // 智能选择最优凭证 + state + .pool_service + .select_credential(db, "kiro", request.model.as_deref()) + .map_err(|e| ApiError { + error: "selection_error".to_string(), + message: format!("凭证选择失败: {}", e), + status_code: 500, + })? + .ok_or_else(|| ApiError { + error: "no_available_credentials".to_string(), + message: "没有可用的kiro凭证".to_string(), + status_code: 503, + })? + }; + + // 脱敏显示token + let token_preview = if let Some(cached_token_info) = &selected_credential.cached_token { + if let Some(token) = &cached_token_info.access_token { + if token.len() > 20 { + format!("{}...{}", &token[..10], &token[token.len() - 10..]) + } else { + "***".to_string() + } + } else { + "无token".to_string() + } + } else { + "未缓存".to_string() + }; + + let selection_reason = if request.force_uuid.is_some() { + "手动指定".to_string() + } else { + "智能算法选择".to_string() + }; + + let expires_at = selected_credential + .cached_token + .as_ref() + .and_then(|cache| cache.expiry_time); + + let response = SelectCredentialResponse { + uuid: selected_credential.uuid.clone(), + name: selected_credential + .name + .clone() + .unwrap_or_else(|| "未命名".to_string()), + access_token_preview: token_preview, + expires_at, + selection_reason, + }; + + tracing::info!( + "[KIRO_API] 选择凭证成功: {} ({})", + response.name, + response.uuid + ); + Ok(Json(response)) +} + +/// PUT /api/kiro/credentials/{uuid}/refresh - 手动刷新指定凭证 +pub async fn refresh_credential( + State(state): State, + Path(uuid): Path, + _headers: HeaderMap, +) -> Result, ApiError> { + tracing::info!("[KIRO_API] 刷新凭证请求: {}", uuid); + + let db = &state.db.as_ref().ok_or_else(|| ApiError { + error: "database_unavailable".to_string(), + message: "数据库连接不可用".to_string(), + status_code: 503, + })?; + + let token_cache = &state.token_cache; + + // 验证凭证存在且为kiro类型 + let credential = { + let conn = db.lock().map_err(|e| ApiError { + error: "database_lock_error".to_string(), + message: format!("数据库锁定失败: {}", e), + status_code: 500, + })?; + + let cred = ProviderPoolDao::get_by_uuid(&conn, &uuid) + .map_err(|e| ApiError { + error: "database_query_error".to_string(), + message: format!("查询凭证失败: {}", e), + status_code: 500, + })? + .ok_or_else(|| ApiError { + error: "credential_not_found".to_string(), + message: format!("未找到UUID为{}的凭证", uuid), + status_code: 404, + })?; + + if cred.provider_type.to_string() != PoolProviderType::Kiro.to_string() { + return Err(ApiError { + error: "invalid_credential_type".to_string(), + message: format!("凭证类型不是kiro: {}", cred.provider_type), + status_code: 400, + }); + } + + cred + }; + + // 执行强制刷新 + match token_cache + .refresh_and_cache_with_events(db, &uuid, true, Some(state.kiro_event_service.clone())) + .await + { + Ok(_new_token) => { + // 获取刷新后的缓存状态 + let cache_status = token_cache + .get_cache_status(db, &uuid) + .map_err(|e| ApiError { + error: "cache_query_error".to_string(), + message: format!("获取刷新后缓存状态失败: {}", e), + status_code: 500, + })?; + + let response = RefreshCredentialResponse { + uuid: uuid.clone(), + success: true, + new_expires_at: cache_status.as_ref().and_then(|c| c.expiry_time), + message: format!( + "凭证 {} 刷新成功", + credential + .name + .clone() + .unwrap_or_else(|| "未命名".to_string()) + ), + error: None, + }; + + tracing::info!( + "[KIRO_API] 凭证刷新成功: {} ({})", + credential + .name + .clone() + .unwrap_or_else(|| "未命名".to_string()), + uuid + ); + Ok(Json(response)) + } + Err(refresh_error) => { + let response = RefreshCredentialResponse { + uuid: uuid.clone(), + success: false, + new_expires_at: None, + message: format!( + "凭证 {} 刷新失败", + credential + .name + .clone() + .unwrap_or_else(|| "未命名".to_string()) + ), + error: Some(refresh_error.clone()), + }; + + tracing::warn!( + "[KIRO_API] 凭证刷新失败: {} ({}): {}", + credential + .name + .clone() + .unwrap_or_else(|| "未命名".to_string()), + uuid, + refresh_error + ); + Ok(Json(response)) + } + } +} + +/// GET /api/kiro/credentials/{uuid}/status - 获取凭证详细状态 +pub async fn get_credential_status( + State(state): State, + Path(uuid): Path, + _headers: HeaderMap, +) -> Result, ApiError> { + tracing::info!("[KIRO_API] 获取凭证状态: {}", uuid); + + let db = &state.db.as_ref().ok_or_else(|| ApiError { + error: "database_unavailable".to_string(), + message: "数据库连接不可用".to_string(), + status_code: 503, + })?; + + let token_cache = &state.token_cache; + + // 验证凭证存在 + let credential = { + let conn = db.lock().map_err(|e| ApiError { + error: "database_lock_error".to_string(), + message: format!("数据库锁定失败: {}", e), + status_code: 500, + })?; + + ProviderPoolDao::get_by_uuid(&conn, &uuid) + .map_err(|e| ApiError { + error: "database_query_error".to_string(), + message: format!("查询凭证失败: {}", e), + status_code: 500, + })? + .ok_or_else(|| ApiError { + error: "credential_not_found".to_string(), + message: format!("未找到UUID为{}的凭证", uuid), + status_code: 404, + })? + }; + + // 获取缓存状态 + let cache_status = token_cache + .get_cache_status(db, &uuid) + .map_err(|e| ApiError { + error: "cache_query_error".to_string(), + message: format!("获取缓存状态失败: {}", e), + status_code: 500, + })?; + + // 计算健康分数 + let health_score = calculate_health_score(&credential, cache_status.as_ref()); + + let mut status = serde_json::Map::new(); + status.insert( + "uuid".to_string(), + serde_json::Value::String(credential.uuid.clone()), + ); + status.insert( + "name".to_string(), + serde_json::Value::String( + credential + .name + .clone() + .unwrap_or_else(|| "未命名".to_string()), + ), + ); + status.insert( + "provider_type".to_string(), + serde_json::Value::String(credential.provider_type.to_string()), + ); + status.insert( + "created_at".to_string(), + serde_json::Value::String(credential.created_at.to_rfc3339()), + ); + status.insert( + "last_used".to_string(), + credential + .last_used + .map(|dt| serde_json::Value::String(dt.to_rfc3339())) + .unwrap_or(serde_json::Value::Null), + ); + status.insert( + "health_score".to_string(), + serde_json::Value::Number( + serde_json::Number::from_f64(health_score).unwrap_or(serde_json::Number::from(0)), + ), + ); + status.insert( + "is_available".to_string(), + serde_json::Value::Bool(health_score > 50.0), + ); + + if let Some(cache) = cache_status { + status.insert( + "has_cached_token".to_string(), + serde_json::Value::Bool(cache.access_token.is_some()), + ); + status.insert( + "expires_at".to_string(), + cache + .expiry_time + .map(|dt| serde_json::Value::String(dt.to_rfc3339())) + .unwrap_or(serde_json::Value::Null), + ); + status.insert( + "last_refresh".to_string(), + cache + .last_refresh + .map(|dt| serde_json::Value::String(dt.to_rfc3339())) + .unwrap_or(serde_json::Value::Null), + ); + status.insert( + "refresh_error_count".to_string(), + serde_json::Value::Number(serde_json::Number::from(cache.refresh_error_count)), + ); + status.insert( + "last_refresh_error".to_string(), + cache + .last_refresh_error + .map(|err| serde_json::Value::String(err)) + .unwrap_or(serde_json::Value::Null), + ); + } else { + status.insert( + "has_cached_token".to_string(), + serde_json::Value::Bool(false), + ); + status.insert("expires_at".to_string(), serde_json::Value::Null); + status.insert("last_refresh".to_string(), serde_json::Value::Null); + status.insert( + "refresh_error_count".to_string(), + serde_json::Value::Number(serde_json::Number::from(0)), + ); + status.insert("last_refresh_error".to_string(), serde_json::Value::Null); + } + + tracing::info!( + "[KIRO_API] 返回凭证状态: {} (健康分数: {:.1})", + credential + .name + .clone() + .unwrap_or_else(|| "未命名".to_string()), + health_score + ); + Ok(Json(serde_json::Value::Object(status))) +} + +/// 计算凭证健康分数 +/// +/// 基于凭证的基本状态、缓存状态、错误计数等因素综合计算健康分数 +/// 分数范围: 0-100,分数越高表示凭证越健康 +fn calculate_health_score( + credential: &ProviderCredential, + cache_status: Option<&CachedTokenInfo>, +) -> f64 { + let mut score = 0.0; + + // 1. 基础健康状态 (40分) + if credential.is_healthy { + score += 40.0; + } else { + score -= 20.0; // 不健康严重扣分 + } + + // 2. 错误计数影响 (20分) + let error_count = credential.error_count; + if error_count == 0 { + score += 20.0; + } else if error_count <= 2 { + score += 10.0; // 少量错误,轻微扣分 + } else { + score -= error_count as f64 * 5.0; // 错误越多扣分越多 + } + + // 3. Token缓存状态 (25分) + if let Some(cache) = cache_status { + if cache.access_token.is_some() { + score += 15.0; // 有缓存token + + // 检查过期时间 + if let Some(expiry_time) = cache.expiry_time { + let now = chrono::Utc::now(); + let time_until_expiry = expiry_time - now; + + if time_until_expiry > chrono::Duration::hours(1) { + score += 10.0; // 距离过期还有较长时间 + } else if time_until_expiry > chrono::Duration::minutes(30) { + score += 5.0; // 距离过期还有一些时间 + } else if time_until_expiry <= chrono::Duration::zero() { + score -= 10.0; // 已过期 + } + } + } else { + score -= 5.0; // 没有缓存token + } + + // 刷新错误计数影响 + if cache.refresh_error_count == 0 { + // 无刷新错误,不加分不减分 + } else if cache.refresh_error_count <= 2 { + score -= cache.refresh_error_count as f64 * 2.0; // 少量刷新错误 + } else { + score -= cache.refresh_error_count as f64 * 5.0; // 大量刷新错误严重扣分 + } + } else { + score -= 10.0; // 完全没有缓存状态 + } + + // 4. 使用活跃度 (15分) + if let Some(last_used) = credential.last_used { + let now = chrono::Utc::now(); + let time_since_used = now - last_used; + + if time_since_used <= chrono::Duration::hours(1) { + score += 15.0; // 最近1小时内使用过 + } else if time_since_used <= chrono::Duration::hours(24) { + score += 10.0; // 最近24小时内使用过 + } else if time_since_used <= chrono::Duration::days(7) { + score += 5.0; // 最近一周内使用过 + } else { + score += 0.0; // 很久未使用,不扣分也不加分 + } + } else { + score -= 5.0; // 从未使用过 + } + + // 确保分数在0-100范围内 + score.max(0.0).min(100.0) +} diff --git a/src-tauri/src/server/handlers/mod.rs b/src-tauri/src/server/handlers/mod.rs index 4279943d5..06343dca2 100644 --- a/src-tauri/src/server/handlers/mod.rs +++ b/src-tauri/src/server/handlers/mod.rs @@ -3,11 +3,13 @@ //! 将 server 中的各类处理器拆分到独立文件 pub mod api; +pub mod kiro_credential; pub mod management; pub mod provider_calls; pub mod websocket; pub use api::*; +pub use kiro_credential::*; pub use management::*; pub use provider_calls::*; pub use websocket::*; diff --git a/src-tauri/src/server/handlers/websocket.rs b/src-tauri/src/server/handlers/websocket.rs index 2387eb349..8723524b1 100644 --- a/src-tauri/src/server/handlers/websocket.rs +++ b/src-tauri/src/server/handlers/websocket.rs @@ -349,6 +349,32 @@ async fn handle_ws_message( "Invalid message type from client", ))), WsProtoMessage::Error(_) => None, + WsProtoMessage::SubscribeKiroEvents => { + // TODO: 实现Kiro事件订阅 + Some(WsProtoMessage::Response(WsApiResponse { + request_id: "subscribe_kiro_events".to_string(), + payload: serde_json::json!({ + "status": "subscribed", + "message": "Successfully subscribed to kiro events" + }), + })) + } + WsProtoMessage::UnsubscribeKiroEvents => { + // TODO: 实现Kiro事件取消订阅 + Some(WsProtoMessage::Response(WsApiResponse { + request_id: "unsubscribe_kiro_events".to_string(), + payload: serde_json::json!({ + "status": "unsubscribed", + "message": "Successfully unsubscribed from kiro events" + }), + })) + } + WsProtoMessage::KiroCredentialEvent(_) => { + // Kiro事件是服务端到客户端的消息,客户端不应该发送 + Some(WsProtoMessage::Error(WsError::invalid_message( + "KiroCredentialEvent messages are server-to-client only", + ))) + } } } diff --git a/src-tauri/src/server/mod.rs b/src-tauri/src/server/mod.rs index 7b7d4e852..18617791c 100644 --- a/src-tauri/src/server/mod.rs +++ b/src-tauri/src/server/mod.rs @@ -28,6 +28,7 @@ use crate::server_utils::{ build_anthropic_response, build_anthropic_stream_response, build_gemini_native_request, health, models, parse_cw_response, }; +use crate::services::kiro_event_service::KiroEventService; use crate::services::provider_pool_service::ProviderPoolService; use crate::services::token_cache_service::TokenCacheService; use crate::websocket::{WsConfig, WsConnectionManager, WsStats}; @@ -386,6 +387,8 @@ pub struct AppState { pub flow_interceptor: Arc, /// 端点 Provider 配置 pub endpoint_providers: Arc>, + /// Kiro 事件服务 + pub kiro_event_service: Arc, } /// 启动配置文件监控 @@ -734,6 +737,9 @@ async fn run_server( .unwrap_or_default(), )); + // 创建 Kiro 事件服务 + let kiro_event_service = Arc::new(KiroEventService::new()); + let state = AppState { api_key: api_key.to_string(), base_url, @@ -757,6 +763,7 @@ async fn run_server( flow_monitor, flow_interceptor, endpoint_providers, + kiro_event_service, }; // 启动配置文件监控 @@ -805,6 +812,25 @@ async fn run_server( management_config, )); + // Kiro凭证管理API路由 + let kiro_api_routes = Router::new() + .route( + "/api/kiro/credentials/available", + get(handlers::get_available_credentials), + ) + .route( + "/api/kiro/credentials/select", + post(handlers::select_credential), + ) + .route( + "/api/kiro/credentials/:uuid/refresh", + axum::routing::put(handlers::refresh_credential), + ) + .route( + "/api/kiro/credentials/:uuid/status", + get(handlers::get_credential_status), + ); + let app = Router::new() .route("/health", get(health)) .route("/v1/models", get(models)) @@ -843,6 +869,8 @@ async fn run_server( ) // 管理 API 路由 .merge(management_routes) + // Kiro凭证管理API路由 + .merge(kiro_api_routes) .layer(DefaultBodyLimit::max(body_limit)) .with_state(state); @@ -1123,10 +1151,13 @@ async fn list_routes(State(state): State) -> impl IntoResponse { None => Vec::new(), }; + // 获取默认 Provider + let default_provider = state.default_provider.read().await.clone(); + // 添加默认路由 let mut all_routes = vec![RouteInfo { selector: "default".to_string(), - provider_type: "kiro".to_string(), + provider_type: default_provider.clone(), credential_count: 1, endpoints: vec![ crate::models::route_model::RouteEndpoint { @@ -1147,7 +1178,7 @@ async fn list_routes(State(state): State) -> impl IntoResponse { let response = RouteListResponse { base_url: state.base_url.clone(), - default_provider: "kiro".to_string(), + default_provider, routes: all_routes, }; diff --git a/src-tauri/src/services/kiro_event_service.rs b/src-tauri/src/services/kiro_event_service.rs new file mode 100644 index 000000000..7b15d41f1 --- /dev/null +++ b/src-tauri/src/services/kiro_event_service.rs @@ -0,0 +1,410 @@ +//! Kiro 凭证事件服务 +//! +//! 负责管理 Kiro 凭证相关的实时事件推送,包括: +//! - 凭证状态更新 +//! - Token 刷新事件 +//! - 健康检查结果 +//! - 凭证池统计 + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use tokio::sync::{broadcast, RwLock}; + +use crate::websocket::{KiroTokenInfo, WsKiroEvent}; + +/// Kiro 事件服务 +#[derive(Debug)] +pub struct KiroEventService { + /// 事件发送器 + event_sender: broadcast::Sender, + /// 凭证状态缓存 + credential_states: RwLock>, +} + +/// 缓存的凭证状态 +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CachedCredentialState { + uuid: String, + is_healthy: bool, + is_disabled: bool, + error_count: u32, + health_score: Option, + last_used: Option>, + last_updated: DateTime, +} + +impl KiroEventService { + /// 创建新的 Kiro 事件服务 + pub fn new() -> Self { + let (event_sender, _) = broadcast::channel(1000); + Self { + event_sender, + credential_states: RwLock::new(HashMap::new()), + } + } + + /// 订阅 Kiro 事件 + pub fn subscribe(&self) -> broadcast::Receiver { + self.event_sender.subscribe() + } + + /// 发送凭证状态更新事件 + pub async fn emit_credential_status_update( + &self, + uuid: String, + is_healthy: bool, + is_disabled: bool, + error_count: u32, + health_score: Option, + last_used: Option>, + ) { + let now = Utc::now(); + + // 更新缓存 + { + let mut states = self.credential_states.write().await; + states.insert( + uuid.clone(), + CachedCredentialState { + uuid: uuid.clone(), + is_healthy, + is_disabled, + error_count, + health_score, + last_used, + last_updated: now, + }, + ); + } + + // 发送事件 + let event = WsKiroEvent::CredentialStatusUpdate { + uuid, + is_healthy, + is_disabled, + error_count, + health_score, + last_used, + }; + + if let Err(e) = self.event_sender.send(event) { + tracing::debug!("Failed to send credential status update event: {}", e); + } + } + + /// 发送凭证刷新开始事件 + pub async fn emit_refresh_started(&self, uuid: String, credential_name: Option) { + let event = WsKiroEvent::RefreshStarted { + uuid, + credential_name, + }; + + if let Err(e) = self.event_sender.send(event) { + tracing::debug!("Failed to send refresh started event: {}", e); + } + } + + /// 发送凭证刷新成功事件 + pub async fn emit_refresh_success( + &self, + uuid: String, + credential_name: Option, + expires_at: DateTime, + auth_method: String, + provider: String, + region: String, + ) { + let new_token_info = KiroTokenInfo { + expires_at, + auth_method, + provider, + region, + }; + + let event = WsKiroEvent::RefreshSuccess { + uuid, + credential_name, + new_token_info, + }; + + if let Err(e) = self.event_sender.send(event) { + tracing::debug!("Failed to send refresh success event: {}", e); + } + } + + /// 发送凭证刷新失败事件 + pub async fn emit_refresh_failed( + &self, + uuid: String, + credential_name: Option, + error: String, + error_code: Option, + ) { + let event = WsKiroEvent::RefreshFailed { + uuid, + credential_name, + error, + error_code, + }; + + if let Err(e) = self.event_sender.send(event) { + tracing::debug!("Failed to send refresh failed event: {}", e); + } + } + + /// 发送健康检查完成事件 + pub async fn emit_health_check_completed( + &self, + uuid: String, + credential_name: Option, + is_healthy: bool, + health_score: Option, + ) { + let last_check = Utc::now(); + let event = WsKiroEvent::HealthCheckCompleted { + uuid, + credential_name, + is_healthy, + health_score, + last_check, + }; + + if let Err(e) = self.event_sender.send(event) { + tracing::debug!("Failed to send health check completed event: {}", e); + } + } + + /// 发送凭证池统计更新事件 + pub async fn emit_pool_stats_update( + &self, + total_credentials: u32, + healthy_credentials: u32, + available_credentials: u32, + average_health_score: Option, + last_rotation: Option>, + ) { + let event = WsKiroEvent::PoolStatsUpdate { + total_credentials, + healthy_credentials, + available_credentials, + average_health_score, + last_rotation, + }; + + if let Err(e) = self.event_sender.send(event) { + tracing::debug!("Failed to send pool stats update event: {}", e); + } + } + + /// 发送凭证轮换事件 + pub async fn emit_credential_rotated( + &self, + from_uuid: Option, + to_uuid: String, + reason: String, + ) { + let rotation_time = Utc::now(); + let event = WsKiroEvent::CredentialRotated { + from_uuid, + to_uuid, + reason, + rotation_time, + }; + + if let Err(e) = self.event_sender.send(event) { + tracing::debug!("Failed to send credential rotated event: {}", e); + } + } + + /// 发送凭证自动禁用事件 + pub async fn emit_credential_auto_disabled( + &self, + uuid: String, + credential_name: Option, + reason: String, + error_type: String, + ) { + let disable_time = Utc::now(); + let event = WsKiroEvent::CredentialAutoDisabled { + uuid, + credential_name, + reason, + error_type, + disable_time, + }; + + if let Err(e) = self.event_sender.send(event) { + tracing::debug!("Failed to send credential auto disabled event: {}", e); + } + } + + /// 获取当前活跃订阅者数量 + pub fn subscriber_count(&self) -> usize { + self.event_sender.receiver_count() + } + + /// 获取缓存的凭证状态 + pub async fn get_credential_state(&self, uuid: &str) -> Option { + let states = self.credential_states.read().await; + states.get(uuid).cloned() + } + + /// 获取所有凭证状态 + pub async fn get_all_credential_states(&self) -> Vec { + let states = self.credential_states.read().await; + states.values().cloned().collect() + } + + /// 清理过期的凭证状态缓存 + pub async fn cleanup_expired_states(&self, retention_hours: u64) { + let cutoff = Utc::now() - chrono::Duration::hours(retention_hours as i64); + let mut states = self.credential_states.write().await; + states.retain(|_, state| state.last_updated > cutoff); + } +} + +impl Default for KiroEventService { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::time::{sleep, Duration}; + + #[tokio::test] + async fn test_credential_status_update_event() { + let service = KiroEventService::new(); + let mut receiver = service.subscribe(); + + // 发送凭证状态更新事件 + let uuid = "test-uuid".to_string(); + service + .emit_credential_status_update(uuid.clone(), true, false, 0, Some(85.5), None) + .await; + + // 验证事件被正确接收 + let event = receiver.recv().await.unwrap(); + match event { + WsKiroEvent::CredentialStatusUpdate { + uuid: event_uuid, + is_healthy, + health_score, + .. + } => { + assert_eq!(event_uuid, uuid); + assert!(is_healthy); + assert_eq!(health_score, Some(85.5)); + } + _ => panic!("Expected CredentialStatusUpdate event"), + } + + // 验证状态被缓存 + let cached_state = service.get_credential_state(&uuid).await.unwrap(); + assert_eq!(cached_state.uuid, uuid); + assert!(cached_state.is_healthy); + assert_eq!(cached_state.health_score, Some(85.5)); + } + + #[tokio::test] + async fn test_refresh_events() { + let service = KiroEventService::new(); + let mut receiver = service.subscribe(); + + let uuid = "test-uuid".to_string(); + let credential_name = Some("test-credential".to_string()); + + // 测试刷新开始事件 + service + .emit_refresh_started(uuid.clone(), credential_name.clone()) + .await; + + let event = receiver.recv().await.unwrap(); + match event { + WsKiroEvent::RefreshStarted { + uuid: event_uuid, + credential_name: event_name, + } => { + assert_eq!(event_uuid, uuid); + assert_eq!(event_name, credential_name); + } + _ => panic!("Expected RefreshStarted event"), + } + + // 测试刷新成功事件 + let expires_at = Utc::now() + chrono::Duration::hours(1); + service + .emit_refresh_success( + uuid.clone(), + credential_name.clone(), + expires_at, + "IdC".to_string(), + "BuilderId".to_string(), + "us-east-1".to_string(), + ) + .await; + + let event = receiver.recv().await.unwrap(); + match event { + WsKiroEvent::RefreshSuccess { + uuid: event_uuid, + new_token_info, + .. + } => { + assert_eq!(event_uuid, uuid); + assert_eq!(new_token_info.auth_method, "IdC"); + assert_eq!(new_token_info.provider, "BuilderId"); + } + _ => panic!("Expected RefreshSuccess event"), + } + } + + #[tokio::test] + async fn test_multiple_subscribers() { + let service = KiroEventService::new(); + let mut receiver1 = service.subscribe(); + let mut receiver2 = service.subscribe(); + + assert_eq!(service.subscriber_count(), 2); + + // 发送事件 + service + .emit_refresh_started("test-uuid".to_string(), None) + .await; + + // 两个订阅者都应该收到事件 + let event1 = receiver1.recv().await.unwrap(); + let event2 = receiver2.recv().await.unwrap(); + + matches!(event1, WsKiroEvent::RefreshStarted { .. }); + matches!(event2, WsKiroEvent::RefreshStarted { .. }); + } + + #[tokio::test] + async fn test_credential_state_caching() { + let service = KiroEventService::new(); + + // 添加多个凭证状态 + service + .emit_credential_status_update("uuid1".to_string(), true, false, 0, Some(90.0), None) + .await; + + service + .emit_credential_status_update("uuid2".to_string(), false, true, 5, Some(30.0), None) + .await; + + // 验证所有状态都被缓存 + let all_states = service.get_all_credential_states().await; + assert_eq!(all_states.len(), 2); + + // 验证可以按 UUID 获取特定状态 + let state1 = service.get_credential_state("uuid1").await.unwrap(); + assert_eq!(state1.health_score, Some(90.0)); + + let state2 = service.get_credential_state("uuid2").await.unwrap(); + assert_eq!(state2.error_count, 5); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 2cd31f0d4..d12511500 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,4 +1,5 @@ pub mod backup_service; +pub mod kiro_event_service; pub mod live_sync; pub mod mcp_service; pub mod mcp_sync; diff --git a/src-tauri/src/services/provider_pool_service.rs b/src-tauri/src/services/provider_pool_service.rs index 01eb97c7a..2f9692071 100644 --- a/src-tauri/src/services/provider_pool_service.rs +++ b/src-tauri/src/services/provider_pool_service.rs @@ -166,7 +166,13 @@ impl ProviderPoolService { ProviderPoolDao::delete(&conn, uuid).map_err(|e| e.to_string()) } - /// 选择一个可用的凭证(轮询负载均衡) + /// 选择一个可用的凭证(智能轮换策略) + /// + /// 增强版轮换策略,考虑以下因素: + /// - 健康状态:优先选择健康的凭证 + /// - 使用频率:优先选择使用次数较少的凭证 + /// - 错误率:避免选择错误次数过多的凭证 + /// - 冷却时间:避免短时间内重复使用同一凭证 pub fn select_credential( &self, db: &DbConnection, @@ -193,35 +199,95 @@ impl ProviderPoolService { return Ok(None); } - // 轮询选择 - let index_key = match model { - Some(m) => format!("{}:{}", provider_type, m), - None => provider_type.to_string(), - }; - - let index = { - let indices = self.round_robin_index.read().unwrap(); - indices - .get(&index_key) - .map(|i| i.load(Ordering::SeqCst)) - .unwrap_or(0) - }; - - let selected_index = index % available.len(); - let selected = available.remove(selected_index); - - // 更新轮询索引 - { - let mut indices = self.round_robin_index.write().unwrap(); - let counter = indices - .entry(index_key) - .or_insert_with(|| AtomicUsize::new(0)); - counter.store((index + 1) % usize::MAX, Ordering::SeqCst); + // 如果只有一个可用凭证,直接返回 + if available.len() == 1 { + return Ok(Some(available.into_iter().next().unwrap())); } + // 智能选择:基于权重分数选择最优凭证 + let selected = self.select_best_credential_by_weight(&available); + Ok(Some(selected)) } + /// 基于权重分数选择最优凭证 + fn select_best_credential_by_weight( + &self, + credentials: &[ProviderCredential], + ) -> ProviderCredential { + let now = chrono::Utc::now(); + + let mut best_score = f64::MIN; + let mut best_credential = None; + + for cred in credentials { + let score = self.calculate_credential_score(cred, now, credentials); + if score > best_score { + best_score = score; + best_credential = Some(cred); + } + } + + best_credential.unwrap().clone() + } + + /// 计算凭证的综合分数(分数越高越优先) + fn calculate_credential_score( + &self, + cred: &ProviderCredential, + now: chrono::DateTime, + all_credentials: &[ProviderCredential], + ) -> f64 { + let mut score = 0.0; + + // 1. 健康状态权重 (40分) + if cred.is_healthy { + score += 40.0; + } else { + score -= 20.0; // 不健康的凭证严重扣分 + } + + // 2. 使用频率权重 (30分) - 使用次数越少分数越高 + let max_usage = all_credentials + .iter() + .map(|c| c.usage_count) + .max() + .unwrap_or(1); + if max_usage > 0 { + let usage_ratio = cred.usage_count as f64 / max_usage as f64; + score += 30.0 * (1.0 - usage_ratio); // 使用越少分数越高 + } else { + score += 30.0; // 如果都没使用过,给满分 + } + + // 3. 错误率权重 (20分) - 错误越少分数越高 + let total_requests = cred.usage_count + cred.error_count as u64; + if total_requests > 0 { + let error_ratio = cred.error_count as f64 / total_requests as f64; + score += 20.0 * (1.0 - error_ratio); // 错误率越低分数越高 + } else { + score += 20.0; // 没有历史记录给满分 + } + + // 4. 冷却时间权重 (10分) - 距离上次使用时间越长分数越高 + if let Some(last_used) = &cred.last_used { + let duration_since_last_use = now.signed_duration_since(*last_used); + let minutes_since_last_use = duration_since_last_use.num_minutes() as f64; + + // 超过5分钟的冷却时间给满分,否则按比例给分 + let cooldown_score = if minutes_since_last_use >= 5.0 { + 10.0 + } else { + 10.0 * (minutes_since_last_use / 5.0) + }; + score += cooldown_score; + } else { + score += 10.0; // 从未使用过给满分 + } + + score + } + /// 记录凭证使用 pub fn record_usage(&self, db: &DbConnection, uuid: &str) -> Result<(), String> { let conn = db.lock().map_err(|e| e.to_string())?; diff --git a/src-tauri/src/services/token_cache_service.rs b/src-tauri/src/services/token_cache_service.rs index 927e551fa..7746437a2 100644 --- a/src-tauri/src/services/token_cache_service.rs +++ b/src-tauri/src/services/token_cache_service.rs @@ -14,11 +14,44 @@ use crate::models::provider_pool_model::{ use crate::providers::gemini::GeminiProvider; use crate::providers::kiro::KiroProvider; use crate::providers::qwen::QwenProvider; +use crate::services::kiro_event_service::KiroEventService; use chrono::Utc; use dashmap::DashMap; use std::sync::Arc; use tokio::sync::Mutex; +/// Token 刷新错误类型 +#[derive(Debug, Clone, PartialEq)] +pub enum RefreshErrorType { + /// Token被截断或格式问题 + TokenTruncated, + /// Token格式异常(长度过短等) + TokenFormat, + /// 网络连接问题 + Network, + /// 服务不可用 + ServiceUnavailable, + /// 认证失败(401, 403等) + AuthenticationFailed, + /// 未知错误 + Unknown, +} + +/// Token 刷新错误分类结果 +#[derive(Debug, Clone)] +pub struct RefreshErrorClassification { + /// 错误类型 + pub error_type: RefreshErrorType, + /// 错误描述 + pub error_description: String, + /// 建议重试次数 + pub retry_count: u32, + /// 是否支持降级策略 + pub supports_fallback: bool, + /// 是否应该自动禁用凭证(永久性错误) + pub should_disable_credential: bool, +} + /// Token 缓存服务 pub struct TokenCacheService { /// 每凭证一把锁,防止并发刷新 @@ -69,77 +102,144 @@ impl TokenCacheService { match self.refresh_and_cache(db, uuid, false).await { Ok(token) => Ok(token), Err(refresh_error) => { - // 刷新失败时,检查是否是因为 refreshToken 被截断 - // 如果是,尝试直接使用源文件中的 accessToken(可能仍然有效) - if refresh_error.contains("截断") || refresh_error.contains("truncated") { - tracing::warn!( - "[TOKEN_CACHE] refreshToken 被截断,尝试使用源文件中的 accessToken: {}", - &uuid[..8] - ); + // 增强的错误处理机制 - 智能检测各种token问题 + let error_classification = self.classify_refresh_error(&refresh_error); - // 获取凭证信息 - let credential = { - let conn = db.lock().map_err(|e| e.to_string())?; - ProviderPoolDao::get_by_uuid(&conn, uuid) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Credential not found: {}", uuid))? - }; + tracing::warn!( + "[TOKEN_CACHE] Token 刷新失败,错误类型: {:?}, 详情: {}", + error_classification.error_type, + &refresh_error + ); - // 尝试从源文件读取 accessToken - match self.read_token_from_source(&credential).await { - Ok(token_info) => { - if let Some(token) = token_info.access_token { + match error_classification.error_type { + RefreshErrorType::TokenTruncated | RefreshErrorType::TokenFormat => { + tracing::warn!( + "[TOKEN_CACHE] 检测到 token 问题,尝试使用源文件中的 accessToken: {}", + &uuid[..8] + ); + + // 获取凭证信息 + let credential = { + let conn = db.lock().map_err(|e| e.to_string())?; + ProviderPoolDao::get_by_uuid(&conn, uuid) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Credential not found: {}", uuid))? + }; + + // 尝试从源文件读取 accessToken + match self.read_token_from_source(&credential).await { + Ok(token_info) => { + if let Some(token) = token_info.access_token { + tracing::info!( + "[TOKEN_CACHE] 使用源文件中的 accessToken 作为降级方案: {}", + &uuid[..8] + ); + + // 缓存这个 token 但标记为降级状态 + let cache_info = CachedTokenInfo { + access_token: Some(token.clone()), + refresh_token: token_info.refresh_token, + expiry_time: None, // 无法确定过期时间 + last_refresh: Some(Utc::now()), + refresh_error_count: error_classification.retry_count, + last_refresh_error: Some(format!( + "{}(降级使用源文件 accessToken): {}", + error_classification.error_description, refresh_error + )), + }; + + // 缓存到数据库 + if let Ok(conn) = db.lock() { + let _ = ProviderPoolDao::update_token_cache( + &conn, + uuid, + &cache_info, + ); + } + + return Ok(token); + } + } + Err(e) => { + tracing::error!( + "[TOKEN_CACHE] 降级策略失败,无法从源文件读取 accessToken: {}", + e + ); + } + } + } + RefreshErrorType::Network | RefreshErrorType::ServiceUnavailable => { + tracing::warn!("[TOKEN_CACHE] 网络/服务问题,建议稍后重试: {}", &uuid[..8]); + // 可以考虑使用缓存中的过期 token 作为临时方案 + if let Some(cache) = cached { + if let Some(token) = cache.access_token { tracing::info!( - "[TOKEN_CACHE] 使用源文件中的 accessToken(可能已过期): {}", + "[TOKEN_CACHE] 网络问题时使用过期缓存 token: {}", &uuid[..8] ); - // 注意:这个 token 可能已过期,但至少可以尝试使用 - // 缓存这个 token(但不设置过期时间,因为我们不知道它何时过期) - let cache_info = CachedTokenInfo { - access_token: Some(token.clone()), - refresh_token: token_info.refresh_token, - expiry_time: None, // 不知道过期时间 - last_refresh: Some(Utc::now()), - refresh_error_count: 1, - last_refresh_error: Some(format!( - "refreshToken 被截断,使用源文件 accessToken: {}", - refresh_error - )), - }; - - // 缓存到数据库 - if let Ok(conn) = db.lock() { - let _ = ProviderPoolDao::update_token_cache( - &conn, - uuid, - &cache_info, - ); - } - return Ok(token); } } - Err(e) => { - tracing::error!("[TOKEN_CACHE] 无法从源文件读取 accessToken: {}", e); - } + } + RefreshErrorType::AuthenticationFailed => { + tracing::error!("[TOKEN_CACHE] 认证失败,凭证可能已被撤销: {}", &uuid[..8]); + // 认证失败通常需要用户重新授权,不进行降级 + } + RefreshErrorType::Unknown => { + tracing::warn!("[TOKEN_CACHE] 未知错误类型,使用默认处理: {}", &uuid[..8]); } } - // 返回原始刷新错误 - Err(refresh_error) + // 更新错误计数 + if let Ok(conn) = db.lock() { + let _ = ProviderPoolDao::record_token_refresh_error( + &conn, + uuid, + &format!( + "{}(分类: {:?}): {}", + error_classification.error_description, + error_classification.error_type, + refresh_error + ), + ); + } + + // 返回分类后的错误信息 + Err(format!( + "{}: {}", + error_classification.error_description, refresh_error + )) } } } - /// 刷新 Token 并缓存到数据库 + /// 刷新 Token 并缓存到数据库(带事件发送) /// /// - force: 是否强制刷新(忽略缓存状态) - pub async fn refresh_and_cache( + /// - kiro_event_service: 可选的事件服务,用于发送 Kiro 凭证刷新事件 + /// + /// 优化说明:添加了随机延迟机制,避免多个凭证同时刷新造成请求过于集中 + pub async fn refresh_and_cache_with_events( &self, db: &DbConnection, uuid: &str, force: bool, + kiro_event_service: Option>, ) -> Result { + // 添加随机延迟,避免多个凭证同时刷新 + // 基于凭证UUID生成0-30秒的随机延迟,确保同一凭证的延迟时间一致但不同凭证间分散 + if !force { + let delay_ms = self.calculate_refresh_delay(uuid); + if delay_ms > 0 { + tracing::debug!( + "[TOKEN_CACHE] Adding {}ms delay before refreshing token for {}", + delay_ms, + &uuid[..8] + ); + tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await; + } + } + // 获取该凭证的锁 let lock = self .locks @@ -183,6 +283,15 @@ impl TokenCacheService { credential.provider_type ); + // 发送刷新开始事件(仅针对 Kiro 凭证) + if let Some(event_service) = &kiro_event_service { + if credential.provider_type == PoolProviderType::Kiro { + event_service + .emit_refresh_started(uuid.to_string(), credential.name.clone()) + .await; + } + } + // 执行刷新 match self.do_refresh(&credential).await { Ok(token_info) => { @@ -203,6 +312,24 @@ impl TokenCacheService { token_info.expiry_time ); + // 发送刷新成功事件(仅针对 Kiro 凭证) + if let Some(event_service) = &kiro_event_service { + if credential.provider_type == PoolProviderType::Kiro { + event_service + .emit_refresh_success( + uuid.to_string(), + credential.name.clone(), + token_info + .expiry_time + .unwrap_or_else(|| Utc::now() + chrono::Duration::hours(1)), + "IdC".to_string(), // 默认为IdC认证 + "BuilderId".to_string(), + "us-east-1".to_string(), + ) + .await; + } + } + Ok(token) } Err(e) => { @@ -218,6 +345,77 @@ impl TokenCacheService { e ); + // 分析错误并决定是否自动禁用凭证 + let error_classification = self.classify_refresh_error(&e); + + // 如果是永久性错误,自动禁用凭证 + if error_classification.should_disable_credential { + let disable_result = { + let conn = db.lock().map_err(|e| e.to_string())?; + // 简化禁用逻辑:直接在数据库中标记为禁用 + let sql = "UPDATE credentials SET is_disabled = true WHERE uuid = ?"; + conn.execute(sql, &[&uuid]).map_err(|e| e.to_string()) + }; + + match disable_result { + Ok(_) => { + tracing::warn!( + "[TOKEN_CACHE] Auto-disabled credential {} due to permanent failure: {:?}", + &uuid[..8], + error_classification.error_type + ); + + // 发送凭证禁用事件 + if let Some(event_service) = &kiro_event_service { + if credential.provider_type == PoolProviderType::Kiro { + // 发送状态更新事件 + event_service + .emit_credential_status_update( + uuid.to_string(), + false, // is_healthy + true, // is_disabled + credential.error_count + 1, + Some(0.0), // health_score降为0 + None, + ) + .await; + + // 发送自动禁用事件 + event_service + .emit_credential_auto_disabled( + uuid.to_string(), + credential.name.clone(), + error_classification.error_description.clone(), + format!("{:?}", error_classification.error_type), + ) + .await; + } + } + } + Err(disable_err) => { + tracing::error!( + "[TOKEN_CACHE] Failed to auto-disable credential {}: {}", + &uuid[..8], + disable_err + ); + } + } + } + + // 发送刷新失败事件(仅针对 Kiro 凭证) + if let Some(event_service) = &kiro_event_service { + if credential.provider_type == PoolProviderType::Kiro { + event_service + .emit_refresh_failed( + uuid.to_string(), + credential.name.clone(), + e.clone(), + Some(format!("{:?}", error_classification.error_type)), + ) + .await; + } + } + Err(e) } } @@ -883,4 +1081,135 @@ impl TokenCacheService { let conn = db.lock().map_err(|e| e.to_string())?; ProviderPoolDao::get_token_cache(&conn, uuid).map_err(|e| e.to_string()) } + + /// 计算刷新延迟时间(毫秒) + /// + /// 基于凭证UUID生成确定性但分散的延迟时间,避免多个凭证同时刷新 + /// 延迟范围:0-30秒,确保同一凭证每次的延迟一致 + fn calculate_refresh_delay(&self, uuid: &str) -> u64 { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + // 使用凭证UUID作为种子生成确定性的延迟 + let mut hasher = DefaultHasher::new(); + uuid.hash(&mut hasher); + let hash_value = hasher.finish(); + + // 生成0-30秒的延迟(转换为毫秒) + (hash_value % 30000) as u64 + } + + /// 智能错误分类方法 + /// + /// 基于错误信息智能识别错误类型,提供针对性的处理建议 + fn classify_refresh_error(&self, error_message: &str) -> RefreshErrorClassification { + let error_lower = error_message.to_lowercase(); + + // Token 被截断问题检测(最严重的问题,优先检查) + if error_lower.contains("截断") || error_lower.contains("truncated") { + return RefreshErrorClassification { + error_type: RefreshErrorType::TokenTruncated, + error_description: "Token 被截断,需检查配置文件".to_string(), + retry_count: 1, + supports_fallback: true, + should_disable_credential: true, // 永久性问题,自动禁用 + }; + } + + // Token 格式问题检测 + if error_lower.contains("格式异常") + || error_lower.contains("长度过短") + || error_lower.contains("format") + || error_lower.contains("invalid") + || error_lower.contains("malformed") + { + return RefreshErrorClassification { + error_type: RefreshErrorType::TokenFormat, + error_description: "Token 格式异常,需重新配置".to_string(), + retry_count: 1, + supports_fallback: true, + should_disable_credential: true, // 配置问题,自动禁用 + }; + } + + // 认证失败检测 + if error_lower.contains("unauthorized") + || error_lower.contains("forbidden") + || error_lower.contains("401") + || error_lower.contains("403") + || error_lower.contains("认证失败") + || error_lower.contains("invalid_grant") + || error_lower.contains("access_denied") + || error_lower.contains("refresh_token") + || error_lower.contains("expired") + { + return RefreshErrorClassification { + error_type: RefreshErrorType::AuthenticationFailed, + error_description: "认证失败,凭证已过期或无效".to_string(), + retry_count: 0, // 不建议重试 + supports_fallback: false, + should_disable_credential: true, // 认证失效,自动禁用 + }; + } + + // 网络问题检测 + if error_lower.contains("network") + || error_lower.contains("connection") + || error_lower.contains("timeout") + || error_lower.contains("dns") + || error_lower.contains("connect") + || error_lower.contains("网络") + || error_lower.contains("连接") + { + return RefreshErrorClassification { + error_type: RefreshErrorType::Network, + error_description: "网络连接问题".to_string(), + retry_count: 3, + supports_fallback: true, + should_disable_credential: false, // 临时问题,不禁用 + }; + } + + // 服务不可用检测 + if error_lower.contains("service unavailable") + || error_lower.contains("502") + || error_lower.contains("503") + || error_lower.contains("504") + || error_lower.contains("internal server error") + || error_lower.contains("服务不可用") + { + return RefreshErrorClassification { + error_type: RefreshErrorType::ServiceUnavailable, + error_description: "服务暂时不可用".to_string(), + retry_count: 2, + supports_fallback: true, + should_disable_credential: false, // 临时问题,不禁用 + }; + } + + // 未知错误(默认分类) + RefreshErrorClassification { + error_type: RefreshErrorType::Unknown, + error_description: "未知错误".to_string(), + retry_count: 1, + supports_fallback: false, + should_disable_credential: false, // 未知错误暂不自动禁用 + } + } + + /// 刷新 Token 并缓存到数据库(兼容版本) + /// + /// - force: 是否强制刷新(忽略缓存状态) + /// + /// 此方法保持与旧版本的兼容性,不发送任何事件。 + /// 如需事件支持,请使用 refresh_and_cache_with_events 方法。 + pub async fn refresh_and_cache( + &self, + db: &DbConnection, + uuid: &str, + force: bool, + ) -> Result { + self.refresh_and_cache_with_events(db, uuid, force, None) + .await + } } diff --git a/src-tauri/src/websocket/handler.rs b/src-tauri/src/websocket/handler.rs index 9aadee43a..876c5f1da 100644 --- a/src-tauri/src/websocket/handler.rs +++ b/src-tauri/src/websocket/handler.rs @@ -261,6 +261,20 @@ async fn handle_message( "FlowEvent messages are server-to-client only", ))) } + WsMessage::SubscribeKiroEvents => { + // TODO: 实现Kiro事件订阅 + None + } + WsMessage::UnsubscribeKiroEvents => { + // TODO: 实现Kiro事件取消订阅 + None + } + WsMessage::KiroCredentialEvent(_) => { + // Kiro事件是服务端到客户端的消息,客户端不应该发送 + Some(WsMessage::Error(WsError::invalid_message( + "KiroCredentialEvent messages are server-to-client only", + ))) + } } } diff --git a/src-tauri/src/websocket/mod.rs b/src-tauri/src/websocket/mod.rs index 1db030985..eb921b146 100644 --- a/src-tauri/src/websocket/mod.rs +++ b/src-tauri/src/websocket/mod.rs @@ -19,8 +19,9 @@ pub use lifecycle::{ pub use processor::MessageProcessor; pub use stream::{BackpressureController, StreamForwarder}; pub use types::{ - WsApiRequest, WsApiResponse, WsConfig, WsConnection, WsConnectionStatus, WsEndpoint, WsError, - WsErrorCode, WsFlowEvent, WsMessage, WsStats, WsStatsSnapshot, WsStreamChunk, WsStreamEnd, + KiroTokenInfo, WsApiRequest, WsApiResponse, WsConfig, WsConnection, WsConnectionStatus, + WsEndpoint, WsError, WsErrorCode, WsFlowEvent, WsKiroEvent, WsMessage, WsStats, + WsStatsSnapshot, WsStreamChunk, WsStreamEnd, }; use dashmap::DashMap; diff --git a/src-tauri/src/websocket/types.rs b/src-tauri/src/websocket/types.rs index 05991caa0..9f07ffdb7 100644 --- a/src-tauri/src/websocket/types.rs +++ b/src-tauri/src/websocket/types.rs @@ -80,6 +80,12 @@ pub enum WsMessage { UnsubscribeFlowEvents, /// Flow 事件通知 FlowEvent(WsFlowEvent), + /// 订阅 Kiro 凭证状态事件 + SubscribeKiroEvents, + /// 取消订阅 Kiro 凭证状态事件 + UnsubscribeKiroEvents, + /// Kiro 凭证状态事件通知 + KiroCredentialEvent(WsKiroEvent), } /// WebSocket API 请求 @@ -369,3 +375,78 @@ impl From for WsFlowEvent { } } } + +/// WebSocket Kiro 凭证事件 +/// +/// 用于通过 WebSocket 推送 Kiro 凭证状态变化 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "event_type", rename_all = "snake_case")] +pub enum WsKiroEvent { + /// 凭证状态更新 + CredentialStatusUpdate { + uuid: String, + is_healthy: bool, + is_disabled: bool, + error_count: u32, + health_score: Option, + last_used: Option>, + }, + /// 凭证刷新开始 + RefreshStarted { + uuid: String, + credential_name: Option, + }, + /// 凭证刷新成功 + RefreshSuccess { + uuid: String, + credential_name: Option, + new_token_info: KiroTokenInfo, + }, + /// 凭证刷新失败 + RefreshFailed { + uuid: String, + credential_name: Option, + error: String, + error_code: Option, + }, + /// 凭证健康检查完成 + HealthCheckCompleted { + uuid: String, + credential_name: Option, + is_healthy: bool, + health_score: Option, + last_check: DateTime, + }, + /// 凭证池统计更新 + PoolStatsUpdate { + total_credentials: u32, + healthy_credentials: u32, + available_credentials: u32, + average_health_score: Option, + last_rotation: Option>, + }, + /// 凭证轮换事件 + CredentialRotated { + from_uuid: Option, + to_uuid: String, + reason: String, + rotation_time: DateTime, + }, + /// 凭证自动禁用事件 + CredentialAutoDisabled { + uuid: String, + credential_name: Option, + reason: String, + error_type: String, + disable_time: DateTime, + }, +} + +/// Kiro Token 信息(用于刷新成功事件) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KiroTokenInfo { + pub expires_at: DateTime, + pub auth_method: String, + pub provider: String, + pub region: String, +} diff --git a/src/components/provider-pool/CredentialCard.tsx b/src/components/provider-pool/CredentialCard.tsx index fd6160bde..26a691847 100644 --- a/src/components/provider-pool/CredentialCard.tsx +++ b/src/components/provider-pool/CredentialCard.tsx @@ -29,6 +29,7 @@ import type { import { getKiroCredentialFingerprint, type KiroFingerprintInfo, + kiroCredentialApi, } from "@/lib/api/providerPool"; import { usageApi, type UsageInfo } from "@/lib/api/usage"; import { UsageDisplay } from "./UsageDisplay"; @@ -74,6 +75,12 @@ export function CredentialCard({ const [fingerprintExpanded, setFingerprintExpanded] = useState(false); const [fingerprintCopied, setFingerprintCopied] = useState(false); + // Kiro 增强状态管理 + const [kiroHealthScore, setKiroHealthScore] = useState(null); + const [kiroStatusLoading, setKiroStatusLoading] = useState(false); + const [kiroRefreshing, setKiroRefreshing] = useState(false); + const [kiroStatusExpanded, setKiroStatusExpanded] = useState(false); + // 查询指纹信息 const handleCheckFingerprint = async () => { if (fingerprintExpanded && fingerprintInfo) { @@ -129,6 +136,51 @@ export function CredentialCard({ } }; + // 获取 Kiro 详细状态 + const handleCheckKiroStatus = async () => { + if (kiroStatusExpanded) { + setKiroStatusExpanded(false); + return; + } + + setKiroStatusExpanded(true); + setKiroStatusLoading(true); + + try { + const status = await kiroCredentialApi.getCredentialStatus( + credential.uuid, + ); + setKiroHealthScore(status.health_score || 0); + } catch (e) { + console.error("获取 Kiro 状态失败:", e); + } finally { + setKiroStatusLoading(false); + } + }; + + // 快速刷新 Kiro Token + const handleQuickRefresh = async () => { + setKiroRefreshing(true); + + try { + const result = await kiroCredentialApi.refreshCredential(credential.uuid); + if (result.success) { + // 刷新成功,可以显示成功消息 + console.log("Token 刷新成功:", result.message); + // 可以触发页面数据刷新 + if (onRefreshToken) { + onRefreshToken(); + } + } else { + console.error("Token 刷新失败:", result.error || result.message); + } + } catch (e) { + console.error("Token 刷新异常:", e); + } finally { + setKiroRefreshing(false); + } + }; + const formatDate = (dateStr?: string) => { if (!dateStr) return "从未"; const date = new Date(dateStr); @@ -334,6 +386,42 @@ export function CredentialCard({ )} + {/* Kiro 详细状态按钮 - 仅 Kiro 凭证显示 */} + {isKiroCredential && ( + + )} + + {/* Kiro 快速刷新按钮 - 仅 Kiro 凭证显示 */} + {isKiroCredential && ( + + )} + + + + {kiroStatusLoading ? ( +
+
+ 加载中... +
+ ) : kiroHealthScore !== null ? ( +
+ {/* 健康分数详情 */} +
+
+ + 健康分数 + +
= 80 + ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" + : kiroHealthScore >= 60 + ? "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400" + : kiroHealthScore >= 40 + ? "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400" + : "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400" + }`} + > + {Math.round(kiroHealthScore)} / 100 +
+
+ + {/* 健康分数条 */} +
+
= 80 + ? "bg-green-500" + : kiroHealthScore >= 60 + ? "bg-yellow-500" + : kiroHealthScore >= 40 + ? "bg-orange-500" + : "bg-red-500" + }`} + style={{ + width: `${Math.max(0, Math.min(100, kiroHealthScore))}%`, + }} + >
+
+ + {/* 健康状态描述 */} +
+ {credential.is_disabled + ? "凭证已被自动禁用,需手动重新启用" + : kiroHealthScore >= 80 + ? "凭证状态良好,可正常使用" + : kiroHealthScore >= 60 + ? "凭证状态一般,建议注意监控" + : kiroHealthScore >= 40 + ? "凭证状态较差,可能有风险" + : "凭证状态异常,需要立即处理"} +
+
+ + {/* 状态指标 */} +
+
+
+ + + 冷却时间 + +
+
+ 根据使用频率计算的建议等待时间 +
+
+ +
+
+ + + 使用权重 + +
+
+ 在轮询池中的权重分配 +
+
+
+ + {/* 快速操作 */} +
+ {credential.is_disabled ? ( + // 已禁用凭证显示重新启用按钮 + + ) : ( + // 正常凭证显示刷新和检查按钮 + <> + + + + )} +
+
+ ) : ( +
+ 无法获取状态信息,请重试 +
+ )} +
+ )} + {/* 用量信息展示区域 - 仅 Kiro 凭证 */} {isKiroCredential && usageExpanded && (
diff --git a/src/lib/api/providerPool.ts b/src/lib/api/providerPool.ts index c14fff90b..301b36d43 100644 --- a/src/lib/api/providerPool.ts +++ b/src/lib/api/providerPool.ts @@ -598,6 +598,134 @@ export async function getKiroCredentialFingerprint( return invoke("get_kiro_credential_fingerprint", { uuid }); } +// ============ Kiro 凭证池管理 HTTP API ============ + +/** 可用凭证信息 */ +export interface AvailableCredential { + /** 凭证UUID */ + uuid: string; + /** 凭证名称 */ + name: string; + /** 是否可用 */ + available: boolean; + /** Token过期时间 */ + expires_at?: string; + /** 最后使用时间 */ + last_used?: string; + /** 健康状态分数 (0-100) */ + health_score: number; + /** 错误计数 */ + error_count: number; + /** 最后错误信息 */ + last_error?: string; +} + +/** 获取可用凭证列表的响应 */ +export interface AvailableCredentialsResponse { + /** 可用凭证列表 */ + credentials: AvailableCredential[]; + /** 总凭证数 */ + total: number; + /** 可用凭证数 */ + available: number; + /** 系统状态 */ + status: string; +} + +/** 选择凭证请求参数 */ +export interface SelectCredentialRequest { + /** 指定模型(可选) */ + model?: string; + /** 强制选择特定UUID(可选) */ + force_uuid?: string; +} + +/** 选择凭证响应 */ +export interface SelectCredentialResponse { + /** 选中��凭证UUID */ + uuid: string; + /** 凭证名称 */ + name: string; + /** Access Token(脱敏显示) */ + access_token_preview: string; + /** Token过期时间 */ + expires_at?: string; + /** 选择原因 */ + selection_reason: string; +} + +/** 刷新凭证响应 */ +export interface RefreshCredentialResponse { + /** 凭证UUID */ + uuid: string; + /** 刷新是否成功 */ + success: boolean; + /** 新的过期时间 */ + new_expires_at?: string; + /** 刷新结果信息 */ + message: string; + /** 错误信息(如果有) */ + error?: string; +} + +/** Kiro 凭证池管理 API */ +export const kiroCredentialApi = { + /** 获取可用凭证列表 */ + async getAvailableCredentials(): Promise { + const response = await fetch("/api/kiro/credentials/available"); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return response.json(); + }, + + /** 智能选择凭证 */ + async selectCredential( + request: SelectCredentialRequest, + ): Promise { + const response = await fetch("/api/kiro/credentials/select", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + errorData.message || `HTTP ${response.status}: ${response.statusText}`, + ); + } + return response.json(); + }, + + /** 手动刷新指定凭证 */ + async refreshCredential(uuid: string): Promise { + const response = await fetch(`/api/kiro/credentials/${uuid}/refresh`, { + method: "PUT", + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + errorData.message || `HTTP ${response.status}: ${response.statusText}`, + ); + } + return response.json(); + }, + + /** 获取凭证详细状态 */ + async getCredentialStatus(uuid: string): Promise> { + const response = await fetch(`/api/kiro/credentials/${uuid}/status`); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + errorData.message || `HTTP ${response.status}: ${response.statusText}`, + ); + } + return response.json(); + }, +}; + // ============ Playwright 指纹浏览器登录 ============ /**