From d67ca77a5fa9abe54b388dee49a8f8a6a35d94fb Mon Sep 17 00:00:00 2001 From: coso Date: Fri, 9 Jan 2026 18:43:52 +0800 Subject: [PATCH] =?UTF-8?q?v0.38.0:=20=E4=BF=AE=E5=A4=8D=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=20Provider=20=E6=B7=BB=E5=8A=A0=20API=20Key=20?= =?UTF-8?q?=E9=97=AE=E9=A2=98=EF=BC=8COAuth=20=E6=8F=92=E4=BB=B6=E6=A0=87?= =?UTF-8?q?=E8=AE=B0=E4=B8=BA=E5=AE=9E=E9=AA=8C=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 4 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- .../src/database/dao/api_key_provider.rs | 19 ++ .../src/services/api_key_provider_service.rs | 203 ++++++++++++------ src-tauri/tauri.conf.json | 2 +- src-tauri/tests/api_key_provider_tests.rs | 34 +++ .../provider-pool/ProviderPoolPage.tsx | 32 ++- .../api-key/AddCustomProviderModal.tsx | 29 ++- .../provider-pool/api-key/ApiKeyList.tsx | 28 +++ .../api-key/ApiKeyProviderSection.tsx | 42 +++- .../api-key/ProviderModelList.tsx | 5 - .../provider-pool/api-key/ProviderSetting.tsx | 1 + src/components/provider-pool/api-key/index.ts | 5 +- src/hooks/__tests__/useModelRegistry.test.ts | 197 +++++++++++++++++ src/hooks/useApiKeyProvider.ts | 53 ++++- src/hooks/useModelRegistry.ts | 59 ++++- 18 files changed, 616 insertions(+), 103 deletions(-) create mode 100644 src/hooks/__tests__/useModelRegistry.test.ts diff --git a/package-lock.json b/package-lock.json index d86aec93d..7cd6ff9ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "proxycast", - "version": "0.37.0", + "version": "0.38.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "proxycast", - "version": "0.37.0", + "version": "0.38.0", "dependencies": { "@fabianlars/tauri-plugin-oauth": "^2", "@floating-ui/react": "^0.27.16", diff --git a/package.json b/package.json index f0eb9a7e2..491a9a590 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.37.0", + "version": "0.38.0", "type": "module", "repository": { "type": "git", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b770ec3b7..a36a6f2e0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3907,7 +3907,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.37.0" +version = "0.38.0" dependencies = [ "anyhow", "arboard", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cdcdf82d9..df218e665 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proxycast" -version = "0.37.0" +version = "0.38.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/src/database/dao/api_key_provider.rs b/src-tauri/src/database/dao/api_key_provider.rs index d5dbfe506..2b673fddc 100644 --- a/src-tauri/src/database/dao/api_key_provider.rs +++ b/src-tauri/src/database/dao/api_key_provider.rs @@ -495,6 +495,12 @@ impl ApiKeyProviderDao { /// 插入新 API Key pub fn insert_api_key(conn: &Connection, key: &ApiKeyEntry) -> Result<(), rusqlite::Error> { + tracing::info!( + "[DAO] insert_api_key: id={}, provider_id={}", + key.id, + key.provider_id + ); + conn.execute( "INSERT INTO api_keys (id, provider_id, api_key_encrypted, alias, enabled, @@ -512,6 +518,8 @@ impl ApiKeyProviderDao { key.created_at.to_rfc3339(), ], )?; + + tracing::info!("[DAO] insert_api_key: 插入成功"); Ok(()) } @@ -631,10 +639,21 @@ impl ApiKeyProviderDao { conn: &Connection, ) -> Result, rusqlite::Error> { let providers = Self::get_all_providers(conn)?; + tracing::info!( + "[DAO] get_all_providers_with_keys: 获取到 {} 个 Provider", + providers.len() + ); + let mut result = Vec::new(); for provider in providers { let api_keys = Self::get_api_keys_by_provider(conn, &provider.id)?; + tracing::info!( + "[DAO] Provider {} ({}): {} 个 API Key", + provider.id, + provider.name, + api_keys.len() + ); result.push(ProviderWithKeys { provider, api_keys }); } diff --git a/src-tauri/src/services/api_key_provider_service.rs b/src-tauri/src/services/api_key_provider_service.rs index e54b6dde9..90d9a4290 100644 --- a/src-tauri/src/services/api_key_provider_service.rs +++ b/src-tauri/src/services/api_key_provider_service.rs @@ -171,14 +171,21 @@ impl ApiKeyProviderService { self.initialize_system_providers(db)?; let conn = db.lock().map_err(|e| e.to_string())?; - let mut providers = + let providers = ApiKeyProviderDao::get_all_providers_with_keys(&conn).map_err(|e| e.to_string())?; - // 解密 API Keys(用于前端显示掩码) - for provider in &mut providers { - for _key in &mut provider.api_keys { - // 保持加密状态,前端会显示掩码 - } + tracing::debug!( + "[ApiKeyProviderService] 获取到 {} 个 Provider", + providers.len() + ); + + for p in &providers { + tracing::debug!( + "[ApiKeyProviderService] Provider: id={}, name={}, api_keys={}", + p.provider.id, + p.provider.name, + p.api_keys.len() + ); } Ok(providers) @@ -318,6 +325,7 @@ impl ApiKeyProviderService { /// 添加 API Key /// /// 当添加第一个 API Key 时,会自动启用 Provider + /// 使用数据库事务确保操作的原子性 pub fn add_api_key( &self, db: &DbConnection, @@ -325,26 +333,54 @@ impl ApiKeyProviderService { api_key: &str, alias: Option, ) -> Result { + tracing::info!( + "[ApiKeyProviderService] 开始添加 API Key: provider_id={}", + provider_id + ); + + let mut conn = db.lock().map_err(|e| e.to_string())?; + + // 使用事务确保操作的原子性 + let tx = conn + .transaction() + .map_err(|e| format!("开始事务失败: {}", e))?; + // 验证 Provider 存在 - let conn = db.lock().map_err(|e| e.to_string())?; - let provider = ApiKeyProviderDao::get_provider_by_id(&conn, provider_id) + let provider = ApiKeyProviderDao::get_provider_by_id(&tx, provider_id) .map_err(|e| e.to_string())? .ok_or_else(|| format!("Provider not found: {}", provider_id))?; - // 检查是否是第一个 API Key,如果是则自动启用 Provider - let existing_keys = ApiKeyProviderDao::get_api_keys_by_provider(&conn, provider_id) - .map_err(|e| e.to_string())?; - let should_enable_provider = existing_keys.is_empty() && !provider.enabled; + tracing::info!( + "[ApiKeyProviderService] 找到 Provider: name={}, id={}", + provider.name, + provider.id + ); - // 加密 API Key - let encrypted_key = self.encryption.encrypt(api_key); + // 检查 API Key 是否已存在(防重复添加) + let existing_keys = ApiKeyProviderDao::get_api_keys_by_provider(&tx, provider_id) + .map_err(|e| e.to_string())?; + + tracing::info!( + "[ApiKeyProviderService] 当前已有 {} 个 API Key", + existing_keys.len() + ); + + // 检查是否有相同的 API Key(比较加密后的值) + let encrypted_input = self.encryption.encrypt(api_key); + for existing_key in &existing_keys { + if existing_key.api_key_encrypted == encrypted_input { + return Err("该 API Key 已存在".to_string()); + } + } + + let should_enable_provider = existing_keys.is_empty() && !provider.enabled; let now = Utc::now(); let key = ApiKeyEntry { id: uuid::Uuid::new_v4().to_string(), provider_id: provider_id.to_string(), - api_key_encrypted: encrypted_key, - alias, + api_key_encrypted: encrypted_input, + alias: alias.clone(), enabled: true, usage_count: 0, error_count: 0, @@ -352,14 +388,21 @@ impl ApiKeyProviderService { created_at: now, }; - ApiKeyProviderDao::insert_api_key(&conn, &key).map_err(|e| e.to_string())?; + // 插入 API Key + ApiKeyProviderDao::insert_api_key(&tx, &key).map_err(|e| e.to_string())?; + + tracing::info!( + "[ApiKeyProviderService] API Key 已插入: id={}, provider_id={}", + key.id, + key.provider_id + ); // 如果是第一个 API Key,自动启用 Provider if should_enable_provider { let mut updated_provider = provider; updated_provider.enabled = true; updated_provider.updated_at = now; - ApiKeyProviderDao::update_provider(&conn, &updated_provider) + ApiKeyProviderDao::update_provider(&tx, &updated_provider) .map_err(|e| e.to_string())?; tracing::info!( "[ApiKeyProviderService] 自动启用 Provider: {} (添加了第一个 API Key)", @@ -367,6 +410,15 @@ impl ApiKeyProviderService { ); } + // 提交事务 + tx.commit().map_err(|e| format!("提交事务失败: {}", e))?; + + tracing::info!( + "[ApiKeyProviderService] 成功添加 API Key: provider={}, alias={:?}", + provider_id, + alias + ); + Ok(key) } @@ -923,8 +975,8 @@ impl ApiKeyProviderService { let provider = match provider { Some(p) if p.enabled => { eprintln!( - "[find_by_provider_id] 找到已启用的 provider: id={}, name={}, api_host={}", - p.id, p.name, p.api_host + "[find_by_provider_id] 找到已启用的 provider: id={}, name={}, api_host={}, type={:?}", + p.id, p.name, p.api_host, p.provider_type ); p } @@ -973,20 +1025,81 @@ impl ApiKeyProviderService { // 解密 API Key let api_key = self.encryption.decrypt(&selected_key.api_key_encrypted)?; - // 转换为 OpenAI 兼容的 ProviderCredential - // 大多数 60+ Provider 都使用 OpenAI 兼容协议 + // 根据 Provider 类型转换为对应的 ProviderCredential let credential = - self.convert_to_openai_compatible_credential(&provider, &selected_key.id, &api_key)?; + self.convert_provider_to_credential(&provider, &selected_key.id, &api_key)?; tracing::info!( - "[智能降级] 成功通过 provider_id 找到凭证: {} (key: {})", + "[智能降级] 成功通过 provider_id 找到凭证: {} (key: {}, type: {:?})", provider.name, - selected_key.alias.as_deref().unwrap_or(&selected_key.id) + selected_key.alias.as_deref().unwrap_or(&selected_key.id), + provider.provider_type ); Ok(Some(credential)) } + /// 根据 Provider 类型转换为对应的 ProviderCredential + fn convert_provider_to_credential( + &self, + provider: &ApiKeyProvider, + key_id: &str, + api_key: &str, + ) -> Result { + let (credential_data, pool_type) = match provider.provider_type { + ApiProviderType::Anthropic => { + // Anthropic 类型使用 ClaudeKey + let data = CredentialData::ClaudeKey { + api_key: api_key.to_string(), + base_url: Some(provider.api_host.clone()), + }; + (data, PoolProviderType::Claude) + } + ApiProviderType::Gemini => { + // Gemini 类型使用 GeminiApiKey + let data = CredentialData::GeminiApiKey { + api_key: api_key.to_string(), + base_url: Some(provider.api_host.clone()), + excluded_models: Vec::new(), + }; + (data, PoolProviderType::GeminiApiKey) + } + _ => { + // 其他类型(OpenAI 兼容)使用 OpenAIKey + let data = CredentialData::OpenAIKey { + api_key: api_key.to_string(), + base_url: Some(provider.api_host.clone()), + }; + (data, PoolProviderType::OpenAI) + } + }; + + let now = chrono::Utc::now(); + Ok(ProviderCredential { + uuid: format!("fallback-{}", key_id), + provider_type: pool_type, + credential: credential_data, + name: Some(format!("[降级] {}", provider.name)), + is_healthy: true, + is_disabled: false, + check_health: false, + check_model_name: None, + not_supported_models: Vec::new(), + usage_count: 0, + error_count: 0, + last_used: None, + last_error_time: None, + last_error_message: None, + last_health_check_time: None, + last_health_check_model: None, + created_at: now, + updated_at: now, + cached_token: None, + source: CredentialSource::Imported, + proxy_url: None, + }) + } + /// 转换为 ProviderCredential fn convert_to_provider_credential( &self, @@ -1043,46 +1156,6 @@ impl ApiKeyProviderService { proxy_url: None, }) } - - /// 转换为 OpenAI 兼容的 ProviderCredential - /// - /// 用于 DeepSeek、Moonshot、智谱 等 60+ Provider - fn convert_to_openai_compatible_credential( - &self, - provider: &ApiKeyProvider, - key_id: &str, - api_key: &str, - ) -> Result { - let credential_data = CredentialData::OpenAIKey { - api_key: api_key.to_string(), - base_url: Some(provider.api_host.clone()), // 关键:使用 Provider 的 api_host - }; - - let now = chrono::Utc::now(); - Ok(ProviderCredential { - uuid: format!("fallback-{}", key_id), - provider_type: PoolProviderType::OpenAI, // 统一使用 OpenAI 类型 - credential: credential_data, - name: Some(format!("[降级] {}", provider.name)), - is_healthy: true, - is_disabled: false, - check_health: false, // 降级凭证不参与健康检查 - check_model_name: None, - not_supported_models: Vec::new(), - usage_count: 0, - error_count: 0, - last_used: None, - last_error_time: None, - last_error_message: None, - last_health_check_time: None, - last_health_check_model: None, - created_at: now, - updated_at: now, - cached_token: None, - source: CredentialSource::Imported, // 标记为导入来源 - proxy_url: None, - }) - } } /// 导入结果 diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c2e5a9a63..376add6d3 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.37.0", + "version": "0.38.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src-tauri/tests/api_key_provider_tests.rs b/src-tauri/tests/api_key_provider_tests.rs index fb8d17785..dda2ca3dc 100644 --- a/src-tauri/tests/api_key_provider_tests.rs +++ b/src-tauri/tests/api_key_provider_tests.rs @@ -775,6 +775,40 @@ mod unit_tests { assert!(deleted); } + /// 单元测试:重复 API Key 检测 + /// 验证修复:第一次添加 API Key 无法保存的问题 + #[test] + fn test_duplicate_api_key_detection() { + let ctx = TestContext::new().expect("Failed to create test context"); + + // 创建 Provider + let provider_id = "test-provider-duplicate"; + ctx.create_test_provider(provider_id) + .expect("Failed to create provider"); + + // 第一次添加 API Key 应该成功 + let api_key = "sk-duplicate-test-123"; + let first_result = ctx.add_test_api_key(provider_id, api_key); + assert!(first_result.is_ok(), "第一次添加应该成功"); + + // 第二次添加相同的 API Key 应该失败 + let second_result = ctx.add_test_api_key(provider_id, api_key); + assert!(second_result.is_err(), "第二次添加相同 API Key 应该失败"); + assert!( + second_result.unwrap_err().contains("该 API Key 已存在"), + "错误信息应该提示 API Key 已存在" + ); + + // 验证 Provider 中只有一个 API Key + let provider = ctx + .service + .get_provider(&ctx.db, provider_id) + .expect("Failed to get provider") + .expect("Provider not found"); + + assert_eq!(provider.api_keys.len(), 1, "应该只有一个 API Key"); + } + /// 单元测试:系统 Provider 不能删除 #[test] fn test_system_provider_cannot_be_deleted() { diff --git a/src/components/provider-pool/ProviderPoolPage.tsx b/src/components/provider-pool/ProviderPoolPage.tsx index f494a0f5a..df232b4e3 100644 --- a/src/components/provider-pool/ProviderPoolPage.tsx +++ b/src/components/provider-pool/ProviderPoolPage.tsx @@ -13,6 +13,7 @@ import { forwardRef, useImperativeHandle, useCallback, + useRef, } from "react"; import { RefreshCw, @@ -34,6 +35,7 @@ import { ConfirmDialog } from "@/components/ConfirmDialog"; import { getConfig } from "@/hooks/useTauri"; import { ProviderIcon } from "@/icons/providers"; import { ApiKeyProviderSection, AddCustomProviderModal } from "./api-key"; +import type { ApiKeyProviderSectionRef } from "./api-key"; import { OAuthPluginTab } from "./OAuthPluginTab"; import { RelayProvidersSection } from "./RelayProvidersSection"; import { ModelRegistryTab } from "./ModelRegistryTab"; @@ -105,6 +107,9 @@ export const ProviderPoolPage = forwardRef( const [addCustomProviderModalOpen, setAddCustomProviderModalOpen] = useState(false); + // ApiKeyProviderSection 的 ref + const apiKeyProviderSectionRef = useRef(null); + const { overview, loading, @@ -124,8 +129,11 @@ export const ProviderPoolPage = forwardRef( } = useProviderPool(); // API Key Provider Hook - const { addCustomProvider, refresh: refreshApiKeyProviders } = - useApiKeyProvider(); + const { + addCustomProvider, + addApiKey, + refresh: refreshApiKeyProviders, + } = useApiKeyProvider(); const [migrating, setMigrating] = useState(false); @@ -313,11 +321,22 @@ export const ProviderPoolPage = forwardRef( // 添加自定义 Provider 处理 const handleAddCustomProvider = useCallback( async (request: AddCustomProviderRequest) => { - await addCustomProvider(request); + const result = await addCustomProvider(request); + return result; // 返回包含 id 的结果 }, [addCustomProvider], ); + // 添加 API Key 处理 + const handleAddApiKey = useCallback( + async (providerId: string, apiKey: string) => { + await addApiKey(providerId, apiKey); + // 刷新 ApiKeyProviderSection 的数据 + await apiKeyProviderSectionRef.current?.refresh(); + }, + [addApiKey], + ); + // Current tab data (仅用于 OAuth 凭证 tab) const currentPool = !isConfigTab(activeTab) && activeCategory === "oauth" @@ -388,7 +407,7 @@ export const ProviderPoolPage = forwardRef( onClick={() => { setActiveCategory("plugins"); }} - className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${ + className={`relative px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${ activeCategory === "plugins" ? "border-primary bg-primary/10 text-primary" : "border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted" @@ -396,6 +415,9 @@ export const ProviderPoolPage = forwardRef( data-testid="plugins-category-tab" > OAuth 插件 + + 实验 +