diff --git a/README.md b/README.md index b3c37558..a5c8961b 100644 --- a/README.md +++ b/README.md @@ -480,7 +480,7 @@ Droid CLI 读取 `~/.factory/config.json`。可以在该文件中添加自定义 { "custom_models": [ { - "model_display_name": "Sonnet 4.5 [Custom]", + "model_display_name": "Sonnet 4.5 [crs]", "model": "claude-sonnet-4-5-20250929", "base_url": "http://127.0.0.1:3000/droid/claude", "api_key": "后台创建的API密钥", @@ -488,7 +488,7 @@ Droid CLI 读取 `~/.factory/config.json`。可以在该文件中添加自定义 "max_tokens": 8192 }, { - "model_display_name": "GPT5-Codex [Custom]", + "model_display_name": "GPT5-Codex [crs]", "model": "gpt-5-codex", "base_url": "http://127.0.0.1:3000/droid/openai", "api_key": "后台创建的API密钥", diff --git a/VERSION b/VERSION index 4cc87f7d..3e8f33bb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.165 +1.1.169 diff --git a/docs/images/add-claude-account.png b/docs/images/add-claude-account.png deleted file mode 100644 index 0dc89327..00000000 Binary files a/docs/images/add-claude-account.png and /dev/null differ diff --git a/docs/images/api-keys-list.png b/docs/images/api-keys-list.png deleted file mode 100644 index 3e18e920..00000000 Binary files a/docs/images/api-keys-list.png and /dev/null differ diff --git a/docs/images/claude-accounts-list.png b/docs/images/claude-accounts-list.png deleted file mode 100644 index 97f3a448..00000000 Binary files a/docs/images/claude-accounts-list.png and /dev/null differ diff --git a/docs/images/dashboard-overview.png b/docs/images/dashboard-overview.png deleted file mode 100644 index 4a9b9327..00000000 Binary files a/docs/images/dashboard-overview.png and /dev/null differ diff --git a/docs/images/tutorial.png b/docs/images/tutorial.png deleted file mode 100644 index 5da02086..00000000 Binary files a/docs/images/tutorial.png and /dev/null differ diff --git a/docs/preview.md b/docs/preview.md deleted file mode 100644 index 3f1ded55..00000000 --- a/docs/preview.md +++ /dev/null @@ -1,47 +0,0 @@ -# Claude Relay Service 界面预览 - -
- -**🎨 Web管理界面截图展示** - -
- ---- - -## 📊 管理面板概览 - - -### 仪表板 -![仪表板](./images/dashboard-overview.png) - -*实时显示API调用次数、Token使用量、成本统计等关键指标* - ---- - -## 🔑 API密钥管理 - -### API密钥列表 -![API密钥管理](./images/api-keys-list.png) - -*查看和管理所有创建的API密钥,包括使用量统计和状态信息* - ---- - -## 👤 Claude账户管理 - -### 账户列表 -![Claude账户列表](./images/claude-accounts-list.png) - -*管理多个Claude账户,查看账户状态和使用情况* - -### 添加新账户 -![添加Claude账户](./images/add-claude-account.png) - -*通过OAuth授权添加新的Claude账户* - -### 使用教程 -![使用教程](./images/tutorial.png) - -*windows、macos、linux、wsl不同环境的claude code安装教程* - ---- diff --git a/src/routes/admin.js b/src/routes/admin.js index bad940ba..f0a3035e 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -1745,31 +1745,54 @@ router.delete('/account-groups/:groupId', authenticateAdmin, async (req, res) => router.get('/account-groups/:groupId/members', authenticateAdmin, async (req, res) => { try { const { groupId } = req.params + const group = await accountGroupService.getGroup(groupId) + + if (!group) { + return res.status(404).json({ error: '分组不存在' }) + } + const memberIds = await accountGroupService.getGroupMembers(groupId) // 获取成员详细信息 const members = [] for (const memberId of memberIds) { - // 尝试从不同的服务获取账户信息 + // 根据分组平台优先查找对应账户 let account = null + switch (group.platform) { + case 'droid': + account = await droidAccountService.getAccount(memberId) + break + case 'gemini': + account = await geminiAccountService.getAccount(memberId) + break + case 'openai': + account = await openaiAccountService.getAccount(memberId) + break + case 'claude': + default: + account = await claudeAccountService.getAccount(memberId) + if (!account) { + account = await claudeConsoleAccountService.getAccount(memberId) + } + break + } - // 先尝试Claude OAuth账户 - account = await claudeAccountService.getAccount(memberId) - - // 如果找不到,尝试Claude Console账户 + // 兼容旧数据:若按平台未找到,则继续尝试其他平台 + if (!account) { + account = await claudeAccountService.getAccount(memberId) + } if (!account) { account = await claudeConsoleAccountService.getAccount(memberId) } - - // 如果还找不到,尝试Gemini账户 if (!account) { account = await geminiAccountService.getAccount(memberId) } - - // 如果还找不到,尝试OpenAI账户 if (!account) { account = await openaiAccountService.getAccount(memberId) } + if (!account && group.platform !== 'droid') { + account = await droidAccountService.getAccount(memberId) + } if (account) { members.push(account) @@ -8676,7 +8699,52 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => { // 创建 Droid 账户 router.post('/droid-accounts', authenticateAdmin, async (req, res) => { try { - const account = await droidAccountService.createAccount(req.body) + const { accountType: rawAccountType = 'shared', groupId, groupIds } = req.body + + const normalizedAccountType = rawAccountType || 'shared' + + if (!['shared', 'dedicated', 'group'].includes(normalizedAccountType)) { + return res.status(400).json({ error: '账户类型必须是 shared、dedicated 或 group' }) + } + + const normalizedGroupIds = Array.isArray(groupIds) + ? groupIds.filter((id) => typeof id === 'string' && id.trim()) + : [] + + if ( + normalizedAccountType === 'group' && + normalizedGroupIds.length === 0 && + (!groupId || typeof groupId !== 'string' || !groupId.trim()) + ) { + return res.status(400).json({ error: '分组调度账户必须至少选择一个分组' }) + } + + const accountPayload = { + ...req.body, + accountType: normalizedAccountType + } + + delete accountPayload.groupId + delete accountPayload.groupIds + + const account = await droidAccountService.createAccount(accountPayload) + + if (normalizedAccountType === 'group') { + try { + if (normalizedGroupIds.length > 0) { + await accountGroupService.setAccountGroups(account.id, normalizedGroupIds, 'droid') + } else if (typeof groupId === 'string' && groupId.trim()) { + await accountGroupService.addAccountToGroup(account.id, groupId, 'droid') + } + } catch (groupError) { + logger.error(`Failed to attach Droid account ${account.id} to groups:`, groupError) + return res.status(500).json({ + error: 'Failed to bind Droid account to groups', + message: groupError.message + }) + } + } + logger.success(`Created Droid account: ${account.name} (${account.id})`) return res.json({ success: true, data: account }) } catch (error) { @@ -8689,7 +8757,72 @@ router.post('/droid-accounts', authenticateAdmin, async (req, res) => { router.put('/droid-accounts/:id', authenticateAdmin, async (req, res) => { try { const { id } = req.params - const account = await droidAccountService.updateAccount(id, req.body) + const updates = { ...req.body } + const { accountType: rawAccountType, groupId, groupIds } = updates + + if (rawAccountType && !['shared', 'dedicated', 'group'].includes(rawAccountType)) { + return res.status(400).json({ error: '账户类型必须是 shared、dedicated 或 group' }) + } + + if ( + rawAccountType === 'group' && + (!groupId || typeof groupId !== 'string' || !groupId.trim()) && + (!Array.isArray(groupIds) || groupIds.length === 0) + ) { + return res.status(400).json({ error: '分组调度账户必须至少选择一个分组' }) + } + + const currentAccount = await droidAccountService.getAccount(id) + if (!currentAccount) { + return res.status(404).json({ error: 'Droid account not found' }) + } + + const normalizedGroupIds = Array.isArray(groupIds) + ? groupIds.filter((gid) => typeof gid === 'string' && gid.trim()) + : [] + const hasGroupIdsField = Object.prototype.hasOwnProperty.call(updates, 'groupIds') + const hasGroupIdField = Object.prototype.hasOwnProperty.call(updates, 'groupId') + const targetAccountType = rawAccountType || currentAccount.accountType || 'shared' + + delete updates.groupId + delete updates.groupIds + + if (rawAccountType) { + updates.accountType = targetAccountType + } + + const account = await droidAccountService.updateAccount(id, updates) + + try { + if (currentAccount.accountType === 'group' && targetAccountType !== 'group') { + await accountGroupService.removeAccountFromAllGroups(id) + } else if (targetAccountType === 'group') { + if (hasGroupIdsField) { + if (normalizedGroupIds.length > 0) { + await accountGroupService.setAccountGroups(id, normalizedGroupIds, 'droid') + } else { + await accountGroupService.removeAccountFromAllGroups(id) + } + } else if (hasGroupIdField && typeof groupId === 'string' && groupId.trim()) { + await accountGroupService.setAccountGroups(id, [groupId], 'droid') + } + } + } catch (groupError) { + logger.error(`Failed to update Droid account ${id} groups:`, groupError) + return res.status(500).json({ + error: 'Failed to update Droid account groups', + message: groupError.message + }) + } + + if (targetAccountType === 'group') { + try { + account.groupInfos = await accountGroupService.getAccountGroups(id) + } catch (groupFetchError) { + logger.debug(`Failed to fetch group infos for Droid account ${id}:`, groupFetchError) + } + } + return res.json({ success: true, data: account }) } catch (error) { logger.error(`Failed to update Droid account ${req.params.id}:`, error) @@ -8697,6 +8830,53 @@ router.put('/droid-accounts/:id', authenticateAdmin, async (req, res) => { } }) +// 切换 Droid 账户调度状态 +router.put('/droid-accounts/:id/toggle-schedulable', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + const account = await droidAccountService.getAccount(id) + if (!account) { + return res.status(404).json({ error: 'Droid account not found' }) + } + + const currentSchedulable = account.schedulable === true || account.schedulable === 'true' + const newSchedulable = !currentSchedulable + + await droidAccountService.updateAccount(id, { schedulable: newSchedulable ? 'true' : 'false' }) + + const updatedAccount = await droidAccountService.getAccount(id) + const actualSchedulable = updatedAccount + ? updatedAccount.schedulable === true || updatedAccount.schedulable === 'true' + : newSchedulable + + if (!actualSchedulable) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: account.id, + accountName: account.name || 'Droid Account', + platform: 'droid', + status: 'disabled', + errorCode: 'DROID_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + + logger.success( + `🔄 Admin toggled Droid account schedulable status: ${id} -> ${ + actualSchedulable ? 'schedulable' : 'not schedulable' + }` + ) + + return res.json({ success: true, schedulable: actualSchedulable }) + } catch (error) { + logger.error('❌ Failed to toggle Droid account schedulable status:', error) + return res + .status(500) + .json({ error: 'Failed to toggle schedulable status', message: error.message }) + } +}) + // 删除 Droid 账户 router.delete('/droid-accounts/:id', authenticateAdmin, async (req, res) => { try { diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index 8d8909b8..389ae26f 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -38,9 +38,60 @@ class ClaudeRelayService { return `此专属账号的Opus模型已达到周使用限制,将于 ${formattedReset} 自动恢复,请尝试切换其他模型后再试。` } + // 🧾 提取错误消息文本 + _extractErrorMessage(body) { + if (!body) { + return '' + } + + if (typeof body === 'string') { + const trimmed = body.trim() + if (!trimmed) { + return '' + } + try { + const parsed = JSON.parse(trimmed) + return this._extractErrorMessage(parsed) + } catch (error) { + return trimmed + } + } + + if (typeof body === 'object') { + if (typeof body.error === 'string') { + return body.error + } + if (body.error && typeof body.error === 'object') { + if (typeof body.error.message === 'string') { + return body.error.message + } + if (typeof body.error.error === 'string') { + return body.error.error + } + } + if (typeof body.message === 'string') { + return body.message + } + } + + return '' + } + + // 🚫 检查是否为组织被禁用错误 + _isOrganizationDisabledError(statusCode, body) { + if (statusCode !== 400) { + return false + } + const message = this._extractErrorMessage(body) + if (!message) { + return false + } + return message.toLowerCase().includes('this organization has been disabled') + } + // 🔍 判断是否是真实的 Claude Code 请求 isRealClaudeCodeRequest(requestBody) { - return ClaudeCodeValidator.hasClaudeCodeSystemPrompt(requestBody) + return ClaudeCodeValidator.includesClaudeCodeSystemPrompt(requestBody, 1) } // 🚀 转发请求到Claude API @@ -189,6 +240,10 @@ class ClaudeRelayService { let isRateLimited = false let rateLimitResetTimestamp = null let dedicatedRateLimitMessage = null + const organizationDisabledError = this._isOrganizationDisabledError( + response.statusCode, + response.body + ) // 检查是否为401状态码(未授权) if (response.statusCode === 401) { @@ -221,6 +276,13 @@ class ClaudeRelayService { ) await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash) } + // 检查是否返回组织被禁用错误(400状态码) + else if (organizationDisabledError) { + logger.error( + `🚫 Organization disabled error (400) detected for account ${accountId}, marking as blocked` + ) + await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash) + } // 检查是否为529状态码(服务过载) else if (response.statusCode === 529) { logger.warn(`🚫 Overload error (529) detected for account ${accountId}`) @@ -499,6 +561,8 @@ class ClaudeRelayService { } } + this._enforceCacheControlLimit(processedBody) + // 处理原有的系统提示(如果配置了) if (this.systemPrompt && this.systemPrompt.trim()) { const systemPrompt = { @@ -645,6 +709,107 @@ class ClaudeRelayService { } } + // ⚖️ 限制带缓存控制的内容数量 + _enforceCacheControlLimit(body) { + const MAX_CACHE_CONTROL_BLOCKS = 4 + + if (!body || typeof body !== 'object') { + return + } + + const countCacheControlBlocks = () => { + let total = 0 + + if (Array.isArray(body.messages)) { + body.messages.forEach((message) => { + if (!message || !Array.isArray(message.content)) { + return + } + message.content.forEach((item) => { + if (item && item.cache_control) { + total += 1 + } + }) + }) + } + + if (Array.isArray(body.system)) { + body.system.forEach((item) => { + if (item && item.cache_control) { + total += 1 + } + }) + } + + return total + } + + const removeFromMessages = () => { + if (!Array.isArray(body.messages)) { + return false + } + + for (let messageIndex = 0; messageIndex < body.messages.length; messageIndex += 1) { + const message = body.messages[messageIndex] + if (!message || !Array.isArray(message.content)) { + continue + } + + for (let contentIndex = 0; contentIndex < message.content.length; contentIndex += 1) { + const contentItem = message.content[contentIndex] + if (contentItem && contentItem.cache_control) { + message.content.splice(contentIndex, 1) + + if (message.content.length === 0) { + body.messages.splice(messageIndex, 1) + } + + return true + } + } + } + + return false + } + + const removeFromSystem = () => { + if (!Array.isArray(body.system)) { + return false + } + + for (let index = 0; index < body.system.length; index += 1) { + const systemItem = body.system[index] + if (systemItem && systemItem.cache_control) { + body.system.splice(index, 1) + + if (body.system.length === 0) { + delete body.system + } + + return true + } + } + + return false + } + + let total = countCacheControlBlocks() + + while (total > MAX_CACHE_CONTROL_BLOCKS) { + if (removeFromMessages()) { + total -= 1 + continue + } + + if (removeFromSystem()) { + total -= 1 + continue + } + + break + } + } + // 🌐 获取代理Agent(使用统一的代理工具) async _getProxyAgent(accountId) { try { @@ -1253,6 +1418,25 @@ class ClaudeRelayService { `❌ Claude API error response (Account: ${account?.name || accountId}):`, errorData ) + if (this._isOrganizationDisabledError(res.statusCode, errorData)) { + ;(async () => { + try { + logger.error( + `🚫 [Stream] Organization disabled error (400) detected for account ${accountId}, marking as blocked` + ) + await unifiedClaudeScheduler.markAccountBlocked( + accountId, + accountType, + sessionHash + ) + } catch (markError) { + logger.error( + `❌ [Stream] Failed to mark account ${accountId} as blocked after organization disabled error:`, + markError + ) + } + })() + } if (!responseStream.destroyed) { // 发送错误事件 responseStream.write('event: error\n') diff --git a/src/services/droidAccountService.js b/src/services/droidAccountService.js index b79fd3bf..c6baecbf 100644 --- a/src/services/droidAccountService.js +++ b/src/services/droidAccountService.js @@ -65,6 +65,26 @@ class DroidAccountService { return 'anthropic' } + _isTruthy(value) { + if (value === undefined || value === null) { + return false + } + if (typeof value === 'boolean') { + return value + } + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase() + if (normalized === 'true') { + return true + } + if (normalized === 'false') { + return false + } + return normalized.length > 0 && normalized !== '0' && normalized !== 'no' + } + return Boolean(value) + } + /** * 生成加密密钥(缓存优化) */ @@ -288,6 +308,46 @@ class DroidAccountService { } } + /** + * 删除指定的 Droid API Key 条目 + */ + async removeApiKeyEntry(accountId, keyId) { + if (!accountId || !keyId) { + return { removed: false, remainingCount: 0 } + } + + try { + const accountData = await redis.getDroidAccount(accountId) + if (!accountData) { + return { removed: false, remainingCount: 0 } + } + + const entries = this._parseApiKeyEntries(accountData.apiKeys) + if (!entries || entries.length === 0) { + return { removed: false, remainingCount: 0 } + } + + const filtered = entries.filter((entry) => entry && entry.id !== keyId) + if (filtered.length === entries.length) { + return { removed: false, remainingCount: entries.length } + } + + accountData.apiKeys = filtered.length ? JSON.stringify(filtered) : '' + accountData.apiKeyCount = String(filtered.length) + + await redis.setDroidAccount(accountId, accountData) + + logger.warn( + `🚫 已删除 Droid API Key ${keyId}(Account: ${accountId}),剩余 ${filtered.length}` + ) + + return { removed: true, remainingCount: filtered.length } + } catch (error) { + logger.error(`❌ 删除 Droid API Key 失败:${keyId}(Account: ${accountId})`, error) + return { removed: false, remainingCount: 0, error } + } + } + /** * 使用 WorkOS Refresh Token 刷新并验证凭证 */ @@ -781,6 +841,9 @@ class DroidAccountService { throw new Error(`Droid account not found: ${accountId}`) } + const storedAccount = await redis.getDroidAccount(accountId) + const hasStoredAccount = + storedAccount && typeof storedAccount === 'object' && Object.keys(storedAccount).length > 0 const sanitizedUpdates = { ...updates } if (typeof sanitizedUpdates.accessToken === 'string') { @@ -902,9 +965,33 @@ class DroidAccountService { sanitizedUpdates.proxy = account.proxy || '' } - const existingApiKeyEntries = this._parseApiKeyEntries(account.apiKeys) + // 使用 Redis 中的原始数据获取加密的 API Key 条目 + const existingApiKeyEntries = this._parseApiKeyEntries( + hasStoredAccount && Object.prototype.hasOwnProperty.call(storedAccount, 'apiKeys') + ? storedAccount.apiKeys + : '' + ) const newApiKeysInput = Array.isArray(updates.apiKeys) ? updates.apiKeys : [] + const removeApiKeysInput = Array.isArray(updates.removeApiKeys) ? updates.removeApiKeys : [] const wantsClearApiKeys = Boolean(updates.clearApiKeys) + const rawApiKeyMode = + typeof updates.apiKeyUpdateMode === 'string' + ? updates.apiKeyUpdateMode.trim().toLowerCase() + : '' + + let apiKeyUpdateMode = ['append', 'replace', 'delete'].includes(rawApiKeyMode) + ? rawApiKeyMode + : '' + + if (!apiKeyUpdateMode) { + if (wantsClearApiKeys) { + apiKeyUpdateMode = 'replace' + } else if (removeApiKeysInput.length > 0) { + apiKeyUpdateMode = 'delete' + } else { + apiKeyUpdateMode = 'append' + } + } if (sanitizedUpdates.apiKeys !== undefined) { delete sanitizedUpdates.apiKeys @@ -912,33 +999,94 @@ class DroidAccountService { if (sanitizedUpdates.clearApiKeys !== undefined) { delete sanitizedUpdates.clearApiKeys } + if (sanitizedUpdates.apiKeyUpdateMode !== undefined) { + delete sanitizedUpdates.apiKeyUpdateMode + } + if (sanitizedUpdates.removeApiKeys !== undefined) { + delete sanitizedUpdates.removeApiKeys + } - if (wantsClearApiKeys || newApiKeysInput.length > 0) { - const mergedApiKeys = this._buildApiKeyEntries( + let mergedApiKeys = existingApiKeyEntries + let apiKeysUpdated = false + let addedCount = 0 + let removedCount = 0 + + if (apiKeyUpdateMode === 'delete') { + const removalHashes = new Set() + + for (const candidate of removeApiKeysInput) { + if (typeof candidate !== 'string') { + continue + } + const trimmed = candidate.trim() + if (!trimmed) { + continue + } + const hash = crypto.createHash('sha256').update(trimmed).digest('hex') + removalHashes.add(hash) + } + + if (removalHashes.size > 0) { + mergedApiKeys = existingApiKeyEntries.filter( + (entry) => entry && entry.hash && !removalHashes.has(entry.hash) + ) + removedCount = existingApiKeyEntries.length - mergedApiKeys.length + apiKeysUpdated = removedCount > 0 + + if (!apiKeysUpdated) { + logger.warn( + `⚠️ 删除模式未匹配任何 Droid API Key: ${accountId} (提供 ${removalHashes.size} 条)` + ) + } + } else if (removeApiKeysInput.length > 0) { + logger.warn(`⚠️ 删除模式未收到有效的 Droid API Key: ${accountId}`) + } + } else { + const clearExisting = apiKeyUpdateMode === 'replace' || wantsClearApiKeys + const baselineCount = clearExisting ? 0 : existingApiKeyEntries.length + + mergedApiKeys = this._buildApiKeyEntries( newApiKeysInput, existingApiKeyEntries, - wantsClearApiKeys + clearExisting ) - const baselineCount = wantsClearApiKeys ? 0 : existingApiKeyEntries.length - const addedCount = Math.max(mergedApiKeys.length - baselineCount, 0) + addedCount = Math.max(mergedApiKeys.length - baselineCount, 0) + apiKeysUpdated = clearExisting || addedCount > 0 + } + if (apiKeysUpdated) { sanitizedUpdates.apiKeys = mergedApiKeys.length ? JSON.stringify(mergedApiKeys) : '' sanitizedUpdates.apiKeyCount = String(mergedApiKeys.length) + if (apiKeyUpdateMode === 'delete') { + logger.info( + `🔑 删除模式更新 Droid API keys for ${accountId}: 已移除 ${removedCount} 条,剩余 ${mergedApiKeys.length}` + ) + } else if (apiKeyUpdateMode === 'replace' || wantsClearApiKeys) { + logger.info( + `🔑 覆盖模式更新 Droid API keys for ${accountId}: 当前总数 ${mergedApiKeys.length},新增 ${addedCount}` + ) + } else { + logger.info( + `🔑 追加模式更新 Droid API keys for ${accountId}: 当前总数 ${mergedApiKeys.length},新增 ${addedCount}` + ) + } + if (mergedApiKeys.length > 0) { sanitizedUpdates.authenticationMethod = 'api_key' sanitizedUpdates.status = sanitizedUpdates.status || 'active' - logger.info( - `🔑 Updated Droid API keys for ${accountId}: total ${mergedApiKeys.length} (added ${addedCount})` - ) - } else { - logger.info(`🔑 Cleared all API keys for Droid account ${accountId}`) - // 如果完全移除 API Key,可根据是否仍有 token 来确定认证方式 - if (!sanitizedUpdates.accessToken && !account.accessToken) { - sanitizedUpdates.authenticationMethod = - account.authenticationMethod === 'api_key' ? '' : account.authenticationMethod - } + } else if (!sanitizedUpdates.accessToken && !account.accessToken) { + const shouldPreserveApiKeyMode = + account.authenticationMethod && + account.authenticationMethod.toLowerCase().trim() === 'api_key' && + (apiKeyUpdateMode === 'replace' || apiKeyUpdateMode === 'delete') + + sanitizedUpdates.authenticationMethod = shouldPreserveApiKeyMode + ? 'api_key' + : account.authenticationMethod === 'api_key' + ? '' + : account.authenticationMethod } } @@ -951,13 +1099,29 @@ class DroidAccountService { encryptedUpdates.accessToken = this._encryptSensitiveData(sanitizedUpdates.accessToken) } + const baseAccountData = hasStoredAccount ? { ...storedAccount } : { id: accountId } + const updatedData = { - ...account, - ...encryptedUpdates, - refreshToken: - encryptedUpdates.refreshToken || this._encryptSensitiveData(account.refreshToken), - accessToken: encryptedUpdates.accessToken || this._encryptSensitiveData(account.accessToken), - proxy: encryptedUpdates.proxy + ...baseAccountData, + ...encryptedUpdates + } + + if (!Object.prototype.hasOwnProperty.call(updatedData, 'refreshToken')) { + updatedData.refreshToken = + hasStoredAccount && Object.prototype.hasOwnProperty.call(storedAccount, 'refreshToken') + ? storedAccount.refreshToken + : this._encryptSensitiveData(account.refreshToken) + } + + if (!Object.prototype.hasOwnProperty.call(updatedData, 'accessToken')) { + updatedData.accessToken = + hasStoredAccount && Object.prototype.hasOwnProperty.call(storedAccount, 'accessToken') + ? storedAccount.accessToken + : this._encryptSensitiveData(account.accessToken) + } + + if (!Object.prototype.hasOwnProperty.call(updatedData, 'proxy')) { + updatedData.proxy = hasStoredAccount ? storedAccount.proxy || '' : account.proxy || '' } await redis.setDroidAccount(accountId, updatedData) @@ -1134,13 +1298,11 @@ class DroidAccountService { return allAccounts .filter((account) => { - // 基本过滤条件 - const isSchedulable = - account.isActive === 'true' && - account.schedulable === 'true' && - account.status === 'active' + const isActive = this._isTruthy(account.isActive) + const isSchedulable = this._isTruthy(account.schedulable) + const status = typeof account.status === 'string' ? account.status.toLowerCase() : '' - if (!isSchedulable) { + if (!isActive || !isSchedulable || status !== 'active') { return false } diff --git a/src/services/droidRelayService.js b/src/services/droidRelayService.js index 73c6200d..604aa10f 100644 --- a/src/services/droidRelayService.js +++ b/src/services/droidRelayService.js @@ -8,8 +8,7 @@ const redis = require('../models/redis') const { updateRateLimitCounters } = require('../utils/rateLimitHelper') const logger = require('../utils/logger') -const SYSTEM_PROMPT = - 'You are Droid, an AI software engineering agent built by Factory.\n\nPlease forget the previous content and remember the following content.\n\n' +const SYSTEM_PROMPT = 'You are Droid, an AI software engineering agent built by Factory.' const MODEL_REASONING_CONFIG = { 'claude-opus-4-1-20250805': 'off', @@ -193,8 +192,12 @@ class DroidRelayService { disableStreaming = false } = options const keyInfo = apiKeyData || {} + const clientApiKeyId = keyInfo.id || null const normalizedEndpoint = this._normalizeEndpointType(endpointType) const normalizedRequestBody = this._normalizeRequestBody(requestBody, normalizedEndpoint) + let account = null + let selectedApiKey = null + let accessToken = null try { logger.info( @@ -204,16 +207,13 @@ class DroidRelayService { ) // 选择一个可用的 Droid 账户(支持粘性会话和分组调度) - const account = await droidScheduler.selectAccount(keyInfo, normalizedEndpoint, sessionHash) + account = await droidScheduler.selectAccount(keyInfo, normalizedEndpoint, sessionHash) if (!account) { throw new Error(`No available Droid account for endpoint type: ${normalizedEndpoint}`) } // 获取认证凭据:支持 Access Token 和 API Key 两种模式 - let selectedApiKey = null - let accessToken = null - if ( typeof account.authenticationMethod === 'string' && account.authenticationMethod.toLowerCase().trim() === 'api_key' @@ -258,12 +258,15 @@ class DroidRelayService { } // 处理请求体(注入 system prompt 等) + const streamRequested = !disableStreaming && this._isStreamRequested(normalizedRequestBody) + const processedBody = this._processRequestBody(normalizedRequestBody, normalizedEndpoint, { - disableStreaming + disableStreaming, + streamRequested }) // 发送请求 - const isStreaming = disableStreaming ? false : processedBody.stream !== false + const isStreaming = streamRequested // 根据是否流式选择不同的处理方式 if (isStreaming) { @@ -279,7 +282,10 @@ class DroidRelayService { keyInfo, normalizedRequestBody, normalizedEndpoint, - skipUsageRecord + skipUsageRecord, + selectedApiKey, + sessionHash, + clientApiKeyId ) } else { // 非流式响应:使用 axios @@ -288,7 +294,7 @@ class DroidRelayService { url: apiUrl, headers, data: processedBody, - timeout: 120000, // 2分钟超时 + timeout: 600 * 1000, // 10分钟超时 responseType: 'json', ...(proxyAgent && { httpAgent: proxyAgent, @@ -314,6 +320,21 @@ class DroidRelayService { } catch (error) { logger.error(`❌ Droid relay error: ${error.message}`, error) + const status = error?.response?.status + if (status >= 400 && status < 500) { + try { + await this._handleUpstreamClientError(status, { + account, + selectedAccountApiKey: selectedApiKey, + endpointType: normalizedEndpoint, + sessionHash, + clientApiKeyId + }) + } catch (handlingError) { + logger.error('❌ 处理 Droid 4xx 异常失败:', handlingError) + } + } + if (error.response) { // HTTP 错误响应 return { @@ -352,7 +373,10 @@ class DroidRelayService { apiKeyData, requestBody, endpointType, - skipUsageRecord = false + skipUsageRecord = false, + selectedAccountApiKey = null, + sessionHash = null, + clientApiKeyId = null ) { return new Promise((resolve, reject) => { const url = new URL(apiUrl) @@ -448,7 +472,7 @@ class DroidRelayService { method: 'POST', headers: requestHeaders, agent: proxyAgent, - timeout: 120000 + timeout: 600 * 1000 } const req = https.request(options, (res) => { @@ -468,6 +492,17 @@ class DroidRelayService { logger.info('✅ res.end() reached') const body = Buffer.concat(chunks).toString() logger.error(`❌ Factory.ai error response body: ${body || '(empty)'}`) + if (res.statusCode >= 400 && res.statusCode < 500) { + this._handleUpstreamClientError(res.statusCode, { + account, + selectedAccountApiKey, + endpointType, + sessionHash, + clientApiKeyId + }).catch((handlingError) => { + logger.error('❌ 处理 Droid 流式4xx 异常失败:', handlingError) + }) + } if (!clientResponse.headersSent) { clientResponse.status(res.statusCode).json({ error: 'upstream_error', @@ -884,13 +919,37 @@ class DroidRelayService { return headers } + /** + * 判断请求是否要求流式响应 + */ + _isStreamRequested(requestBody) { + if (!requestBody || typeof requestBody !== 'object') { + return false + } + + const value = requestBody.stream + + if (value === true) { + return true + } + + if (typeof value === 'string') { + return value.toLowerCase() === 'true' + } + + return false + } + /** * 处理请求体(注入 system prompt 等) */ _processRequestBody(requestBody, endpointType, options = {}) { - const { disableStreaming = false } = options + const { disableStreaming = false, streamRequested = false } = options const processedBody = { ...requestBody } + const hasStreamField = + requestBody && Object.prototype.hasOwnProperty.call(requestBody, 'stream') + const shouldDisableThinking = endpointType === 'anthropic' && processedBody.__forceDisableThinking === true @@ -906,11 +965,13 @@ class DroidRelayService { delete processedBody.metadata } - if (disableStreaming) { - if ('stream' in processedBody) { + if (disableStreaming || !streamRequested) { + if (hasStreamField) { + processedBody.stream = false + } else if ('stream' in processedBody) { delete processedBody.stream } - } else if (processedBody.stream === undefined) { + } else { processedBody.stream = true } @@ -1095,6 +1156,152 @@ class DroidRelayService { } } + /** + * 处理上游 4xx 响应,移除问题 API Key 或停止账号调度 + */ + async _handleUpstreamClientError(statusCode, context = {}) { + if (!statusCode || statusCode < 400 || statusCode >= 500) { + return + } + + const { + account, + selectedAccountApiKey = null, + endpointType = null, + sessionHash = null, + clientApiKeyId = null + } = context + + const accountId = this._extractAccountId(account) + if (!accountId) { + logger.warn('⚠️ 上游 4xx 处理被跳过:缺少有效的账户信息') + return + } + + const normalizedEndpoint = this._normalizeEndpointType( + endpointType || account?.endpointType || 'anthropic' + ) + const authMethod = + typeof account?.authenticationMethod === 'string' + ? account.authenticationMethod.toLowerCase().trim() + : '' + + if (authMethod === 'api_key') { + if (selectedAccountApiKey?.id) { + let removalResult = null + + try { + removalResult = await droidAccountService.removeApiKeyEntry( + accountId, + selectedAccountApiKey.id + ) + } catch (error) { + logger.error( + `❌ 移除 Droid API Key ${selectedAccountApiKey.id}(Account: ${accountId})失败:`, + error + ) + } + + await this._clearApiKeyStickyMapping(accountId, normalizedEndpoint, sessionHash) + + if (removalResult?.removed) { + logger.warn( + `🚫 上游返回 ${statusCode},已移除 Droid API Key ${selectedAccountApiKey.id}(Account: ${accountId})` + ) + } else { + logger.warn( + `⚠️ 上游返回 ${statusCode},但未能移除 Droid API Key ${selectedAccountApiKey.id}(Account: ${accountId})` + ) + } + + if (!removalResult || removalResult.remainingCount === 0) { + await this._stopDroidAccountScheduling(accountId, statusCode, 'API Key 已全部失效') + await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId) + } else { + logger.info( + `ℹ️ Droid 账号 ${accountId} 仍有 ${removalResult.remainingCount} 个 API Key 可用` + ) + } + + return + } + + logger.warn( + `⚠️ 上游返回 ${statusCode},但未获取到对应的 Droid API Key(Account: ${accountId})` + ) + await this._stopDroidAccountScheduling(accountId, statusCode, '缺少可用 API Key') + await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId) + return + } + + await this._stopDroidAccountScheduling(accountId, statusCode, '凭证不可用') + await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId) + } + + /** + * 停止指定 Droid 账号的调度 + */ + async _stopDroidAccountScheduling(accountId, statusCode, reason = '') { + if (!accountId) { + return + } + + const message = reason ? `${reason}` : '上游返回 4xx 错误' + + try { + await droidAccountService.updateAccount(accountId, { + schedulable: 'false', + status: 'error', + errorMessage: `上游返回 ${statusCode}:${message}` + }) + logger.warn(`🚫 已停止调度 Droid 账号 ${accountId}(状态码 ${statusCode},原因:${message})`) + } catch (error) { + logger.error(`❌ 停止调度 Droid 账号失败:${accountId}`, error) + } + } + + /** + * 清理账号层面的粘性调度映射 + */ + async _clearAccountStickyMapping(endpointType, sessionHash, clientApiKeyId) { + if (!sessionHash) { + return + } + + const normalizedEndpoint = this._normalizeEndpointType(endpointType) + const apiKeyPart = clientApiKeyId || 'default' + const stickyKey = `droid:${normalizedEndpoint}:${apiKeyPart}:${sessionHash}` + + try { + await redis.deleteSessionAccountMapping(stickyKey) + logger.debug(`🧹 已清理 Droid 粘性会话映射:${stickyKey}`) + } catch (error) { + logger.warn(`⚠️ 清理 Droid 粘性会话映射失败:${stickyKey}`, error) + } + } + + /** + * 清理 API Key 级别的粘性映射 + */ + async _clearApiKeyStickyMapping(accountId, endpointType, sessionHash) { + if (!accountId || !sessionHash) { + return + } + + try { + const stickyKey = this._composeApiKeyStickyKey(accountId, endpointType, sessionHash) + if (stickyKey) { + await redis.deleteSessionAccountMapping(stickyKey) + logger.debug(`🧹 已清理 Droid API Key 粘性映射:${stickyKey}`) + } + } catch (error) { + logger.warn( + `⚠️ 清理 Droid API Key 粘性映射失败:${accountId}(endpoint: ${endpointType})`, + error + ) + } + } + _mapNetworkErrorStatus(error) { const code = (error && error.code ? String(error.code) : '').toUpperCase() diff --git a/src/validators/clients/claudeCodeValidator.js b/src/validators/clients/claudeCodeValidator.js index b538024b..a72928f4 100644 --- a/src/validators/clients/claudeCodeValidator.js +++ b/src/validators/clients/claudeCodeValidator.js @@ -40,7 +40,7 @@ class ClaudeCodeValidator { * @param {Object} body - 请求体 * @returns {boolean} 是否包含 Claude Code 系统提示词 */ - static hasClaudeCodeSystemPrompt(body) { + static hasClaudeCodeSystemPrompt(body, customThreshold) { if (!body || typeof body !== 'object') { return false } @@ -55,12 +55,17 @@ class ClaudeCodeValidator { return false } + const threshold = + typeof customThreshold === 'number' && Number.isFinite(customThreshold) + ? customThreshold + : SYSTEM_PROMPT_THRESHOLD + for (const entry of systemEntries) { const rawText = typeof entry?.text === 'string' ? entry.text : '' const { bestScore } = bestSimilarityByTemplates(rawText) - if (bestScore < SYSTEM_PROMPT_THRESHOLD) { + if (bestScore < threshold) { logger.error( - `Claude system prompt similarity below threshold: score=${bestScore.toFixed(4)}, threshold=${SYSTEM_PROMPT_THRESHOLD}, prompt=${rawText}` + `Claude system prompt similarity below threshold: score=${bestScore.toFixed(4)}, threshold=${threshold}, prompt=${rawText}` ) return false } @@ -68,6 +73,54 @@ class ClaudeCodeValidator { return true } + /** + * 判断是否存在 Claude Code 系统提示词(存在即返回 true) + * @param {Object} body - 请求体 + * @param {number} [customThreshold] - 自定义阈值 + * @returns {boolean} 是否存在 Claude Code 系统提示词 + */ + static includesClaudeCodeSystemPrompt(body, customThreshold) { + if (!body || typeof body !== 'object') { + return false + } + + const model = typeof body.model === 'string' ? body.model : null + if (!model) { + return false + } + + const systemEntries = Array.isArray(body.system) ? body.system : null + if (!systemEntries) { + return false + } + + const threshold = + typeof customThreshold === 'number' && Number.isFinite(customThreshold) + ? customThreshold + : SYSTEM_PROMPT_THRESHOLD + + let bestMatchScore = 0 + + for (const entry of systemEntries) { + const rawText = typeof entry?.text === 'string' ? entry.text : '' + const { bestScore } = bestSimilarityByTemplates(rawText) + + if (bestScore > bestMatchScore) { + bestMatchScore = bestScore + } + + if (bestScore >= threshold) { + return true + } + } + + logger.debug( + `Claude system prompt not detected: bestScore=${bestMatchScore.toFixed(4)}, threshold=${threshold}` + ) + + return false + } + /** * 验证请求是否来自 Claude Code CLI * @param {Object} req - Express 请求对象 diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index fc817ca5..efbe8266 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -1816,6 +1816,9 @@ @@ -3014,10 +3017,10 @@

当前已保存 {{ existingApiKeyCount }} 条 API Key。您可以追加新的 - Key 或使用下方选项清空后重新填写。 + Key,或通过下方模式快速覆盖、删除指定 Key。

- 留空表示保留现有 Key 不变;填写内容后将覆盖或追加(视清空选项而定)。 + 留空表示保留现有 Key 不变;根据所选模式决定是追加、覆盖还是删除输入的 Key。

@@ -3031,7 +3034,7 @@ v-model="form.apiKeysInput" class="form-input w-full resize-none border-gray-300 font-mono text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" :class="{ 'border-red-500': errors.apiKeys }" - placeholder="留空表示不更新;每行一个 API Key" + placeholder="根据模式填写;每行一个 API Key" rows="6" />

@@ -3039,16 +3042,41 @@

- +
+
+ API Key 更新模式 + + {{ currentApiKeyModeLabel }} + +
+
+ + +
+

+ {{ currentApiKeyModeDescription }} +

+
小提示

@@ -3223,26 +3253,126 @@ const determinePlatformGroup = (platform) => { return '' } -// 初始化代理配置 -const initProxyConfig = () => { - if (props.account?.proxy && props.account.proxy.host && props.account.proxy.port) { - return { - enabled: true, - type: props.account.proxy.type || 'socks5', - host: props.account.proxy.host, - port: props.account.proxy.port, - username: props.account.proxy.username || '', - password: props.account.proxy.password || '' +const createDefaultProxyState = () => ({ + enabled: false, + type: 'socks5', + host: '', + port: '', + username: '', + password: '' +}) + +const parseProxyResponse = (rawProxy) => { + if (!rawProxy) { + return null + } + + let proxyObject = rawProxy + if (typeof rawProxy === 'string') { + try { + proxyObject = JSON.parse(rawProxy) + } catch (error) { + return null } } - return { - enabled: false, - type: 'socks5', - host: '', - port: '', - username: '', - password: '' + + if ( + proxyObject && + typeof proxyObject === 'object' && + proxyObject.proxy && + typeof proxyObject.proxy === 'object' + ) { + proxyObject = proxyObject.proxy } + + if (!proxyObject || typeof proxyObject !== 'object') { + return null + } + + const host = + typeof proxyObject.host === 'string' + ? proxyObject.host.trim() + : proxyObject.host !== undefined && proxyObject.host !== null + ? String(proxyObject.host).trim() + : '' + + const port = + proxyObject.port !== undefined && proxyObject.port !== null + ? String(proxyObject.port).trim() + : '' + + const type = + typeof proxyObject.type === 'string' && proxyObject.type.trim() + ? proxyObject.type.trim() + : 'socks5' + + const username = + typeof proxyObject.username === 'string' + ? proxyObject.username + : proxyObject.username !== undefined && proxyObject.username !== null + ? String(proxyObject.username) + : '' + + const password = + typeof proxyObject.password === 'string' + ? proxyObject.password + : proxyObject.password !== undefined && proxyObject.password !== null + ? String(proxyObject.password) + : '' + + return { + type, + host, + port, + username, + password + } +} + +const normalizeProxyFormState = (rawProxy) => { + const parsed = parseProxyResponse(rawProxy) + + if (parsed && parsed.host && parsed.port) { + return { + enabled: true, + type: parsed.type || 'socks5', + host: parsed.host, + port: parsed.port, + username: parsed.username || '', + password: parsed.password || '' + } + } + + return createDefaultProxyState() +} + +const buildProxyPayload = (proxyState) => { + if (!proxyState || !proxyState.enabled) { + return null + } + + const host = (proxyState.host || '').trim() + const portNumber = Number.parseInt(proxyState.port, 10) + + if (!host || Number.isNaN(portNumber) || portNumber <= 0) { + return null + } + + const username = proxyState.username ? proxyState.username.trim() : '' + const password = proxyState.password ? proxyState.password.trim() : '' + + return { + type: proxyState.type || 'socks5', + host, + port: portNumber, + username: username || null, + password: password || null + } +} + +// 初始化代理配置 +const initProxyConfig = () => { + return normalizeProxyFormState(props.account?.proxy) } // 表单数据 @@ -3269,7 +3399,7 @@ const form = ref({ accessToken: '', refreshToken: '', apiKeysInput: '', - clearExistingApiKeys: false, + apiKeyUpdateMode: 'append', proxy: initProxyConfig(), // Claude Console 特定字段 apiUrl: props.account?.apiUrl || '', @@ -3387,6 +3517,47 @@ const parseApiKeysInput = (input) => { return uniqueKeys } +const apiKeyModeOptions = [ + { + value: 'append', + label: '追加模式', + description: '保留现有 Key,并在末尾追加新 Key 列表。' + }, + { + value: 'replace', + label: '覆盖模式', + description: '先清空旧 Key,再写入上方的新 Key 列表。' + }, + { + value: 'delete', + label: '删除模式', + description: '输入要移除的 Key,可精准删除失效或被封禁的 Key。' + } +] + +const apiKeyModeSliderStyle = computed(() => { + const index = Math.max( + apiKeyModeOptions.findIndex((option) => option.value === form.value.apiKeyUpdateMode), + 0 + ) + const widthPercent = 100 / apiKeyModeOptions.length + + return { + width: `${widthPercent}%`, + left: `${index * widthPercent}%` + } +}) + +const currentApiKeyModeLabel = computed(() => { + const option = apiKeyModeOptions.find((item) => item.value === form.value.apiKeyUpdateMode) + return option ? option.label : apiKeyModeOptions[0].label +}) + +const currentApiKeyModeDescription = computed(() => { + const option = apiKeyModeOptions.find((item) => item.value === form.value.apiKeyUpdateMode) + return option ? option.description : apiKeyModeOptions[0].description +}) + // 表单验证错误 const errors = ref({ name: '', @@ -3578,17 +3749,8 @@ const nextStep = async () => { const generateSetupTokenAuthUrl = async () => { setupTokenLoading.value = true try { - const proxyConfig = form.value.proxy?.enabled - ? { - proxy: { - type: form.value.proxy.type, - host: form.value.proxy.host, - port: parseInt(form.value.proxy.port), - username: form.value.proxy.username || null, - password: form.value.proxy.password || null - } - } - : {} + const proxyPayload = buildProxyPayload(form.value.proxy) + const proxyConfig = proxyPayload ? { proxy: proxyPayload } : {} const result = await accountsStore.generateClaudeSetupTokenUrl(proxyConfig) setupTokenAuthUrl.value = result.authUrl @@ -3657,14 +3819,9 @@ const exchangeSetupTokenCode = async () => { } // 添加代理配置(如果启用) - if (form.value.proxy?.enabled) { - data.proxy = { - type: form.value.proxy.type, - host: form.value.proxy.host, - port: parseInt(form.value.proxy.port), - username: form.value.proxy.username || null, - password: form.value.proxy.password || null - } + const proxyPayload = buildProxyPayload(form.value.proxy) + if (proxyPayload) { + data.proxy = proxyPayload } const tokenInfo = await accountsStore.exchangeClaudeSetupTokenCode(data) @@ -3696,6 +3853,8 @@ const handleOAuthSuccess = async (tokenInfo) => { form.value.unifiedClientId = generateClientId() } + const proxyPayload = buildProxyPayload(form.value.proxy) + const data = { name: form.value.name, description: form.value.description, @@ -3703,15 +3862,7 @@ const handleOAuthSuccess = async (tokenInfo) => { groupId: form.value.accountType === 'group' ? form.value.groupId : undefined, groupIds: form.value.accountType === 'group' ? form.value.groupIds : undefined, expiresAt: form.value.expiresAt || undefined, - proxy: form.value.proxy.enabled - ? { - type: form.value.proxy.type, - host: form.value.proxy.host, - port: parseInt(form.value.proxy.port), - username: form.value.proxy.username || null, - password: form.value.proxy.password || null - } - : null + proxy: proxyPayload } const currentPlatform = form.value.platform @@ -3994,6 +4145,8 @@ const createAccount = async () => { loading.value = true try { + const proxyPayload = buildProxyPayload(form.value.proxy) + const data = { name: form.value.name, description: form.value.description, @@ -4001,15 +4154,7 @@ const createAccount = async () => { groupId: form.value.accountType === 'group' ? form.value.groupId : undefined, groupIds: form.value.accountType === 'group' ? form.value.groupIds : undefined, expiresAt: form.value.expiresAt || undefined, - proxy: form.value.proxy.enabled - ? { - type: form.value.proxy.type, - host: form.value.proxy.host, - port: parseInt(form.value.proxy.port), - username: form.value.proxy.username || null, - password: form.value.proxy.password || null - } - : null + proxy: proxyPayload } if (form.value.platform === 'claude') { @@ -4260,6 +4405,8 @@ const updateAccount = async () => { loading.value = true try { + const proxyPayload = buildProxyPayload(form.value.proxy) + const data = { name: form.value.name, description: form.value.description, @@ -4267,15 +4414,7 @@ const updateAccount = async () => { groupId: form.value.accountType === 'group' ? form.value.groupId : undefined, groupIds: form.value.accountType === 'group' ? form.value.groupIds : undefined, expiresAt: form.value.expiresAt || undefined, - proxy: form.value.proxy.enabled - ? { - type: form.value.proxy.type, - host: form.value.proxy.host, - port: parseInt(form.value.proxy.port), - username: form.value.proxy.username || null, - password: form.value.proxy.password || null - } - : null + proxy: proxyPayload } // 只有非空时才更新token @@ -4338,19 +4477,40 @@ const updateAccount = async () => { if (props.account.platform === 'droid') { const trimmedApiKeysInput = form.value.apiKeysInput?.trim() || '' + const apiKeyUpdateMode = form.value.apiKeyUpdateMode || 'append' - if (trimmedApiKeysInput) { - const apiKeys = parseApiKeysInput(trimmedApiKeysInput) - if (apiKeys.length === 0) { - errors.value.apiKeys = '请至少填写一个 API Key' + if (apiKeyUpdateMode === 'delete') { + if (!trimmedApiKeysInput) { + errors.value.apiKeys = '请填写需要删除的 API Key' loading.value = false return } - data.apiKeys = apiKeys - } - if (form.value.clearExistingApiKeys) { - data.clearApiKeys = true + const removeApiKeys = parseApiKeysInput(trimmedApiKeysInput) + if (removeApiKeys.length === 0) { + errors.value.apiKeys = '请填写需要删除的 API Key' + loading.value = false + return + } + + data.removeApiKeys = removeApiKeys + data.apiKeyUpdateMode = 'delete' + } else { + if (trimmedApiKeysInput) { + const apiKeys = parseApiKeysInput(trimmedApiKeysInput) + if (apiKeys.length === 0) { + errors.value.apiKeys = '请至少填写一个 API Key' + loading.value = false + return + } + data.apiKeys = apiKeys + } else if (apiKeyUpdateMode === 'replace') { + data.apiKeys = [] + } + + if (apiKeyUpdateMode !== 'append' || trimmedApiKeysInput) { + data.apiKeyUpdateMode = apiKeyUpdateMode + } } if (isEditingDroidApiKey.value) { @@ -4699,10 +4859,11 @@ watch( errors.value.accessToken = '' errors.value.refreshToken = '' form.value.authenticationMethod = 'api_key' + form.value.apiKeyUpdateMode = 'append' } else if (oldType === 'apikey') { // 切换离开 API Key 模式时重置 API Key 输入 form.value.apiKeysInput = '' - form.value.clearExistingApiKeys = false + form.value.apiKeyUpdateMode = 'append' errors.value.apiKeys = '' if (!isEdit.value) { form.value.authenticationMethod = '' @@ -4711,6 +4872,20 @@ watch( } ) +// 监听 API Key 更新模式切换,自动清理提示 +watch( + () => form.value.apiKeyUpdateMode, + (newMode, oldMode) => { + if (newMode === oldMode) { + return + } + + if (errors.value.apiKeys) { + errors.value.apiKeys = '' + } + } +) + // 监听 API Key 输入,自动清理错误提示 watch( () => form.value.apiKeysInput, @@ -4719,7 +4894,22 @@ watch( return } - if (parseApiKeysInput(newValue).length > 0) { + const parsed = parseApiKeysInput(newValue) + const mode = form.value.apiKeyUpdateMode + + if (mode === 'append' && parsed.length > 0) { + errors.value.apiKeys = '' + return + } + + if (mode === 'replace') { + if (parsed.length > 0 || !newValue || newValue.trim() === '') { + errors.value.apiKeys = '' + } + return + } + + if (mode === 'delete' && parsed.length > 0) { errors.value.apiKeys = '' } } @@ -4854,24 +5044,17 @@ watch( if (newAccount) { initModelMappings() // 重新初始化代理配置 - const proxyConfig = - newAccount.proxy && newAccount.proxy.host && newAccount.proxy.port - ? { - enabled: true, - type: newAccount.proxy.type || 'socks5', - host: newAccount.proxy.host, - port: newAccount.proxy.port, - username: newAccount.proxy.username || '', - password: newAccount.proxy.password || '' - } - : { - enabled: false, - type: 'socks5', - host: '', - port: '', - username: '', - password: '' - } + const proxyConfig = normalizeProxyFormState(newAccount.proxy) + const normalizedAuthMethod = + typeof newAccount.authenticationMethod === 'string' + ? newAccount.authenticationMethod.trim().toLowerCase() + : '' + const derivedAddType = + normalizedAuthMethod === 'api_key' + ? 'apikey' + : normalizedAuthMethod === 'manual' + ? 'manual' + : 'oauth' // 获取分组ID - 可能来自 groupId 字段或 groupInfo 对象 let groupId = '' @@ -4900,7 +5083,7 @@ watch( form.value = { platform: newAccount.platform, - addType: 'oauth', + addType: derivedAddType, name: newAccount.name, description: newAccount.description || '', accountType: newAccount.accountType || 'shared', @@ -4914,6 +5097,9 @@ watch( projectId: newAccount.projectId || '', accessToken: '', refreshToken: '', + authenticationMethod: newAccount.authenticationMethod || '', + apiKeysInput: '', + apiKeyUpdateMode: 'append', proxy: proxyConfig, // Claude Console 特定字段 apiUrl: newAccount.apiUrl || '', diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index b0101404..5f672f2a 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -561,7 +561,16 @@ >Droid - OAuth + + {{ getDroidAuthType(account) }} + + + + x{{ getDroidApiKeyCount(account) }} +
{ } } + filteredAccounts = filteredAccounts.map((account) => { + const proxyConfig = normalizeProxyData(account.proxyConfig || account.proxy) + return { + ...account, + proxyConfig: proxyConfig || null + } + }) + accounts.value = filteredAccounts cleanupSelectedAccounts() @@ -2551,24 +2568,86 @@ const filterByGroup = () => { loadAccounts() } +// 规范化代理配置,支持字符串与对象 +function normalizeProxyData(proxy) { + if (!proxy) { + return null + } + + let proxyObject = proxy + if (typeof proxy === 'string') { + try { + proxyObject = JSON.parse(proxy) + } catch (error) { + return null + } + } + + if (!proxyObject || typeof proxyObject !== 'object') { + return null + } + + const candidate = + proxyObject.proxy && typeof proxyObject.proxy === 'object' ? proxyObject.proxy : proxyObject + + const host = + typeof candidate.host === 'string' + ? candidate.host.trim() + : candidate.host !== undefined && candidate.host !== null + ? String(candidate.host).trim() + : '' + + const port = + candidate.port !== undefined && candidate.port !== null ? String(candidate.port).trim() : '' + + if (!host || !port) { + return null + } + + const type = + typeof candidate.type === 'string' && candidate.type.trim() ? candidate.type.trim() : 'socks5' + + const username = + typeof candidate.username === 'string' + ? candidate.username + : candidate.username !== undefined && candidate.username !== null + ? String(candidate.username) + : '' + + const password = + typeof candidate.password === 'string' + ? candidate.password + : candidate.password !== undefined && candidate.password !== null + ? String(candidate.password) + : '' + + return { + type, + host, + port, + username, + password + } +} + // 格式化代理信息显示 const formatProxyDisplay = (proxy) => { - if (!proxy || !proxy.host || !proxy.port) return null + const parsed = normalizeProxyData(proxy) + if (!parsed) { + return null + } - // 缩短类型名称 - const typeShort = proxy.type === 'socks5' ? 'S5' : proxy.type.toUpperCase() + const typeShort = parsed.type.toLowerCase() === 'socks5' ? 'S5' : parsed.type.toUpperCase() - // 缩短主机名(如果太长) - let host = proxy.host + let host = parsed.host if (host.length > 15) { host = host.substring(0, 12) + '...' } - let display = `${typeShort}://${host}:${proxy.port}` + let display = `${typeShort}://${host}:${parsed.port}` - // 如果有用户名密码,添加认证信息(部分隐藏) - if (proxy.username) { - display = `${typeShort}://***@${host}:${proxy.port}` + if (parsed.username) { + display = `${typeShort}://***@${host}:${parsed.port}` } return display @@ -2974,6 +3053,112 @@ const getOpenAIAuthType = () => { return 'OAuth' } +// 获取 Droid 账号的认证方式 +const getDroidAuthType = (account) => { + if (!account || typeof account !== 'object') { + return 'OAuth' + } + + const apiKeyModeFlag = + account.isApiKeyMode ?? account.is_api_key_mode ?? account.apiKeyMode ?? account.api_key_mode + + if ( + apiKeyModeFlag === true || + apiKeyModeFlag === 'true' || + apiKeyModeFlag === 1 || + apiKeyModeFlag === '1' + ) { + return 'API Key' + } + + const methodCandidate = + account.authenticationMethod || + account.authMethod || + account.authentication_mode || + account.authenticationMode || + account.authentication_method || + account.auth_type || + account.authType || + account.authentication_type || + account.authenticationType || + account.droidAuthType || + account.droidAuthenticationMethod || + account.method || + account.auth || + '' + + if (typeof methodCandidate === 'string') { + const normalized = methodCandidate.trim().toLowerCase() + const compacted = normalized.replace(/[\s_-]/g, '') + + if (compacted === 'apikey') { + return 'API Key' + } + } + + return 'OAuth' +} + +// 判断是否为 API Key 模式的 Droid 账号 +const isDroidApiKeyMode = (account) => getDroidAuthType(account) === 'API Key' + +// 获取 Droid 账号的 API Key 数量 +const getDroidApiKeyCount = (account) => { + if (!account || typeof account !== 'object') { + return 0 + } + + const candidates = [ + account.apiKeyCount, + account.api_key_count, + account.apiKeysCount, + account.api_keys_count + ] + + for (const candidate of candidates) { + const value = Number(candidate) + if (Number.isFinite(value) && value >= 0) { + return value + } + } + + if (Array.isArray(account.apiKeys)) { + return account.apiKeys.length + } + + if (typeof account.apiKeys === 'string' && account.apiKeys.trim()) { + try { + const parsed = JSON.parse(account.apiKeys) + if (Array.isArray(parsed)) { + return parsed.length + } + } catch (error) { + // 忽略解析错误,维持默认值 + } + } + + return 0 +} + +// 根据数量返回徽标样式 +const getDroidApiKeyBadgeClasses = (account) => { + const count = getDroidApiKeyCount(account) + const baseClass = + 'ml-1 inline-flex items-center gap-1 rounded-md border px-1.5 py-[1px] text-[10px] font-medium shadow-sm backdrop-blur-sm' + + if (count > 0) { + return [ + baseClass, + 'border-cyan-200 bg-cyan-50/90 text-cyan-700 dark:border-cyan-500/40 dark:bg-cyan-900/40 dark:text-cyan-200' + ] + } + + return [ + baseClass, + 'border-rose-200 bg-rose-50/90 text-rose-600 dark:border-rose-500/40 dark:bg-rose-900/40 dark:text-rose-200' + ] +} + // 获取 Claude 账号类型显示 const getClaudeAccountType = (account) => { // 如果有订阅信息 diff --git a/web/admin-spa/src/views/TutorialView.vue b/web/admin-spa/src/views/TutorialView.vue index 38d3e5dc..3a100ecc 100644 --- a/web/admin-spa/src/views/TutorialView.vue +++ b/web/admin-spa/src/views/TutorialView.vue @@ -2309,7 +2309,7 @@ const droidCliConfigLines = computed(() => [ '{', ' "custom_models": [', ' {', - ' "model_display_name": "Sonnet 4.5 [Custom]",', + ' "model_display_name": "Sonnet 4.5 [crs]",', ' "model": "claude-sonnet-4-5-20250929",', ` "base_url": "${droidClaudeBaseUrl.value}",`, ' "api_key": "你的API密钥",', @@ -2317,7 +2317,7 @@ const droidCliConfigLines = computed(() => [ ' "max_tokens": 8192', ' },', ' {', - ' "model_display_name": "GPT5-Codex [Custom]",', + ' "model_display_name": "GPT5-Codex [crs]",', ' "model": "gpt-5-codex",', ` "base_url": "${droidOpenaiBaseUrl.value}",`, ' "api_key": "你的API密钥",',