diff --git a/README.md b/README.md index 17c7b7fe..95a47a55 100644 --- a/README.md +++ b/README.md @@ -411,11 +411,10 @@ export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥" **Gemini CLI 设置环境变量:** ```bash -export CODE_ASSIST_ENDPOINT="http://127.0.0.1:3000/gemini" # 根据实际填写你服务器的ip地址或者域名 -export GOOGLE_CLOUD_ACCESS_TOKEN="后台创建的API密钥" # 使用相同的API密钥即可 -export GOOGLE_GENAI_USE_GCA="true" +GEMINI_MODEL="gemini-2.5-pro" +GOOGLE_GEMINI_BASE_URL="http://127.0.0.1:3000/gemini" # 根据实际填写你服务器的ip地址或者域名 +GEMINI_API_KEY="后台创建的API密钥" # 使用相同的API密钥即可 ``` - **使用 Claude Code:** ```bash diff --git a/VERSION b/VERSION index ae88434e..01793f68 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.173 +1.1.175 diff --git a/src/middleware/auth.js b/src/middleware/auth.js index b18b5322..378f1565 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -65,6 +65,44 @@ const TOKEN_COUNT_PATHS = new Set([ '/droid/claude/v1/messages/count_tokens' ]) +function extractApiKey(req) { + const candidates = [ + req.headers['x-api-key'], + req.headers['x-goog-api-key'], + req.headers['authorization'], + req.headers['api-key'], + req.query?.key + ] + + for (const candidate of candidates) { + let value = candidate + + if (Array.isArray(value)) { + value = value.find((item) => typeof item === 'string' && item.trim()) + } + + if (typeof value !== 'string') { + continue + } + + let trimmed = value.trim() + if (!trimmed) { + continue + } + + if (/^Bearer\s+/i.test(trimmed)) { + trimmed = trimmed.replace(/^Bearer\s+/i, '').trim() + if (!trimmed) { + continue + } + } + + return trimmed + } + + return '' +} + function normalizeRequestPath(value) { if (!value) { return '/' @@ -95,18 +133,18 @@ const authenticateApiKey = async (req, res, next) => { try { // 安全提取API Key,支持多种格式(包括Gemini CLI支持) - const apiKey = - req.headers['x-api-key'] || - req.headers['x-goog-api-key'] || - req.headers['authorization']?.replace(/^Bearer\s+/i, '') || - req.headers['api-key'] || - req.query.key + const apiKey = extractApiKey(req) + + if (apiKey) { + req.headers['x-api-key'] = apiKey + } if (!apiKey) { logger.security(`🔒 Missing API key attempt from ${req.ip || 'unknown'}`) return res.status(401).json({ error: 'Missing API key', - message: 'Please provide an API key in the x-api-key header or Authorization header' + message: + 'Please provide an API key in the x-api-key, x-goog-api-key, or Authorization header' }) } @@ -950,6 +988,7 @@ const corsMiddleware = (req, res, next) => { 'Accept', 'Authorization', 'x-api-key', + 'x-goog-api-key', 'api-key', 'x-admin-token', 'anthropic-version', diff --git a/src/middleware/browserFallback.js b/src/middleware/browserFallback.js index df81ae38..ed82532c 100644 --- a/src/middleware/browserFallback.js +++ b/src/middleware/browserFallback.js @@ -7,12 +7,38 @@ const logger = require('../utils/logger') const browserFallbackMiddleware = (req, res, next) => { const userAgent = req.headers['user-agent'] || '' const origin = req.headers['origin'] || '' - const authHeader = req.headers['authorization'] || req.headers['x-api-key'] || '' + + const extractHeader = (value) => { + let candidate = value + + if (Array.isArray(candidate)) { + candidate = candidate.find((item) => typeof item === 'string' && item.trim()) + } + + if (typeof candidate !== 'string') { + return '' + } + + let trimmed = candidate.trim() + if (!trimmed) { + return '' + } + + if (/^Bearer\s+/i.test(trimmed)) { + trimmed = trimmed.replace(/^Bearer\s+/i, '').trim() + } + + return trimmed + } + + const apiKeyHeader = + extractHeader(req.headers['x-api-key']) || extractHeader(req.headers['x-goog-api-key']) + const normalizedKey = extractHeader(req.headers['authorization']) || apiKeyHeader // 检查是否为Chrome插件或浏览器请求 const isChromeExtension = origin.startsWith('chrome-extension://') const isBrowserRequest = userAgent.includes('Mozilla/') && userAgent.includes('Chrome/') - const hasApiKey = authHeader.startsWith('cr_') // 我们的API Key格式 + const hasApiKey = normalizedKey.startsWith('cr_') // 我们的API Key格式 if ((isChromeExtension || isBrowserRequest) && hasApiKey) { // 为Chrome插件请求添加特殊标记 @@ -23,8 +49,8 @@ const browserFallbackMiddleware = (req, res, next) => { req.headers['user-agent'] = 'claude-cli/1.0.110 (external, cli, browser-fallback)' // 确保设置正确的认证头 - if (!req.headers['authorization'] && req.headers['x-api-key']) { - req.headers['authorization'] = `Bearer ${req.headers['x-api-key']}` + if (!req.headers['authorization'] && apiKeyHeader) { + req.headers['authorization'] = `Bearer ${apiKeyHeader}` } // 添加必要的Anthropic头 diff --git a/src/routes/admin.js b/src/routes/admin.js index 476226ae..e1ff4804 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -8938,6 +8938,109 @@ router.put('/droid-accounts/:id/toggle-schedulable', authenticateAdmin, async (r } }) +// 获取单个 Droid 账户详细信息 +router.get('/droid-accounts/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + // 获取账户基本信息 + const account = await droidAccountService.getAccount(id) + if (!account) { + return res.status(404).json({ + error: 'Not Found', + message: 'Droid account not found' + }) + } + + // 获取使用统计信息 + let usageStats + try { + usageStats = await redis.getAccountUsageStats(account.id, 'droid') + } catch (error) { + logger.debug(`Failed to get usage stats for Droid account ${account.id}:`, error) + usageStats = { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + + // 获取分组信息 + let groupInfos = [] + try { + groupInfos = await accountGroupService.getAccountGroups(account.id) + } catch (error) { + logger.debug(`Failed to get group infos for Droid account ${account.id}:`, error) + groupInfos = [] + } + + // 获取绑定的 API Key 数量 + const allApiKeys = await redis.getAllApiKeys() + const groupIds = groupInfos.map((group) => group.id) + const boundApiKeysCount = allApiKeys.reduce((count, key) => { + const binding = key.droidAccountId + if (!binding) { + return count + } + if (binding === account.id) { + return count + 1 + } + if (binding.startsWith('group:')) { + const groupId = binding.substring('group:'.length) + if (groupIds.includes(groupId)) { + return count + 1 + } + } + return count + }, 0) + + // 获取解密的 API Keys(用于管理界面) + let decryptedApiKeys = [] + try { + decryptedApiKeys = await droidAccountService.getDecryptedApiKeyEntries(id) + } catch (error) { + logger.debug(`Failed to get decrypted API keys for Droid account ${account.id}:`, error) + decryptedApiKeys = [] + } + + // 返回完整的账户信息,包含实际的 API Keys + const accountDetails = { + ...account, + // 映射字段:使用 subscriptionExpiresAt 作为前端显示的 expiresAt + expiresAt: account.subscriptionExpiresAt || null, + schedulable: account.schedulable === 'true', + boundApiKeysCount, + groupInfos, + // 包含实际的 API Keys(用于管理界面) + apiKeys: decryptedApiKeys.map((entry) => ({ + key: entry.key, + id: entry.id, + usageCount: entry.usageCount || 0, + lastUsedAt: entry.lastUsedAt || null, + status: entry.status || 'active', // 使用实际的状态,默认为 active + errorMessage: entry.errorMessage || '', // 包含错误信息 + createdAt: entry.createdAt || null + })), + usage: { + daily: usageStats.daily, + total: usageStats.total, + averages: usageStats.averages + } + } + + return res.json({ + success: true, + data: accountDetails + }) + } catch (error) { + logger.error(`Failed to get Droid account ${req.params.id}:`, error) + return res.status(500).json({ + error: 'Failed to get Droid account', + message: error.message + }) + } +}) + // 删除 Droid 账户 router.delete('/droid-accounts/:id', authenticateAdmin, async (req, res) => { try { diff --git a/src/routes/geminiRoutes.js b/src/routes/geminiRoutes.js index 532979cf..8ece60fd 100644 --- a/src/routes/geminiRoutes.js +++ b/src/routes/geminiRoutes.js @@ -13,13 +13,10 @@ const { updateRateLimitCounters } = require('../utils/rateLimitHelper') // 生成会话哈希 function generateSessionHash(req) { - const sessionData = [ - req.headers['user-agent'], - req.ip, - req.headers['x-api-key']?.substring(0, 10) - ] - .filter(Boolean) - .join(':') + const apiKeyPrefix = + req.headers['x-api-key']?.substring(0, 10) || req.headers['x-goog-api-key']?.substring(0, 10) + + const sessionData = [req.headers['user-agent'], req.ip, apiKeyPrefix].filter(Boolean).join(':') return crypto.createHash('sha256').update(sessionData).digest('hex') } diff --git a/src/routes/openaiGeminiRoutes.js b/src/routes/openaiGeminiRoutes.js index 54305401..a718aad2 100644 --- a/src/routes/openaiGeminiRoutes.js +++ b/src/routes/openaiGeminiRoutes.js @@ -9,11 +9,10 @@ const crypto = require('crypto') // 生成会话哈希 function generateSessionHash(req) { - const sessionData = [ - req.headers['user-agent'], - req.ip, - req.headers['authorization']?.substring(0, 20) - ] + const authSource = + req.headers['authorization'] || req.headers['x-api-key'] || req.headers['x-goog-api-key'] + + const sessionData = [req.headers['user-agent'], req.ip, authSource?.substring(0, 20)] .filter(Boolean) .join(':') diff --git a/src/services/droidAccountService.js b/src/services/droidAccountService.js index 5475c5af..a27ac935 100644 --- a/src/services/droidAccountService.js +++ b/src/services/droidAccountService.js @@ -183,7 +183,10 @@ class DroidAccountService { ? [] : normalizedExisting .filter((entry) => entry && entry.id && entry.encryptedKey) - .map((entry) => ({ ...entry })) + .map((entry) => ({ + ...entry, + status: entry.status || 'active' // 确保有默认状态 + })) const hashSet = new Set(entries.map((entry) => entry.hash).filter(Boolean)) @@ -214,7 +217,9 @@ class DroidAccountService { encryptedKey: this._encryptSensitiveData(trimmed), createdAt: now, lastUsedAt: '', - usageCount: '0' + usageCount: '0', + status: 'active', // 新增状态字段 + errorMessage: '' // 新增错误信息字段 }) } @@ -230,7 +235,9 @@ class DroidAccountService { id: entry.id, createdAt: entry.createdAt || '', lastUsedAt: entry.lastUsedAt || '', - usageCount: entry.usageCount || '0' + usageCount: entry.usageCount || '0', + status: entry.status || 'active', // 新增状态字段 + errorMessage: entry.errorMessage || '' // 新增错误信息字段 })) } @@ -252,7 +259,9 @@ class DroidAccountService { hash: entry.hash || '', createdAt: entry.createdAt || '', lastUsedAt: entry.lastUsedAt || '', - usageCount: Number.isFinite(usageCountNumber) && usageCountNumber >= 0 ? usageCountNumber : 0 + usageCount: Number.isFinite(usageCountNumber) && usageCountNumber >= 0 ? usageCountNumber : 0, + status: entry.status || 'active', // 新增状态字段 + errorMessage: entry.errorMessage || '' // 新增错误信息字段 } } @@ -348,6 +357,56 @@ class DroidAccountService { } } + /** + * 标记指定的 Droid API Key 条目为异常状态 + */ + async markApiKeyAsError(accountId, keyId, errorMessage = '') { + if (!accountId || !keyId) { + return { marked: false, error: '参数无效' } + } + + try { + const accountData = await redis.getDroidAccount(accountId) + if (!accountData) { + return { marked: false, error: '账户不存在' } + } + + const entries = this._parseApiKeyEntries(accountData.apiKeys) + if (!entries || entries.length === 0) { + return { marked: false, error: '无API Key条目' } + } + + let marked = false + const updatedEntries = entries.map((entry) => { + if (entry && entry.id === keyId) { + marked = true + return { + ...entry, + status: 'error', + errorMessage: errorMessage || 'API Key异常' + } + } + return entry + }) + + if (!marked) { + return { marked: false, error: '未找到指定的API Key' } + } + + accountData.apiKeys = JSON.stringify(updatedEntries) + await redis.setDroidAccount(accountId, accountData) + + logger.warn( + `⚠️ 已标记 Droid API Key ${keyId} 为异常状态(Account: ${accountId}):${errorMessage}` + ) + + return { marked: true } + } catch (error) { + logger.error(`❌ 标记 Droid API Key 异常状态失败:${keyId}(Account: ${accountId})`, error) + return { marked: false, error: error.message } + } + } + /** * 使用 WorkOS Refresh Token 刷新并验证凭证 */ @@ -994,7 +1053,7 @@ class DroidAccountService { ? updates.apiKeyUpdateMode.trim().toLowerCase() : '' - let apiKeyUpdateMode = ['append', 'replace', 'delete'].includes(rawApiKeyMode) + let apiKeyUpdateMode = ['append', 'replace', 'delete', 'update'].includes(rawApiKeyMode) ? rawApiKeyMode : '' @@ -1056,6 +1115,60 @@ class DroidAccountService { } else if (removeApiKeysInput.length > 0) { logger.warn(`⚠️ 删除模式未收到有效的 Droid API Key: ${accountId}`) } + } else if (apiKeyUpdateMode === 'update') { + // 更新模式:根据提供的 key 匹配现有条目并更新状态 + mergedApiKeys = [...existingApiKeyEntries] + const updatedHashes = new Set() + + for (const updateItem of newApiKeysInput) { + if (!updateItem || typeof updateItem !== 'object') { + continue + } + + const key = updateItem.key || updateItem.apiKey || '' + if (!key || typeof key !== 'string') { + continue + } + + const trimmed = key.trim() + if (!trimmed) { + continue + } + + const hash = crypto.createHash('sha256').update(trimmed).digest('hex') + updatedHashes.add(hash) + + // 查找现有条目 + const existingIndex = mergedApiKeys.findIndex((entry) => entry && entry.hash === hash) + + if (existingIndex !== -1) { + // 更新现有条目的状态信息 + const existingEntry = mergedApiKeys[existingIndex] + mergedApiKeys[existingIndex] = { + ...existingEntry, + status: updateItem.status || existingEntry.status || 'active', + errorMessage: + updateItem.errorMessage !== undefined + ? updateItem.errorMessage + : existingEntry.errorMessage || '', + lastUsedAt: + updateItem.lastUsedAt !== undefined + ? updateItem.lastUsedAt + : existingEntry.lastUsedAt || '', + usageCount: + updateItem.usageCount !== undefined + ? String(updateItem.usageCount) + : existingEntry.usageCount || '0' + } + apiKeysUpdated = true + } + } + + if (!apiKeysUpdated) { + logger.warn( + `⚠️ 更新模式未匹配任何 Droid API Key: ${accountId} (提供 ${updatedHashes.size} 个哈希)` + ) + } } else { const clearExisting = apiKeyUpdateMode === 'replace' || wantsClearApiKeys const baselineCount = clearExisting ? 0 : existingApiKeyEntries.length @@ -1078,6 +1191,10 @@ class DroidAccountService { logger.info( `🔑 删除模式更新 Droid API keys for ${accountId}: 已移除 ${removedCount} 条,剩余 ${mergedApiKeys.length}` ) + } else if (apiKeyUpdateMode === 'update') { + logger.info( + `🔑 更新模式更新 Droid API keys for ${accountId}: 更新了 ${newApiKeysInput.length} 个 API Key 的状态信息` + ) } else if (apiKeyUpdateMode === 'replace' || wantsClearApiKeys) { logger.info( `🔑 覆盖模式更新 Droid API keys for ${accountId}: 当前总数 ${mergedApiKeys.length},新增 ${addedCount}` diff --git a/src/services/droidRelayService.js b/src/services/droidRelayService.js index 1072b869..38cd9a6b 100644 --- a/src/services/droidRelayService.js +++ b/src/services/droidRelayService.js @@ -121,12 +121,18 @@ class DroidRelayService { throw new Error(`Droid account ${account.id} 未配置任何 API Key`) } + // 过滤掉异常状态的API Key + const activeEntries = entries.filter((entry) => entry.status !== 'error') + if (!activeEntries || activeEntries.length === 0) { + throw new Error(`Droid account ${account.id} 没有可用的 API Key(所有API Key均已异常)`) + } + const stickyKey = this._composeApiKeyStickyKey(account.id, endpointType, sessionHash) if (stickyKey) { const mappedKeyId = await redis.getSessionAccountMapping(stickyKey) if (mappedKeyId) { - const mappedEntry = entries.find((entry) => entry.id === mappedKeyId) + const mappedEntry = activeEntries.find((entry) => entry.id === mappedKeyId) if (mappedEntry) { await redis.extendSessionAccountMappingTTL(stickyKey) await droidAccountService.touchApiKeyUsage(account.id, mappedEntry.id) @@ -138,7 +144,7 @@ class DroidRelayService { } } - const selectedEntry = entries[Math.floor(Math.random() * entries.length)] + const selectedEntry = activeEntries[Math.floor(Math.random() * activeEntries.length)] if (!selectedEntry) { throw new Error(`Droid account ${account.id} 没有可用的 API Key`) } @@ -150,7 +156,7 @@ class DroidRelayService { await droidAccountService.touchApiKeyUsage(account.id, selectedEntry.id) logger.info( - `🔐 随机选取 Droid API Key ${selectedEntry.id}(Account: ${account.id}, Keys: ${entries.length})` + `🔐 随机选取 Droid API Key ${selectedEntry.id}(Account: ${account.id}, Active Keys: ${activeEntries.length}/${entries.length})` ) return selectedEntry @@ -1144,39 +1150,50 @@ class DroidRelayService { if (authMethod === 'api_key') { if (selectedAccountApiKey?.id) { - let removalResult = null + let markResult = null + const errorMessage = `${statusCode}` try { - removalResult = await droidAccountService.removeApiKeyEntry( + // 标记API Key为异常状态而不是删除 + markResult = await droidAccountService.markApiKeyAsError( accountId, - selectedAccountApiKey.id + selectedAccountApiKey.id, + errorMessage ) } catch (error) { logger.error( - `❌ 移除 Droid API Key ${selectedAccountApiKey.id}(Account: ${accountId})失败:`, + `❌ 标记 Droid API Key ${selectedAccountApiKey.id} 异常状态(Account: ${accountId})失败:`, error ) } await this._clearApiKeyStickyMapping(accountId, normalizedEndpoint, sessionHash) - if (removalResult?.removed) { + if (markResult?.marked) { logger.warn( - `🚫 上游返回 ${statusCode},已移除 Droid API Key ${selectedAccountApiKey.id}(Account: ${accountId})` + `⚠️ 上游返回 ${statusCode},已标记 Droid API Key ${selectedAccountApiKey.id} 为异常状态(Account: ${accountId})` ) } else { logger.warn( - `⚠️ 上游返回 ${statusCode},但未能移除 Droid API Key ${selectedAccountApiKey.id}(Account: ${accountId})` + `⚠️ 上游返回 ${statusCode},但未能标记 Droid API Key ${selectedAccountApiKey.id} 异常状态(Account: ${accountId}):${markResult?.error || '未知错误'}` ) } - if (!removalResult || removalResult.remainingCount === 0) { - await this._stopDroidAccountScheduling(accountId, statusCode, 'API Key 已全部失效') + // 检查是否还有可用的API Key + try { + const availableEntries = await droidAccountService.getDecryptedApiKeyEntries(accountId) + const activeEntries = availableEntries.filter((entry) => entry.status !== 'error') + + if (activeEntries.length === 0) { + await this._stopDroidAccountScheduling(accountId, statusCode, '所有API Key均已异常') + await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId) + } else { + logger.info(`ℹ️ Droid 账号 ${accountId} 仍有 ${activeEntries.length} 个可用 API Key`) + } + } catch (error) { + logger.error(`❌ 检查可用API Key失败(Account: ${accountId}):`, error) + await this._stopDroidAccountScheduling(accountId, statusCode, 'API Key检查失败') await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId) - } else { - logger.info( - `ℹ️ Droid 账号 ${accountId} 仍有 ${removalResult.remainingCount} 个 API Key 可用` - ) } return diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 67ab4390..0bea28a8 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -1817,7 +1817,8 @@
当前已保存 {{ existingApiKeyCount }} 条 API Key。您可以追加新的
Key,或通过下方模式快速覆盖、删除指定 Key。
@@ -3187,6 +3196,15 @@
@close="showGroupManagement = false"
@refresh="handleGroupRefresh"
/>
+
+
+
@@ -431,16 +431,16 @@ >
@@ -1075,13 +1073,13 @@ >
@@ -1688,13 +1686,13 @@ >