From 42db2718486a407d74ced9db2e7903584766c4c0 Mon Sep 17 00:00:00 2001 From: shaw Date: Fri, 10 Oct 2025 15:13:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20droid=E5=B9=B3=E5=8F=B0=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E6=95=B0=E6=8D=AE=E7=BB=9F=E8=AE=A1=E5=8F=8A=E8=B0=83?= =?UTF-8?q?=E5=BA=A6=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/middleware/auth.js | 1 + src/models/redis.js | 7 +- src/routes/admin.js | 142 ++++++- src/routes/droidRoutes.js | 73 ++-- src/services/accountGroupService.js | 7 +- src/services/apiKeyService.js | 13 +- src/services/droidAccountService.js | 295 +++++++++++++-- src/services/droidRelayService.js | 349 +++++++++++++----- src/services/droidScheduler.js | 218 +++++++++++ src/validators/clientDefinitions.js | 11 +- src/validators/clientValidator.js | 5 +- src/validators/clients/claudeCodeValidator.js | 6 +- src/validators/clients/droidCliValidator.js | 57 +++ .../src/components/accounts/AccountForm.vue | 64 +++- .../accounts/GroupManagementModal.vue | 12 +- .../apikeys/BatchEditApiKeyModal.vue | 107 +++++- .../components/apikeys/CreateApiKeyModal.vue | 77 +++- .../components/apikeys/EditApiKeyModal.vue | 86 ++++- .../src/components/common/AccountSelector.vue | 8 +- web/admin-spa/src/views/AccountsView.vue | 13 +- web/admin-spa/src/views/ApiKeysView.vue | 85 ++++- 21 files changed, 1424 insertions(+), 212 deletions(-) create mode 100644 src/services/droidScheduler.js create mode 100644 src/validators/clients/droidCliValidator.js diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 2b55d69f..e8a67a43 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -438,6 +438,7 @@ const authenticateApiKey = async (req, res, next) => { geminiAccountId: validation.keyData.geminiAccountId, openaiAccountId: validation.keyData.openaiAccountId, // 添加 OpenAI 账号ID bedrockAccountId: validation.keyData.bedrockAccountId, // 添加 Bedrock 账号ID + droidAccountId: validation.keyData.droidAccountId, permissions: validation.keyData.permissions, concurrencyLimit: validation.keyData.concurrencyLimit, rateLimitWindow: validation.keyData.rateLimitWindow, diff --git a/src/models/redis.js b/src/models/redis.js index e90f2d4a..e4c27243 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -858,7 +858,9 @@ class RedisClient { // 获取账户创建时间来计算平均值 - 支持不同类型的账号 let accountData = {} - if (accountType === 'openai') { + if (accountType === 'droid') { + accountData = await this.client.hgetall(`droid:account:${accountId}`) + } else if (accountType === 'openai') { accountData = await this.client.hgetall(`openai:account:${accountId}`) } else if (accountType === 'openai-responses') { accountData = await this.client.hgetall(`openai_responses_account:${accountId}`) @@ -874,6 +876,9 @@ class RedisClient { if (!accountData.createdAt) { accountData = await this.client.hgetall(`openai_account:${accountId}`) } + if (!accountData.createdAt) { + accountData = await this.client.hgetall(`droid:account:${accountId}`) + } } const createdAt = accountData.createdAt ? new Date(accountData.createdAt) : new Date() const now = new Date() diff --git a/src/routes/admin.js b/src/routes/admin.js index 119d457d..f7b50ff5 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -539,6 +539,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { geminiAccountId, openaiAccountId, bedrockAccountId, + droidAccountId, permissions, concurrencyLimit, rateLimitWindow, @@ -676,6 +677,18 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { } } + // 验证服务权限字段 + if ( + permissions !== undefined && + permissions !== null && + permissions !== '' && + !['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions) + ) { + return res.status(400).json({ + error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all' + }) + } + const newKey = await apiKeyService.generateApiKey({ name, description, @@ -686,6 +699,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { geminiAccountId, openaiAccountId, bedrockAccountId, + droidAccountId, permissions, concurrencyLimit, rateLimitWindow, @@ -727,6 +741,7 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { geminiAccountId, openaiAccountId, bedrockAccountId, + droidAccountId, permissions, concurrencyLimit, rateLimitWindow, @@ -761,6 +776,17 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { .json({ error: 'Base name must be less than 90 characters to allow for numbering' }) } + if ( + permissions !== undefined && + permissions !== null && + permissions !== '' && + !['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions) + ) { + return res.status(400).json({ + error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all' + }) + } + // 生成批量API Keys const createdKeys = [] const errors = [] @@ -778,6 +804,7 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { geminiAccountId, openaiAccountId, bedrockAccountId, + droidAccountId, permissions, concurrencyLimit, rateLimitWindow, @@ -860,6 +887,15 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => { }) } + if ( + updates.permissions !== undefined && + !['claude', 'gemini', 'openai', 'droid', 'all'].includes(updates.permissions) + ) { + return res.status(400).json({ + error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all' + }) + } + logger.info( `🔄 Admin batch editing ${keyIds.length} API keys with updates: ${JSON.stringify(updates)}` ) @@ -945,6 +981,9 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => { if (updates.bedrockAccountId !== undefined) { finalUpdates.bedrockAccountId = updates.bedrockAccountId } + if (updates.droidAccountId !== undefined) { + finalUpdates.droidAccountId = updates.droidAccountId || '' + } // 处理标签操作 if (updates.tags !== undefined) { @@ -1031,6 +1070,7 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { geminiAccountId, openaiAccountId, bedrockAccountId, + droidAccountId, permissions, enableModelRestriction, restrictedModels, @@ -1122,12 +1162,17 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { updates.bedrockAccountId = bedrockAccountId || '' } + if (droidAccountId !== undefined) { + // 空字符串表示解绑,null或空字符串都设置为空字符串 + updates.droidAccountId = droidAccountId || '' + } + if (permissions !== undefined) { // 验证权限值 - if (!['claude', 'gemini', 'openai', 'all'].includes(permissions)) { - return res - .status(400) - .json({ error: 'Invalid permissions value. Must be claude, gemini, openai, or all' }) + if (!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)) { + return res.status(400).json({ + error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all' + }) } updates.permissions = permissions } @@ -4393,6 +4438,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => { openaiAccounts, ccrAccounts, openaiResponsesAccounts, + droidAccounts, todayStats, systemAverages, realtimeMetrics @@ -4406,6 +4452,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => { redis.getAllOpenAIAccounts(), ccrAccountService.getAllAccounts(), openaiResponsesAccountService.getAllAccounts(true), + droidAccountService.getAllAccounts(), redis.getTodayStats(), redis.getSystemAverages(), redis.getRealtimeSystemMetrics() @@ -4413,6 +4460,42 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => { // 处理Bedrock账户数据 const bedrockAccounts = bedrockAccountsResult.success ? bedrockAccountsResult.data : [] + const normalizeBoolean = (value) => value === true || value === 'true' + const isRateLimitedFlag = (status) => { + if (!status) { + return false + } + if (typeof status === 'string') { + return status === 'limited' + } + if (typeof status === 'object') { + return status.isRateLimited === true + } + return false + } + + const normalDroidAccounts = droidAccounts.filter( + (acc) => + normalizeBoolean(acc.isActive) && + acc.status !== 'blocked' && + acc.status !== 'unauthorized' && + normalizeBoolean(acc.schedulable) && + !isRateLimitedFlag(acc.rateLimitStatus) + ).length + const abnormalDroidAccounts = droidAccounts.filter( + (acc) => + !normalizeBoolean(acc.isActive) || acc.status === 'blocked' || acc.status === 'unauthorized' + ).length + const pausedDroidAccounts = droidAccounts.filter( + (acc) => + !normalizeBoolean(acc.schedulable) && + normalizeBoolean(acc.isActive) && + acc.status !== 'blocked' && + acc.status !== 'unauthorized' + ).length + const rateLimitedDroidAccounts = droidAccounts.filter((acc) => + isRateLimitedFlag(acc.rateLimitStatus) + ).length // 计算使用统计(统一使用allTokens) const totalTokensUsed = apiKeys.reduce( @@ -4660,7 +4743,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => { abnormalBedrockAccounts + abnormalOpenAIAccounts + abnormalOpenAIResponsesAccounts + - abnormalCcrAccounts, + abnormalCcrAccounts + + abnormalDroidAccounts, pausedAccounts: pausedClaudeAccounts + pausedClaudeConsoleAccounts + @@ -4668,7 +4752,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => { pausedBedrockAccounts + pausedOpenAIAccounts + pausedOpenAIResponsesAccounts + - pausedCcrAccounts, + pausedCcrAccounts + + pausedDroidAccounts, rateLimitedAccounts: rateLimitedClaudeAccounts + rateLimitedClaudeConsoleAccounts + @@ -4676,7 +4761,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => { rateLimitedBedrockAccounts + rateLimitedOpenAIAccounts + rateLimitedOpenAIResponsesAccounts + - rateLimitedCcrAccounts, + rateLimitedCcrAccounts + + rateLimitedDroidAccounts, // 各平台详细统计 accountsByPlatform: { claude: { @@ -4727,6 +4813,13 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => { abnormal: abnormalOpenAIResponsesAccounts, paused: pausedOpenAIResponsesAccounts, rateLimited: rateLimitedOpenAIResponsesAccounts + }, + droid: { + total: droidAccounts.length, + normal: normalDroidAccounts, + abnormal: abnormalDroidAccounts, + paused: pausedDroidAccounts, + rateLimited: rateLimitedDroidAccounts } }, // 保留旧字段以兼容 @@ -4737,7 +4830,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => { normalBedrockAccounts + normalOpenAIAccounts + normalOpenAIResponsesAccounts + - normalCcrAccounts, + normalCcrAccounts + + normalDroidAccounts, totalClaudeAccounts: claudeAccounts.length + claudeConsoleAccounts.length, activeClaudeAccounts: normalClaudeAccounts + normalClaudeConsoleAccounts, rateLimitedClaudeAccounts: rateLimitedClaudeAccounts + rateLimitedClaudeConsoleAccounts, @@ -4775,6 +4869,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => { redisConnected: redis.isConnected, claudeAccountsHealthy: normalClaudeAccounts + normalClaudeConsoleAccounts > 0, geminiAccountsHealthy: normalGeminiAccounts > 0, + droidAccountsHealthy: normalDroidAccounts > 0, uptime: process.uptime() }, systemTimezone: config.system.timezoneOffset || 8 @@ -8490,15 +8585,44 @@ router.post('/droid-accounts/exchange-code', authenticateAdmin, async (req, res) router.get('/droid-accounts', authenticateAdmin, async (req, res) => { try { const accounts = await droidAccountService.getAllAccounts() + const allApiKeys = await redis.getAllApiKeys() // 添加使用统计 const accountsWithStats = await Promise.all( accounts.map(async (account) => { try { const usageStats = await redis.getAccountUsageStats(account.id, 'droid') + let groupInfos = [] + try { + groupInfos = await accountGroupService.getAccountGroups(account.id) + } catch (groupError) { + logger.debug(`Failed to get group infos for Droid account ${account.id}:`, groupError) + groupInfos = [] + } + + 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) + return { ...account, schedulable: account.schedulable === 'true', + boundApiKeysCount, + groupInfos, usage: { daily: usageStats.daily, total: usageStats.total, @@ -8509,6 +8633,8 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => { logger.warn(`Failed to get stats for Droid account ${account.id}:`, error.message) return { ...account, + boundApiKeysCount: 0, + groupInfos: [], usage: { daily: { tokens: 0, requests: 0 }, total: { tokens: 0, requests: 0 }, diff --git a/src/routes/droidRoutes.js b/src/routes/droidRoutes.js index 1a0dc014..ea96d80d 100644 --- a/src/routes/droidRoutes.js +++ b/src/routes/droidRoutes.js @@ -1,29 +1,47 @@ +const crypto = require('crypto') const express = require('express') const { authenticateApiKey } = require('../middleware/auth') const droidRelayService = require('../services/droidRelayService') +const sessionHelper = require('../utils/sessionHelper') const logger = require('../utils/logger') const router = express.Router() +function hasDroidPermission(apiKeyData) { + const permissions = apiKeyData?.permissions || 'all' + return permissions === 'all' || permissions === 'droid' +} + /** * Droid API 转发路由 * - * 支持多种 Factory.ai 端点: + * 支持的 Factory.ai 端点: * - /droid/claude - Anthropic (Claude) Messages API * - /droid/openai - OpenAI Responses API - * - /droid/chat - OpenAI Chat Completions API (通用) */ // Claude (Anthropic) 端点 - /v1/messages router.post('/claude/v1/messages', authenticateApiKey, async (req, res) => { try { + const sessionHash = sessionHelper.generateSessionHash(req.body) + + if (!hasDroidPermission(req.apiKey)) { + logger.security( + `🚫 API Key ${req.apiKey?.id || 'unknown'} 缺少 Droid 权限,拒绝访问 ${req.originalUrl}` + ) + return res.status(403).json({ + error: 'permission_denied', + message: '此 API Key 未启用 Droid 权限' + }) + } + const result = await droidRelayService.relayRequest( req.body, req.apiKey, req, res, req.headers, - { endpointType: 'anthropic' } + { endpointType: 'anthropic', sessionHash } ) // 如果是流式响应,已经在 relayService 中处理了 @@ -45,13 +63,34 @@ router.post('/claude/v1/messages', authenticateApiKey, async (req, res) => { // OpenAI 端点 - /v1/responses router.post('/openai/v1/responses', authenticateApiKey, async (req, res) => { try { + const sessionId = + req.headers['session_id'] || + req.headers['x-session-id'] || + req.body?.session_id || + req.body?.conversation_id || + null + + const sessionHash = sessionId + ? crypto.createHash('sha256').update(String(sessionId)).digest('hex') + : null + + if (!hasDroidPermission(req.apiKey)) { + logger.security( + `🚫 API Key ${req.apiKey?.id || 'unknown'} 缺少 Droid 权限,拒绝访问 ${req.originalUrl}` + ) + return res.status(403).json({ + error: 'permission_denied', + message: '此 API Key 未启用 Droid 权限' + }) + } + const result = await droidRelayService.relayRequest( req.body, req.apiKey, req, res, req.headers, - { endpointType: 'openai' } + { endpointType: 'openai', sessionHash } ) if (result.streaming) { @@ -68,32 +107,6 @@ router.post('/openai/v1/responses', authenticateApiKey, async (req, res) => { } }) -// 通用 OpenAI Chat Completions 端点 -router.post('/chat/v1/chat/completions', authenticateApiKey, async (req, res) => { - try { - const result = await droidRelayService.relayRequest( - req.body, - req.apiKey, - req, - res, - req.headers, - { endpointType: 'common' } - ) - - if (result.streaming) { - return - } - - res.status(result.statusCode).set(result.headers).send(result.body) - } catch (error) { - logger.error('Droid Chat relay error:', error) - res.status(500).json({ - error: 'internal_server_error', - message: error.message - }) - } -}) - // 模型列表端点(兼容性) router.get('/*/v1/models', authenticateApiKey, async (req, res) => { try { diff --git a/src/services/accountGroupService.js b/src/services/accountGroupService.js index 7268dad5..23293a18 100644 --- a/src/services/accountGroupService.js +++ b/src/services/accountGroupService.js @@ -27,8 +27,8 @@ class AccountGroupService { } // 验证平台类型 - if (!['claude', 'gemini', 'openai'].includes(platform)) { - throw new Error('平台类型必须是 claude、gemini 或 openai') + if (!['claude', 'gemini', 'openai', 'droid'].includes(platform)) { + throw new Error('平台类型必须是 claude、gemini、openai 或 droid') } const client = redis.getClientSafe() @@ -311,7 +311,8 @@ class AccountGroupService { keyData && (keyData.claudeAccountId === groupKey || keyData.geminiAccountId === groupKey || - keyData.openaiAccountId === groupKey) + keyData.openaiAccountId === groupKey || + keyData.droidAccountId === groupKey) ) { boundApiKeys.push({ id: keyId, diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index 5d63e72f..d187d54c 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -22,7 +22,8 @@ class ApiKeyService { openaiAccountId = null, azureOpenaiAccountId = null, bedrockAccountId = null, // 添加 Bedrock 账号ID支持 - permissions = 'all', // 'claude', 'gemini', 'openai', 'all' + droidAccountId = null, + permissions = 'all', // 可选值:'claude'、'gemini'、'openai'、'droid' 或 'all' isActive = true, concurrencyLimit = 0, rateLimitWindow = null, @@ -64,6 +65,7 @@ class ApiKeyService { openaiAccountId: openaiAccountId || '', azureOpenaiAccountId: azureOpenaiAccountId || '', bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID + droidAccountId: droidAccountId || '', permissions: permissions || 'all', enableModelRestriction: String(enableModelRestriction), restrictedModels: JSON.stringify(restrictedModels || []), @@ -109,6 +111,7 @@ class ApiKeyService { openaiAccountId: keyData.openaiAccountId, azureOpenaiAccountId: keyData.azureOpenaiAccountId, bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID + droidAccountId: keyData.droidAccountId, permissions: keyData.permissions, enableModelRestriction: keyData.enableModelRestriction === 'true', restrictedModels: JSON.parse(keyData.restrictedModels), @@ -256,6 +259,7 @@ class ApiKeyService { openaiAccountId: keyData.openaiAccountId, azureOpenaiAccountId: keyData.azureOpenaiAccountId, bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID + droidAccountId: keyData.droidAccountId, permissions: keyData.permissions || 'all', tokenLimit: parseInt(keyData.tokenLimit), concurrencyLimit: parseInt(keyData.concurrencyLimit || 0), @@ -382,6 +386,7 @@ class ApiKeyService { openaiAccountId: keyData.openaiAccountId, azureOpenaiAccountId: keyData.azureOpenaiAccountId, bedrockAccountId: keyData.bedrockAccountId, + droidAccountId: keyData.droidAccountId, permissions: keyData.permissions || 'all', tokenLimit: parseInt(keyData.tokenLimit), concurrencyLimit: parseInt(keyData.concurrencyLimit || 0), @@ -553,6 +558,7 @@ class ApiKeyService { 'openaiAccountId', 'azureOpenaiAccountId', 'bedrockAccountId', // 添加 Bedrock 账号ID + 'droidAccountId', 'permissions', 'expiresAt', 'activationDays', // 新增:激活后有效天数 @@ -1211,6 +1217,7 @@ class ApiKeyService { userId: key.userId, userUsername: key.userUsername, createdBy: key.createdBy, + droidAccountId: key.droidAccountId, // Include deletion fields for deleted keys isDeleted: key.isDeleted, deletedAt: key.deletedAt, @@ -1254,7 +1261,8 @@ class ApiKeyService { createdBy: keyData.createdBy, permissions: keyData.permissions, dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), - totalCostLimit: parseFloat(keyData.totalCostLimit || 0) + totalCostLimit: parseFloat(keyData.totalCostLimit || 0), + droidAccountId: keyData.droidAccountId } } catch (error) { logger.error('❌ Failed to get API key by ID:', error) @@ -1401,6 +1409,7 @@ class ApiKeyService { 'openai-responses': 'openaiAccountId', // 特殊处理,带 responses: 前缀 azure_openai: 'azureOpenaiAccountId', bedrock: 'bedrockAccountId', + droid: 'droidAccountId', ccr: null // CCR 账号没有对应的 API Key 字段 } diff --git a/src/services/droidAccountService.js b/src/services/droidAccountService.js index 3d0c743a..e748576f 100644 --- a/src/services/droidAccountService.js +++ b/src/services/droidAccountService.js @@ -44,6 +44,25 @@ class DroidAccountService { }, 10 * 60 * 1000 ) + + this.supportedEndpointTypes = new Set(['anthropic', 'openai']) + } + + _sanitizeEndpointType(endpointType) { + if (!endpointType) { + return 'anthropic' + } + + const normalized = String(endpointType).toLowerCase() + if (normalized === 'openai' || normalized === 'common') { + return 'openai' + } + + if (this.supportedEndpointTypes.has(normalized)) { + return normalized + } + + return 'anthropic' } /** @@ -117,7 +136,7 @@ class DroidAccountService { /** * 使用 WorkOS Refresh Token 刷新并验证凭证 */ - async _refreshTokensWithWorkOS(refreshToken, proxyConfig = null) { + async _refreshTokensWithWorkOS(refreshToken, proxyConfig = null, organizationId = null) { if (!refreshToken || typeof refreshToken !== 'string') { throw new Error('Refresh Token 无效') } @@ -126,6 +145,9 @@ class DroidAccountService { formData.append('grant_type', 'refresh_token') formData.append('refresh_token', refreshToken) formData.append('client_id', this.workosClientId) + if (organizationId) { + formData.append('organization_id', organizationId) + } const requestOptions = { method: 'POST', @@ -184,6 +206,49 @@ class DroidAccountService { } } + /** + * 使用 Factory CLI 接口获取组织 ID 列表 + */ + async _fetchFactoryOrgIds(accessToken, proxyConfig = null) { + if (!accessToken) { + return [] + } + + const requestOptions = { + method: 'GET', + url: 'https://app.factory.ai/api/cli/org', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'x-factory-client': 'cli', + 'User-Agent': this.userAgent + }, + timeout: 15000 + } + + if (proxyConfig) { + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + requestOptions.httpAgent = proxyAgent + requestOptions.httpsAgent = proxyAgent + } + } + + try { + const response = await axios(requestOptions) + const data = response.data || {} + if (Array.isArray(data.workosOrgIds) && data.workosOrgIds.length > 0) { + return data.workosOrgIds + } + logger.warn('⚠️ 未从 Factory CLI 接口获取到 workosOrgIds') + return [] + } catch (error) { + logger.warn('⚠️ 获取 Factory 组织信息失败:', error.message) + return [] + } + } + /** * 创建 Droid 账户 * @@ -203,7 +268,7 @@ class DroidAccountService { platform = 'droid', priority = 50, // 调度优先级 (1-100) schedulable = true, // 是否可被调度 - endpointType = 'anthropic', // 默认端点类型: 'anthropic', 'openai', 'common' + endpointType = 'anthropic', // 默认端点类型: 'anthropic' 或 'openai' organizationId = '', ownerEmail = '', ownerName = '', @@ -215,6 +280,8 @@ class DroidAccountService { const accountId = uuidv4() + const normalizedEndpointType = this._sanitizeEndpointType(endpointType) + let normalizedRefreshToken = refreshToken let normalizedAccessToken = accessToken let normalizedExpiresAt = expiresAt || '' @@ -229,22 +296,40 @@ class DroidAccountService { let lastRefreshAt = accessToken ? new Date().toISOString() : '' let status = accessToken ? 'active' : 'created' - if (normalizedRefreshToken) { - try { - let proxyConfig = null - if (proxy && typeof proxy === 'object') { - proxyConfig = proxy - } else if (typeof proxy === 'string' && proxy.trim()) { - try { - proxyConfig = JSON.parse(proxy) - } catch (error) { - logger.warn('⚠️ Droid 手动账号代理配置解析失败,已忽略:', error.message) - proxyConfig = null - } - } + const isManualProvision = + typeof authenticationMethod === 'string' && + authenticationMethod.toLowerCase().trim() === 'manual' + const provisioningMode = isManualProvision ? 'manual' : 'oauth' + + logger.info( + `🔍 [Droid ${provisioningMode}] 初始令牌 - AccountName: ${name}, AccessToken: ${normalizedAccessToken || '[empty]'}, RefreshToken: ${normalizedRefreshToken || '[empty]'}` + ) + + let proxyConfig = null + if (proxy && typeof proxy === 'object') { + proxyConfig = proxy + } else if (typeof proxy === 'string' && proxy.trim()) { + try { + proxyConfig = JSON.parse(proxy) + } catch (error) { + logger.warn('⚠️ Droid 代理配置解析失败,已忽略:', error.message) + proxyConfig = null + } + } + + if (normalizedRefreshToken && isManualProvision) { + try { const refreshed = await this._refreshTokensWithWorkOS(normalizedRefreshToken, proxyConfig) + logger.info( + `🔍 [Droid manual] 刷新后令牌 - AccountName: ${name}, AccessToken: ${refreshed.accessToken || '[empty]'}, RefreshToken: ${refreshed.refreshToken || '[empty]'}, ExpiresAt: ${refreshed.expiresAt || '[empty]'}, ExpiresIn: ${ + refreshed.expiresIn !== null && refreshed.expiresIn !== undefined + ? refreshed.expiresIn + : '[empty]' + }` + ) + normalizedAccessToken = refreshed.accessToken normalizedRefreshToken = refreshed.refreshToken normalizedExpiresAt = refreshed.expiresAt || normalizedExpiresAt @@ -296,8 +381,113 @@ class DroidAccountService { logger.error('❌ 使用 Refresh Token 验证 Droid 账户失败:', error) throw new Error(`Refresh Token 验证失败:${error.message}`) } + } else if (normalizedRefreshToken && !isManualProvision) { + try { + const orgIds = await this._fetchFactoryOrgIds(normalizedAccessToken, proxyConfig) + const selectedOrgId = + normalizedOrganizationId || + (Array.isArray(orgIds) + ? orgIds.find((id) => typeof id === 'string' && id.trim()) + : null) || + '' + + if (!selectedOrgId) { + logger.warn(`⚠️ [Droid oauth] 未获取到组织ID,跳过 WorkOS 刷新: ${name} (${accountId})`) + } else { + const refreshed = await this._refreshTokensWithWorkOS( + normalizedRefreshToken, + proxyConfig, + selectedOrgId + ) + + logger.info( + `🔍 [Droid oauth] 组织刷新后令牌 - AccountName: ${name}, AccessToken: ${refreshed.accessToken || '[empty]'}, RefreshToken: ${refreshed.refreshToken || '[empty]'}, OrganizationId: ${ + refreshed.organizationId || selectedOrgId + }, ExpiresAt: ${refreshed.expiresAt || '[empty]'}` + ) + + normalizedAccessToken = refreshed.accessToken + normalizedRefreshToken = refreshed.refreshToken + normalizedExpiresAt = refreshed.expiresAt || normalizedExpiresAt + normalizedTokenType = refreshed.tokenType || normalizedTokenType + normalizedAuthenticationMethod = + refreshed.authenticationMethod || normalizedAuthenticationMethod + if (refreshed.expiresIn !== null && refreshed.expiresIn !== undefined) { + normalizedExpiresIn = refreshed.expiresIn + } + if (refreshed.organizationId) { + normalizedOrganizationId = refreshed.organizationId + } else { + normalizedOrganizationId = selectedOrgId + } + + if (refreshed.user) { + const userInfo = refreshed.user + if (typeof userInfo.email === 'string' && userInfo.email.trim()) { + normalizedOwnerEmail = userInfo.email.trim() + } + const nameParts = [] + if (typeof userInfo.first_name === 'string' && userInfo.first_name.trim()) { + nameParts.push(userInfo.first_name.trim()) + } + if (typeof userInfo.last_name === 'string' && userInfo.last_name.trim()) { + nameParts.push(userInfo.last_name.trim()) + } + const derivedName = + nameParts.join(' ').trim() || + (typeof userInfo.name === 'string' ? userInfo.name.trim() : '') || + (typeof userInfo.display_name === 'string' ? userInfo.display_name.trim() : '') + + if (derivedName) { + normalizedOwnerName = derivedName + normalizedOwnerDisplayName = derivedName + } else if (normalizedOwnerEmail) { + normalizedOwnerName = normalizedOwnerName || normalizedOwnerEmail + normalizedOwnerDisplayName = + normalizedOwnerDisplayName || normalizedOwnerEmail || normalizedOwnerName + } + + if (typeof userInfo.id === 'string' && userInfo.id.trim()) { + normalizedUserId = userInfo.id.trim() + } + } + + lastRefreshAt = new Date().toISOString() + status = 'active' + } + } catch (error) { + logger.warn(`⚠️ [Droid oauth] 初始化刷新失败: ${name} (${accountId}) - ${error.message}`) + } } + if (!normalizedExpiresAt) { + let expiresInSeconds = null + if (typeof normalizedExpiresIn === 'number' && Number.isFinite(normalizedExpiresIn)) { + expiresInSeconds = normalizedExpiresIn + } else if ( + typeof normalizedExpiresIn === 'string' && + normalizedExpiresIn.trim() && + !Number.isNaN(Number(normalizedExpiresIn)) + ) { + expiresInSeconds = Number(normalizedExpiresIn) + } + + if (!Number.isFinite(expiresInSeconds) || expiresInSeconds <= 0) { + expiresInSeconds = this.tokenValidHours * 3600 + } + + normalizedExpiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString() + normalizedExpiresIn = expiresInSeconds + } + + logger.info( + `🔍 [Droid ${provisioningMode}] 写入前令牌快照 - AccountName: ${name}, AccessToken: ${normalizedAccessToken || '[empty]'}, RefreshToken: ${normalizedRefreshToken || '[empty]'}, ExpiresAt: ${normalizedExpiresAt || '[empty]'}, ExpiresIn: ${ + normalizedExpiresIn !== null && normalizedExpiresIn !== undefined + ? normalizedExpiresIn + : '[empty]' + }` + ) + const accountData = { id: accountId, name, @@ -316,7 +506,7 @@ class DroidAccountService { status, // created, active, expired, error errorMessage: '', schedulable: schedulable.toString(), - endpointType, // anthropic, openai, common + endpointType: normalizedEndpointType, // anthropic 或 openai organizationId: normalizedOrganizationId || '', owner: normalizedOwnerName || normalizedOwnerEmail || '', ownerEmail: normalizedOwnerEmail || '', @@ -334,7 +524,20 @@ class DroidAccountService { await redis.setDroidAccount(accountId, accountData) - logger.success(`🏢 Created Droid account: ${name} (${accountId}) - Endpoint: ${endpointType}`) + logger.success( + `🏢 Created Droid account: ${name} (${accountId}) - Endpoint: ${normalizedEndpointType}` + ) + + try { + const verifyAccount = await this.getAccount(accountId) + logger.info( + `🔍 [Droid ${provisioningMode}] Redis 写入后验证 - AccountName: ${name}, AccessToken: ${verifyAccount?.accessToken || '[empty]'}, RefreshToken: ${verifyAccount?.refreshToken || '[empty]'}, ExpiresAt: ${verifyAccount?.expiresAt || '[empty]'}` + ) + } catch (verifyError) { + logger.warn( + `⚠️ [Droid ${provisioningMode}] 写入后验证失败: ${name} (${accountId}) - ${verifyError.message}` + ) + } return { id: accountId, ...accountData } } @@ -350,6 +553,8 @@ class DroidAccountService { // 解密敏感数据 return { ...account, + id: accountId, + endpointType: this._sanitizeEndpointType(account.endpointType), refreshToken: this._decryptSensitiveData(account.refreshToken), accessToken: this._decryptSensitiveData(account.accessToken) } @@ -362,6 +567,7 @@ class DroidAccountService { const accounts = await redis.getAllDroidAccounts() return accounts.map((account) => ({ ...account, + endpointType: this._sanitizeEndpointType(account.endpointType), // 不解密完整 token,只返回掩码 refreshToken: account.refreshToken ? '***ENCRYPTED***' : '', accessToken: account.accessToken @@ -388,6 +594,10 @@ class DroidAccountService { sanitizedUpdates.refreshToken = sanitizedUpdates.refreshToken.trim() } + if (sanitizedUpdates.endpointType) { + sanitizedUpdates.endpointType = this._sanitizeEndpointType(sanitizedUpdates.endpointType) + } + const parseProxyConfig = (value) => { if (!value) { return null @@ -547,7 +757,11 @@ class DroidAccountService { try { const proxy = proxyConfig || (account.proxy ? JSON.parse(account.proxy) : null) - const refreshed = await this._refreshTokensWithWorkOS(account.refreshToken, proxy) + const refreshed = await this._refreshTokensWithWorkOS( + account.refreshToken, + proxy, + account.organizationId || null + ) // 更新账户信息 await this.updateAccount(accountId, { @@ -673,6 +887,8 @@ class DroidAccountService { async getSchedulableAccounts(endpointType = null) { const allAccounts = await redis.getAllDroidAccounts() + const normalizedFilter = endpointType ? this._sanitizeEndpointType(endpointType) : null + return allAccounts .filter((account) => { // 基本过滤条件 @@ -681,15 +897,29 @@ class DroidAccountService { account.schedulable === 'true' && account.status === 'active' - // 如果指定了端点类型,进一步过滤 - if (endpointType) { - return isSchedulable && account.endpointType === endpointType + if (!isSchedulable) { + return false } - return isSchedulable + if (!normalizedFilter) { + return true + } + + const accountEndpoint = this._sanitizeEndpointType(account.endpointType) + + if (normalizedFilter === 'openai') { + return accountEndpoint === 'openai' || accountEndpoint === 'anthropic' + } + + if (normalizedFilter === 'anthropic') { + return accountEndpoint === 'anthropic' || accountEndpoint === 'openai' + } + + return accountEndpoint === normalizedFilter }) .map((account) => ({ ...account, + endpointType: this._sanitizeEndpointType(account.endpointType), priority: parseInt(account.priority, 10) || 50, // 解密 accessToken 用于使用 accessToken: this._decryptSensitiveData(account.accessToken) @@ -737,7 +967,7 @@ class DroidAccountService { }) logger.info( - `✅ Selected Droid account: ${selectedAccount.name} (${selectedAccount.id}) - Endpoint: ${selectedAccount.endpointType}` + `✅ Selected Droid account: ${selectedAccount.name} (${selectedAccount.id}) - Endpoint: ${this._sanitizeEndpointType(selectedAccount.endpointType)}` ) return selectedAccount @@ -747,13 +977,26 @@ class DroidAccountService { * 获取 Factory.ai API 的完整 URL */ getFactoryApiUrl(endpointType, endpoint) { + const normalizedType = this._sanitizeEndpointType(endpointType) const baseUrls = { anthropic: `${this.factoryApiBaseUrl}/a${endpoint}`, - openai: `${this.factoryApiBaseUrl}/o${endpoint}`, - common: `${this.factoryApiBaseUrl}/o${endpoint}` + openai: `${this.factoryApiBaseUrl}/o${endpoint}` } - return baseUrls[endpointType] || baseUrls.common + return baseUrls[normalizedType] || baseUrls.openai + } + + async touchLastUsedAt(accountId) { + if (!accountId) { + return + } + + try { + const client = redis.getClientSafe() + await client.hset(`droid:account:${accountId}`, 'lastUsedAt', new Date().toISOString()) + } catch (error) { + logger.warn(`⚠️ Failed to update lastUsedAt for Droid account ${accountId}:`, error) + } } } diff --git a/src/services/droidRelayService.js b/src/services/droidRelayService.js index 45914023..13366815 100644 --- a/src/services/droidRelayService.js +++ b/src/services/droidRelayService.js @@ -1,8 +1,11 @@ const https = require('https') const axios = require('axios') const ProxyHelper = require('../utils/proxyHelper') +const droidScheduler = require('./droidScheduler') const droidAccountService = require('./droidAccountService') +const apiKeyService = require('./apiKeyService') const redis = require('../models/redis') +const { updateRateLimitCounters } = require('../utils/rateLimitHelper') const logger = require('../utils/logger') const SYSTEM_PROMPT = @@ -28,8 +31,7 @@ class DroidRelayService { this.endpoints = { anthropic: '/a/v1/messages', - openai: '/o/v1/responses', - common: '/o/v1/chat/completions' + openai: '/o/v1/responses' } this.userAgent = 'factory-cli/0.19.4' @@ -45,6 +47,46 @@ class DroidRelayService { }) } + _normalizeEndpointType(endpointType) { + if (!endpointType) { + return 'anthropic' + } + + const normalized = String(endpointType).toLowerCase() + if (normalized === 'openai' || normalized === 'common') { + return 'openai' + } + + if (normalized === 'anthropic') { + return 'anthropic' + } + + return 'anthropic' + } + + async _applyRateLimitTracking(rateLimitInfo, usageSummary, model, context = '') { + if (!rateLimitInfo) { + return + } + + try { + const { totalTokens, totalCost } = await updateRateLimitCounters( + rateLimitInfo, + usageSummary, + model + ) + + if (totalTokens > 0) { + logger.api(`📊 Updated rate limit token count${context}: +${totalTokens}`) + } + if (typeof totalCost === 'number' && totalCost > 0) { + logger.api(`💰 Updated rate limit cost count${context}: +$${totalCost.toFixed(6)}`) + } + } catch (error) { + logger.error(`❌ Failed to update rate limit counters${context}:`, error) + } + } + async relayRequest( requestBody, apiKeyData, @@ -53,26 +95,29 @@ class DroidRelayService { clientHeaders, options = {} ) { - const { endpointType = 'anthropic' } = options + const { endpointType = 'anthropic', sessionHash = null } = options const keyInfo = apiKeyData || {} + const normalizedEndpoint = this._normalizeEndpointType(endpointType) try { logger.info( - `📤 Processing Droid API request for key: ${keyInfo.name || keyInfo.id || 'unknown'}, endpoint: ${endpointType}` + `📤 Processing Droid API request for key: ${ + keyInfo.name || keyInfo.id || 'unknown' + }, endpoint: ${normalizedEndpoint}${sessionHash ? `, session: ${sessionHash}` : ''}` ) - // 选择一个可用的 Droid 账户 - const account = await droidAccountService.selectAccount(endpointType) + // 选择一个可用的 Droid 账户(支持粘性会话和分组调度) + const account = await droidScheduler.selectAccount(keyInfo, normalizedEndpoint, sessionHash) if (!account) { - throw new Error(`No available Droid account for endpoint type: ${endpointType}`) + throw new Error(`No available Droid account for endpoint type: ${normalizedEndpoint}`) } // 获取有效的 access token(自动刷新) const accessToken = await droidAccountService.getValidAccessToken(account.id) // 获取 Factory.ai API URL - const endpoint = this.endpoints[endpointType] + const endpoint = this.endpoints[normalizedEndpoint] const apiUrl = `${this.factoryApiBaseUrl}${endpoint}` logger.info(`🌐 Forwarding to Factory.ai: ${apiUrl}`) @@ -86,10 +131,15 @@ class DroidRelayService { } // 构建请求头 - const headers = this._buildHeaders(accessToken, requestBody, endpointType, clientHeaders) + const headers = this._buildHeaders( + accessToken, + requestBody, + normalizedEndpoint, + clientHeaders + ) // 处理请求体(注入 system prompt 等) - const processedBody = this._processRequestBody(requestBody, endpointType) + const processedBody = this._processRequestBody(requestBody, normalizedEndpoint) // 发送请求 const isStreaming = processedBody.stream !== false @@ -102,11 +152,12 @@ class DroidRelayService { headers, processedBody, proxyAgent, + clientRequest, clientResponse, account, keyInfo, requestBody, - endpointType + normalizedEndpoint ) } else { // 非流式响应:使用 axios @@ -128,7 +179,14 @@ class DroidRelayService { logger.info(`✅ Factory.ai response status: ${response.status}`) // 处理非流式响应 - return this._handleNonStreamResponse(response, account, keyInfo, requestBody) + return this._handleNonStreamResponse( + response, + account, + keyInfo, + requestBody, + clientRequest, + normalizedEndpoint + ) } } catch (error) { logger.error(`❌ Droid relay error: ${error.message}`, error) @@ -167,6 +225,7 @@ class DroidRelayService { headers, processedBody, proxyAgent, + clientRequest, clientResponse, account, apiKeyData, @@ -181,6 +240,7 @@ class DroidRelayService { ...headers, 'content-length': contentLength.toString() } + let responseStarted = false let responseCompleted = false let settled = false @@ -298,12 +358,13 @@ class DroidRelayService { // 转发数据到客户端 clientResponse.write(chunk) + hasForwardedData = true // 解析 usage 数据(根据端点类型) if (endpointType === 'anthropic') { // Anthropic Messages API 格式 this._parseAnthropicUsageFromSSE(chunkStr, buffer, currentUsageData) - } else if (endpointType === 'openai' || endpointType === 'common') { + } else if (endpointType === 'openai') { // OpenAI Chat Completions 格式 this._parseOpenAIUsageFromSSE(chunkStr, buffer, currentUsageData) } @@ -320,7 +381,26 @@ class DroidRelayService { clientResponse.end() // 记录 usage 数据 - await this._recordUsageFromStreamData(currentUsageData, apiKeyData, account, model) + const normalizedUsage = await this._recordUsageFromStreamData( + currentUsageData, + apiKeyData, + account, + model + ) + + const usageSummary = { + inputTokens: normalizedUsage.input_tokens || 0, + outputTokens: normalizedUsage.output_tokens || 0, + cacheCreateTokens: normalizedUsage.cache_creation_input_tokens || 0, + cacheReadTokens: normalizedUsage.cache_read_input_tokens || 0 + } + + await this._applyRateLimitTracking( + clientRequest?.rateLimitInfo, + usageSummary, + model, + ' [stream]' + ) logger.success(`✅ Droid stream completed - Account: ${account.name}`) resolveOnce({ statusCode: 200, streaming: true }) @@ -432,7 +512,7 @@ class DroidRelayService { const data = JSON.parse(jsonStr) - // OpenAI 格式在流结束时可能包含 usage + // 兼容传统 Chat Completions usage 字段 if (data.usage) { currentUsageData.input_tokens = data.usage.prompt_tokens || 0 currentUsageData.output_tokens = data.usage.completion_tokens || 0 @@ -440,6 +520,17 @@ class DroidRelayService { logger.debug('📊 Droid OpenAI usage:', currentUsageData) } + + // 新 Response API 在 response.usage 中返回统计 + if (data.response && data.response.usage) { + const { usage } = data.response + currentUsageData.input_tokens = + usage.input_tokens || usage.prompt_tokens || usage.total_tokens || 0 + currentUsageData.output_tokens = usage.output_tokens || usage.completion_tokens || 0 + currentUsageData.total_tokens = usage.total_tokens || 0 + + logger.debug('📊 Droid OpenAI response usage:', currentUsageData) + } } catch (parseError) { // 忽略解析错误 } @@ -471,7 +562,7 @@ class DroidRelayService { return false } - if (endpointType === 'openai' || endpointType === 'common') { + if (endpointType === 'openai') { if (lower.includes('data: [done]')) { return true } @@ -479,6 +570,17 @@ class DroidRelayService { if (compact.includes('"finish_reason"')) { return true } + + if (lower.includes('event: response.done') || lower.includes('event: response.completed')) { + return true + } + + if ( + compact.includes('"type":"response.done"') || + compact.includes('"type":"response.completed"') + ) { + return true + } } return false @@ -488,23 +590,107 @@ class DroidRelayService { * 记录从流中解析的 usage 数据 */ async _recordUsageFromStreamData(usageData, apiKeyData, account, model) { - const inputTokens = usageData.input_tokens || 0 - const outputTokens = usageData.output_tokens || 0 - const cacheCreateTokens = usageData.cache_creation_input_tokens || 0 - const cacheReadTokens = usageData.cache_read_input_tokens || 0 - const totalTokens = inputTokens + outputTokens + const normalizedUsage = this._normalizeUsageSnapshot(usageData) + await this._recordUsage(apiKeyData, account, model, normalizedUsage) + return normalizedUsage + } - if (totalTokens > 0) { - await this._recordUsage( - apiKeyData, - account, - model, - inputTokens, - outputTokens, - cacheCreateTokens, - cacheReadTokens - ) + /** + * 标准化 usage 数据,确保字段完整且为数字 + */ + _normalizeUsageSnapshot(usageData = {}) { + const toNumber = (value) => { + if (value === undefined || value === null || value === '') { + return 0 + } + const num = Number(value) + if (!Number.isFinite(num)) { + return 0 + } + return Math.max(0, num) } + + const inputTokens = toNumber( + usageData.input_tokens ?? + usageData.prompt_tokens ?? + usageData.inputTokens ?? + usageData.total_input_tokens + ) + const outputTokens = toNumber( + usageData.output_tokens ?? usageData.completion_tokens ?? usageData.outputTokens + ) + const cacheReadTokens = toNumber( + usageData.cache_read_input_tokens ?? + usageData.cacheReadTokens ?? + usageData.input_tokens_details?.cached_tokens + ) + + const rawCacheCreateTokens = + usageData.cache_creation_input_tokens ?? + usageData.cacheCreateTokens ?? + usageData.cache_tokens ?? + 0 + let cacheCreateTokens = toNumber(rawCacheCreateTokens) + + const ephemeral5m = toNumber( + usageData.cache_creation?.ephemeral_5m_input_tokens ?? usageData.ephemeral_5m_input_tokens + ) + const ephemeral1h = toNumber( + usageData.cache_creation?.ephemeral_1h_input_tokens ?? usageData.ephemeral_1h_input_tokens + ) + + if (cacheCreateTokens === 0 && (ephemeral5m > 0 || ephemeral1h > 0)) { + cacheCreateTokens = ephemeral5m + ephemeral1h + } + + const normalized = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + } + + if (ephemeral5m > 0 || ephemeral1h > 0) { + normalized.cache_creation = { + ephemeral_5m_input_tokens: ephemeral5m, + ephemeral_1h_input_tokens: ephemeral1h + } + } + + return normalized + } + + /** + * 计算 usage 对象的总 token 数 + */ + _getTotalTokens(usageObject = {}) { + const toNumber = (value) => { + if (value === undefined || value === null || value === '') { + return 0 + } + const num = Number(value) + if (!Number.isFinite(num)) { + return 0 + } + return Math.max(0, num) + } + + return ( + toNumber(usageObject.input_tokens) + + toNumber(usageObject.output_tokens) + + toNumber(usageObject.cache_creation_input_tokens) + + toNumber(usageObject.cache_read_input_tokens) + ) + } + + /** + * 提取账户 ID + */ + _extractAccountId(account) { + if (!account || typeof account !== 'object') { + return null + } + return account.id || account.accountId || account.account_id || null } /** @@ -534,7 +720,7 @@ class DroidRelayService { } // OpenAI 特定头 - if (endpointType === 'openai' || endpointType === 'common') { + if (endpointType === 'openai') { headers['x-api-provider'] = 'azure_openai' } @@ -636,34 +822,40 @@ class DroidRelayService { /** * 处理非流式响应 */ - async _handleNonStreamResponse(response, account, apiKeyData, requestBody) { + async _handleNonStreamResponse( + response, + account, + apiKeyData, + requestBody, + clientRequest, + endpointType + ) { const { data } = response // 从响应中提取 usage 数据 const usage = data.usage || {} - // Anthropic 格式 - const inputTokens = usage.input_tokens || 0 - const outputTokens = usage.output_tokens || 0 - const cacheCreateTokens = usage.cache_creation_input_tokens || 0 - const cacheReadTokens = usage.cache_read_input_tokens || 0 - - const totalTokens = inputTokens + outputTokens const model = requestBody.model || 'unknown' - // 记录使用统计 - if (totalTokens > 0) { - await this._recordUsage( - apiKeyData, - account, - model, - inputTokens, - outputTokens, - cacheCreateTokens, - cacheReadTokens - ) + const normalizedUsage = this._normalizeUsageSnapshot(usage) + await this._recordUsage(apiKeyData, account, model, normalizedUsage) + + const totalTokens = this._getTotalTokens(normalizedUsage) + + const usageSummary = { + inputTokens: normalizedUsage.input_tokens || 0, + outputTokens: normalizedUsage.output_tokens || 0, + cacheCreateTokens: normalizedUsage.cache_creation_input_tokens || 0, + cacheReadTokens: normalizedUsage.cache_read_input_tokens || 0 } + await this._applyRateLimitTracking( + clientRequest?.rateLimitInfo, + usageSummary, + model, + endpointType === 'anthropic' ? ' [anthropic]' : ' [openai]' + ) + logger.success(`✅ Droid request completed - Account: ${account.name}, Tokens: ${totalTokens}`) return { @@ -676,51 +868,38 @@ class DroidRelayService { /** * 记录使用统计 */ - async _recordUsage( - apiKeyData, - account, - model, - inputTokens, - outputTokens, - cacheCreateTokens = 0, - cacheReadTokens = 0 - ) { - const totalTokens = inputTokens + outputTokens + async _recordUsage(apiKeyData, account, model, usageObject = {}) { + const totalTokens = this._getTotalTokens(usageObject) + + if (totalTokens <= 0) { + logger.debug('🪙 Droid usage 数据为空,跳过记录') + return + } try { const keyId = apiKeyData?.id - // 记录 API Key 级别的使用统计 + const accountId = this._extractAccountId(account) + if (keyId) { - await redis.incrementTokenUsage( - keyId, + await apiKeyService.recordUsageWithDetails(keyId, usageObject, model, accountId, 'droid') + } else if (accountId) { + await redis.incrementAccountUsage( + accountId, totalTokens, - inputTokens, - outputTokens, - cacheCreateTokens, - cacheReadTokens, + usageObject.input_tokens || 0, + usageObject.output_tokens || 0, + usageObject.cache_creation_input_tokens || 0, + usageObject.cache_read_input_tokens || 0, model, - 0, // ephemeral5mTokens - 0, // ephemeral1hTokens - false // isLongContextRequest + false ) } else { - logger.warn('⚠️ Skipping API Key usage recording: missing apiKeyData.id') + logger.warn('⚠️ 无法记录 Droid usage:缺少 API Key 和账户标识') + return } - // 记录账户级别的使用统计 - await redis.incrementAccountUsage( - account.id, - totalTokens, - inputTokens, - outputTokens, - cacheCreateTokens, - cacheReadTokens, - model, - false // isLongContextRequest - ) - logger.debug( - `📊 Droid usage recorded - Key: ${keyId || 'unknown'}, Account: ${account.id}, Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Total: ${totalTokens}` + `📊 Droid usage recorded - Key: ${keyId || 'unknown'}, Account: ${accountId || 'unknown'}, Model: ${model}, Input: ${usageObject.input_tokens || 0}, Output: ${usageObject.output_tokens || 0}, Cache Create: ${usageObject.cache_creation_input_tokens || 0}, Cache Read: ${usageObject.cache_read_input_tokens || 0}, Total: ${totalTokens}` ) } catch (error) { logger.error('❌ Failed to record Droid usage:', error) diff --git a/src/services/droidScheduler.js b/src/services/droidScheduler.js new file mode 100644 index 00000000..67add5ea --- /dev/null +++ b/src/services/droidScheduler.js @@ -0,0 +1,218 @@ +const droidAccountService = require('./droidAccountService') +const accountGroupService = require('./accountGroupService') +const redis = require('../models/redis') +const logger = require('../utils/logger') + +class DroidScheduler { + constructor() { + this.STICKY_PREFIX = 'droid' + } + + _normalizeEndpointType(endpointType) { + if (!endpointType) { + return 'anthropic' + } + const normalized = String(endpointType).toLowerCase() + if (normalized === 'openai' || normalized === 'common') { + return 'openai' + } + return 'anthropic' + } + + _isTruthy(value) { + if (value === undefined || value === null) { + return false + } + if (typeof value === 'boolean') { + return value + } + if (typeof value === 'string') { + return value.toLowerCase() === 'true' + } + return Boolean(value) + } + + _isAccountActive(account) { + if (!account) { + return false + } + const isActive = this._isTruthy(account.isActive) + if (!isActive) { + return false + } + + const status = (account.status || 'active').toLowerCase() + const unhealthyStatuses = new Set(['error', 'unauthorized', 'blocked']) + return !unhealthyStatuses.has(status) + } + + _isAccountSchedulable(account) { + return this._isTruthy(account?.schedulable ?? true) + } + + _matchesEndpoint(account, endpointType) { + const normalizedEndpoint = this._normalizeEndpointType(endpointType) + const accountEndpoint = this._normalizeEndpointType(account?.endpointType) + if (normalizedEndpoint === accountEndpoint) { + return true + } + + const sharedEndpoints = new Set(['anthropic', 'openai']) + return sharedEndpoints.has(normalizedEndpoint) && sharedEndpoints.has(accountEndpoint) + } + + _sortCandidates(candidates) { + return [...candidates].sort((a, b) => { + const priorityA = parseInt(a.priority, 10) || 50 + const priorityB = parseInt(b.priority, 10) || 50 + + if (priorityA !== priorityB) { + return priorityA - priorityB + } + + const lastUsedA = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0 + const lastUsedB = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0 + + if (lastUsedA !== lastUsedB) { + return lastUsedA - lastUsedB + } + + const createdA = a.createdAt ? new Date(a.createdAt).getTime() : 0 + const createdB = b.createdAt ? new Date(b.createdAt).getTime() : 0 + return createdA - createdB + }) + } + + _composeStickySessionKey(endpointType, sessionHash, apiKeyId) { + if (!sessionHash) { + return null + } + const normalizedEndpoint = this._normalizeEndpointType(endpointType) + const apiKeyPart = apiKeyId || 'default' + return `${this.STICKY_PREFIX}:${normalizedEndpoint}:${apiKeyPart}:${sessionHash}` + } + + async _loadGroupAccounts(groupId) { + const memberIds = await accountGroupService.getGroupMembers(groupId) + if (!memberIds || memberIds.length === 0) { + return [] + } + + const accounts = await Promise.all( + memberIds.map(async (memberId) => { + try { + return await droidAccountService.getAccount(memberId) + } catch (error) { + logger.warn(`⚠️ 获取 Droid 分组成员账号失败: ${memberId}`, error) + return null + } + }) + ) + + return accounts.filter( + (account) => account && this._isAccountActive(account) && this._isAccountSchedulable(account) + ) + } + + async _ensureLastUsedUpdated(accountId) { + try { + await droidAccountService.touchLastUsedAt(accountId) + } catch (error) { + logger.warn(`⚠️ 更新 Droid 账号最后使用时间失败: ${accountId}`, error) + } + } + + async _cleanupStickyMapping(stickyKey) { + if (!stickyKey) { + return + } + try { + await redis.deleteSessionAccountMapping(stickyKey) + } catch (error) { + logger.warn(`⚠️ 清理 Droid 粘性会话映射失败: ${stickyKey}`, error) + } + } + + async selectAccount(apiKeyData, endpointType, sessionHash) { + const normalizedEndpoint = this._normalizeEndpointType(endpointType) + const stickyKey = this._composeStickySessionKey(normalizedEndpoint, sessionHash, apiKeyData?.id) + + let candidates = [] + let isDedicatedBinding = false + + if (apiKeyData?.droidAccountId) { + const binding = apiKeyData.droidAccountId + if (binding.startsWith('group:')) { + const groupId = binding.substring('group:'.length) + logger.info( + `🤖 API Key ${apiKeyData.name || apiKeyData.id} 绑定 Droid 分组 ${groupId},按分组调度` + ) + candidates = await this._loadGroupAccounts(groupId, normalizedEndpoint) + } else { + const account = await droidAccountService.getAccount(binding) + if (account) { + candidates = [account] + isDedicatedBinding = true + } + } + } + + if (!candidates || candidates.length === 0) { + candidates = await droidAccountService.getSchedulableAccounts(normalizedEndpoint) + } + + const filtered = candidates.filter( + (account) => + account && + this._isAccountActive(account) && + this._isAccountSchedulable(account) && + this._matchesEndpoint(account, normalizedEndpoint) + ) + + if (filtered.length === 0) { + throw new Error( + `No available Droid accounts for endpoint ${normalizedEndpoint}${apiKeyData?.droidAccountId ? ' (respecting binding)' : ''}` + ) + } + + if (stickyKey && !isDedicatedBinding) { + const mappedAccountId = await redis.getSessionAccountMapping(stickyKey) + if (mappedAccountId) { + const mappedAccount = filtered.find((account) => account.id === mappedAccountId) + if (mappedAccount) { + await redis.extendSessionAccountMappingTTL(stickyKey) + logger.info( + `🤖 命中 Droid 粘性会话: ${sessionHash} -> ${mappedAccount.name || mappedAccount.id}` + ) + await this._ensureLastUsedUpdated(mappedAccount.id) + return mappedAccount + } + + await this._cleanupStickyMapping(stickyKey) + } + } + + const sorted = this._sortCandidates(filtered) + const selected = sorted[0] + + if (!selected) { + throw new Error( + `No schedulable Droid account available after sorting (${normalizedEndpoint})` + ) + } + + if (stickyKey && !isDedicatedBinding) { + await redis.setSessionAccountMapping(stickyKey, selected.id) + } + + await this._ensureLastUsedUpdated(selected.id) + + logger.info( + `🤖 选择 Droid 账号 ${selected.name || selected.id}(endpoint: ${normalizedEndpoint}, priority: ${selected.priority || 50})` + ) + + return selected + } +} + +module.exports = new DroidScheduler() diff --git a/src/validators/clientDefinitions.js b/src/validators/clientDefinitions.js index b70e3474..89c3e528 100644 --- a/src/validators/clientDefinitions.js +++ b/src/validators/clientDefinitions.js @@ -26,6 +26,14 @@ const CLIENT_DEFINITIONS = { displayName: 'Codex Command Line Tool', description: 'Cursor/Codex command-line interface', icon: '🔷' + }, + + DROID_CLI: { + id: 'droid_cli', + name: 'Droid CLI', + displayName: 'Factory Droid CLI', + description: 'Factory Droid platform command-line interface', + icon: '🤖' } } @@ -33,7 +41,8 @@ const CLIENT_DEFINITIONS = { const CLIENT_IDS = { CLAUDE_CODE: 'claude_code', GEMINI_CLI: 'gemini_cli', - CODEX_CLI: 'codex_cli' + CODEX_CLI: 'codex_cli', + DROID_CLI: 'droid_cli' } // 获取所有客户端定义 diff --git a/src/validators/clientValidator.js b/src/validators/clientValidator.js index 54a87634..13cb38eb 100644 --- a/src/validators/clientValidator.js +++ b/src/validators/clientValidator.js @@ -8,6 +8,7 @@ const { CLIENT_DEFINITIONS, getAllClientDefinitions } = require('./clientDefinit const ClaudeCodeValidator = require('./clients/claudeCodeValidator') const GeminiCliValidator = require('./clients/geminiCliValidator') const CodexCliValidator = require('./clients/codexCliValidator') +const DroidCliValidator = require('./clients/droidCliValidator') /** * 客户端验证器类 @@ -26,6 +27,8 @@ class ClientValidator { return GeminiCliValidator case 'codex_cli': return CodexCliValidator + case 'droid_cli': + return DroidCliValidator default: logger.warn(`Unknown client ID: ${clientId}`) return null @@ -37,7 +40,7 @@ class ClientValidator { * @returns {Array} 客户端ID列表 */ static getSupportedClients() { - return ['claude_code', 'gemini_cli', 'codex_cli'] + return ['claude_code', 'gemini_cli', 'codex_cli', 'droid_cli'] } /** diff --git a/src/validators/clients/claudeCodeValidator.js b/src/validators/clients/claudeCodeValidator.js index f012030b..b538024b 100644 --- a/src/validators/clients/claudeCodeValidator.js +++ b/src/validators/clients/claudeCodeValidator.js @@ -50,7 +50,11 @@ class ClaudeCodeValidator { return false } - const systemEntries = Array.isArray(body.system) ? body.system : [] + const systemEntries = Array.isArray(body.system) ? body.system : null + if (!systemEntries) { + return false + } + for (const entry of systemEntries) { const rawText = typeof entry?.text === 'string' ? entry.text : '' const { bestScore } = bestSimilarityByTemplates(rawText) diff --git a/src/validators/clients/droidCliValidator.js b/src/validators/clients/droidCliValidator.js new file mode 100644 index 00000000..7fde7aa9 --- /dev/null +++ b/src/validators/clients/droidCliValidator.js @@ -0,0 +1,57 @@ +const logger = require('../../utils/logger') +const { CLIENT_DEFINITIONS } = require('../clientDefinitions') + +/** + * Droid CLI 验证器 + * 检查请求是否来自 Factory Droid CLI + */ +class DroidCliValidator { + static getId() { + return CLIENT_DEFINITIONS.DROID_CLI.id + } + + static getName() { + return CLIENT_DEFINITIONS.DROID_CLI.name + } + + static getDescription() { + return CLIENT_DEFINITIONS.DROID_CLI.description + } + + static validate(req) { + try { + const userAgent = req.headers['user-agent'] || '' + const factoryClientHeader = (req.headers['x-factory-client'] || '').toString().toLowerCase() + + const uaMatch = /factory-cli\/(\d+\.\d+\.\d+)/i.exec(userAgent) + const hasFactoryClientHeader = + typeof factoryClientHeader === 'string' && + (factoryClientHeader.includes('droid') || factoryClientHeader.includes('factory-cli')) + + if (!uaMatch && !hasFactoryClientHeader) { + logger.debug(`Droid CLI validation failed - UA mismatch: ${userAgent}`) + return false + } + + // 允许,通过基础验证 + logger.debug( + `Droid CLI validation passed (UA: ${userAgent || 'N/A'}, header: ${factoryClientHeader || 'N/A'})` + ) + return true + } catch (error) { + logger.error('Error in DroidCliValidator:', error) + return false + } + } + + static getInfo() { + return { + id: this.getId(), + name: this.getName(), + description: this.getDescription(), + icon: CLIENT_DEFINITIONS.DROID_CLI.icon + } + } +} + +module.exports = DroidCliValidator diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index e368f685..5f7c485d 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -71,7 +71,7 @@
-
+
Google AI

+ + +
+
+
+
+ +
+
+ +
+
+

+ Droid +

+

Claude Droid

+
+
@@ -447,6 +478,35 @@
+ + + @@ -2992,6 +3052,8 @@ const selectPlatformGroup = (group) => { form.value.platform = 'openai' } else if (group === 'gemini') { form.value.platform = 'gemini' + } else if (group === 'droid') { + form.value.platform = 'droid' } } diff --git a/web/admin-spa/src/components/accounts/GroupManagementModal.vue b/web/admin-spa/src/components/accounts/GroupManagementModal.vue index b793c356..105a4f80 100644 --- a/web/admin-spa/src/components/accounts/GroupManagementModal.vue +++ b/web/admin-spa/src/components/accounts/GroupManagementModal.vue @@ -58,6 +58,10 @@ OpenAI + @@ -120,7 +124,9 @@ ? 'bg-purple-100 text-purple-700' : group.platform === 'gemini' ? 'bg-blue-100 text-blue-700' - : 'bg-gray-100 text-gray-700' + : group.platform === 'openai' + ? 'bg-gray-100 text-gray-700' + : 'bg-cyan-100 text-cyan-700' ]" > {{ @@ -128,7 +134,9 @@ ? 'Claude' : group.platform === 'gemini' ? 'Gemini' - : 'OpenAI' + : group.platform === 'openai' + ? 'OpenAI' + : 'Droid' }} diff --git a/web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue b/web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue index b0a5e0a4..853287d6 100644 --- a/web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue @@ -311,6 +311,10 @@ 仅 OpenAI + @@ -345,7 +349,7 @@ @@ -411,7 +415,7 @@ @@ -457,6 +461,37 @@ +
+ + +
@@ -497,7 +532,17 @@ const props = defineProps({ }, accounts: { type: Object, - default: () => ({ claude: [], gemini: [], openai: [], bedrock: [] }) + default: () => ({ + claude: [], + gemini: [], + openai: [], + bedrock: [], + droid: [], + claudeGroups: [], + geminiGroups: [], + openaiGroups: [], + droidGroups: [] + }) } }) @@ -511,9 +556,11 @@ const localAccounts = ref({ gemini: [], openai: [], bedrock: [], + droid: [], claudeGroups: [], geminiGroups: [], - openaiGroups: [] + openaiGroups: [], + droidGroups: [] }) // 标签相关 @@ -542,6 +589,7 @@ const form = reactive({ geminiAccountId: '', openaiAccountId: '', bedrockAccountId: '', + droidAccountId: '', tags: [], isActive: null // null表示不修改 }) @@ -571,15 +619,23 @@ const removeTag = (index) => { const refreshAccounts = async () => { accountsLoading.value = true try { - const [claudeData, claudeConsoleData, geminiData, openaiData, bedrockData, groupsData] = - await Promise.all([ - apiClient.get('/admin/claude-accounts'), - apiClient.get('/admin/claude-console-accounts'), - apiClient.get('/admin/gemini-accounts'), - apiClient.get('/admin/openai-accounts'), - apiClient.get('/admin/bedrock-accounts'), - apiClient.get('/admin/account-groups') - ]) + const [ + claudeData, + claudeConsoleData, + geminiData, + openaiData, + bedrockData, + droidData, + groupsData + ] = await Promise.all([ + apiClient.get('/admin/claude-accounts'), + apiClient.get('/admin/claude-console-accounts'), + apiClient.get('/admin/gemini-accounts'), + apiClient.get('/admin/openai-accounts'), + apiClient.get('/admin/bedrock-accounts'), + apiClient.get('/admin/droid-accounts'), + apiClient.get('/admin/account-groups') + ]) // 合并Claude OAuth账户和Claude Console账户 const claudeAccounts = [] @@ -627,12 +683,21 @@ const refreshAccounts = async () => { })) } + if (droidData.success) { + localAccounts.value.droid = (droidData.data || []).map((account) => ({ + ...account, + platform: 'droid', + isDedicated: account.accountType === 'dedicated' + })) + } + // 处理分组数据 if (groupsData.success) { const allGroups = groupsData.data || [] localAccounts.value.claudeGroups = allGroups.filter((g) => g.platform === 'claude') localAccounts.value.geminiGroups = allGroups.filter((g) => g.platform === 'gemini') localAccounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai') + localAccounts.value.droidGroups = allGroups.filter((g) => g.platform === 'droid') } showToast('账号列表已刷新', 'success') @@ -720,6 +785,14 @@ const batchUpdateApiKeys = async () => { } } + if (form.droidAccountId !== '') { + if (form.droidAccountId === 'SHARED_POOL') { + updates.droidAccountId = null + } else { + updates.droidAccountId = form.droidAccountId + } + } + // 激活状态 if (form.isActive !== null) { updates.isActive = form.isActive @@ -774,9 +847,11 @@ onMounted(async () => { gemini: props.accounts.gemini || [], openai: props.accounts.openai || [], bedrock: props.accounts.bedrock || [], + droid: props.accounts.droid || [], claudeGroups: props.accounts.claudeGroups || [], geminiGroups: props.accounts.geminiGroups || [], - openaiGroups: props.accounts.openaiGroups || [] + openaiGroups: props.accounts.openaiGroups || [], + droidGroups: props.accounts.droidGroups || [] } } }) diff --git a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue index 75c4d82c..a580d628 100644 --- a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue @@ -616,6 +616,15 @@ /> 仅 OpenAI +

控制此 API Key 可以访问哪些服务 @@ -653,7 +662,7 @@ v-model="form.claudeAccountId" :accounts="localAccounts.claude" default-option-text="使用共享账号池" - :disabled="form.permissions === 'gemini' || form.permissions === 'openai'" + :disabled="form.permissions !== 'all' && form.permissions !== 'claude'" :groups="localAccounts.claudeGroups" placeholder="请选择Claude账号" platform="claude" @@ -667,7 +676,7 @@ v-model="form.geminiAccountId" :accounts="localAccounts.gemini" default-option-text="使用共享账号池" - :disabled="form.permissions === 'claude' || form.permissions === 'openai'" + :disabled="form.permissions !== 'all' && form.permissions !== 'gemini'" :groups="localAccounts.geminiGroups" placeholder="请选择Gemini账号" platform="gemini" @@ -681,7 +690,7 @@ v-model="form.openaiAccountId" :accounts="localAccounts.openai" default-option-text="使用共享账号池" - :disabled="form.permissions === 'claude' || form.permissions === 'gemini'" + :disabled="form.permissions !== 'all' && form.permissions !== 'openai'" :groups="localAccounts.openaiGroups" placeholder="请选择OpenAI账号" platform="openai" @@ -695,12 +704,26 @@ v-model="form.bedrockAccountId" :accounts="localAccounts.bedrock" default-option-text="使用共享账号池" - :disabled="form.permissions === 'gemini' || form.permissions === 'openai'" + :disabled="form.permissions !== 'all' && form.permissions !== 'openai'" :groups="[]" placeholder="请选择Bedrock账号" platform="bedrock" /> +

+ + +

选择专属账号后,此API Key将只使用该账号,不选择则使用共享账号池 @@ -875,7 +898,17 @@ import AccountSelector from '@/components/common/AccountSelector.vue' const props = defineProps({ accounts: { type: Object, - default: () => ({ claude: [], gemini: [] }) + default: () => ({ + claude: [], + gemini: [], + openai: [], + bedrock: [], + droid: [], + claudeGroups: [], + geminiGroups: [], + openaiGroups: [], + droidGroups: [] + }) } }) @@ -889,10 +922,12 @@ const localAccounts = ref({ claude: [], gemini: [], openai: [], - bedrock: [], // 添加 Bedrock 账号列表 + bedrock: [], + droid: [], claudeGroups: [], geminiGroups: [], - openaiGroups: [] + openaiGroups: [], + droidGroups: [] }) // 表单验证状态 @@ -935,7 +970,8 @@ const form = reactive({ claudeAccountId: '', geminiAccountId: '', openaiAccountId: '', - bedrockAccountId: '', // 添加 Bedrock 账号ID + bedrockAccountId: '', + droidAccountId: '', enableModelRestriction: false, restrictedModels: [], modelInput: '', @@ -973,10 +1009,15 @@ onMounted(async () => { claude: props.accounts.claude || [], gemini: props.accounts.gemini || [], openai: openaiAccounts, - bedrock: props.accounts.bedrock || [], // 添加 Bedrock 账号 + bedrock: props.accounts.bedrock || [], + droid: (props.accounts.droid || []).map((account) => ({ + ...account, + platform: 'droid' + })), claudeGroups: props.accounts.claudeGroups || [], geminiGroups: props.accounts.geminiGroups || [], - openaiGroups: props.accounts.openaiGroups || [] + openaiGroups: props.accounts.openaiGroups || [], + droidGroups: props.accounts.droidGroups || [] } } @@ -995,6 +1036,7 @@ const refreshAccounts = async () => { openaiData, openaiResponsesData, bedrockData, + droidData, groupsData ] = await Promise.all([ apiClient.get('/admin/claude-accounts'), @@ -1002,7 +1044,8 @@ const refreshAccounts = async () => { apiClient.get('/admin/gemini-accounts'), apiClient.get('/admin/openai-accounts'), apiClient.get('/admin/openai-responses-accounts'), // 获取 OpenAI-Responses 账号 - apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取 + apiClient.get('/admin/bedrock-accounts'), + apiClient.get('/admin/droid-accounts'), apiClient.get('/admin/account-groups') ]) @@ -1070,12 +1113,21 @@ const refreshAccounts = async () => { })) } + if (droidData.success) { + localAccounts.value.droid = (droidData.data || []).map((account) => ({ + ...account, + platform: 'droid', + isDedicated: account.accountType === 'dedicated' + })) + } + // 处理分组数据 if (groupsData.success) { const allGroups = groupsData.data || [] localAccounts.value.claudeGroups = allGroups.filter((g) => g.platform === 'claude') localAccounts.value.geminiGroups = allGroups.filter((g) => g.platform === 'gemini') localAccounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai') + localAccounts.value.droidGroups = allGroups.filter((g) => g.platform === 'droid') } showToast('账号列表已刷新', 'success') @@ -1346,6 +1398,9 @@ const createApiKey = async () => { if (form.bedrockAccountId) { baseData.bedrockAccountId = form.bedrockAccountId } + if (form.droidAccountId) { + baseData.droidAccountId = form.droidAccountId + } if (form.createType === 'single') { // 单个创建 diff --git a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue index 3bb099f3..2fc1a9ed 100644 --- a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue @@ -449,6 +449,15 @@ /> 仅 OpenAI +

控制此 API Key 可以访问哪些服务 @@ -486,7 +495,7 @@ v-model="form.claudeAccountId" :accounts="localAccounts.claude" default-option-text="使用共享账号池" - :disabled="form.permissions === 'gemini' || form.permissions === 'openai'" + :disabled="form.permissions !== 'all' && form.permissions !== 'claude'" :groups="localAccounts.claudeGroups" placeholder="请选择Claude账号" platform="claude" @@ -500,7 +509,7 @@ v-model="form.geminiAccountId" :accounts="localAccounts.gemini" default-option-text="使用共享账号池" - :disabled="form.permissions === 'claude' || form.permissions === 'openai'" + :disabled="form.permissions !== 'all' && form.permissions !== 'gemini'" :groups="localAccounts.geminiGroups" placeholder="请选择Gemini账号" platform="gemini" @@ -514,7 +523,7 @@ v-model="form.openaiAccountId" :accounts="localAccounts.openai" default-option-text="使用共享账号池" - :disabled="form.permissions === 'claude' || form.permissions === 'gemini'" + :disabled="form.permissions !== 'all' && form.permissions !== 'openai'" :groups="localAccounts.openaiGroups" placeholder="请选择OpenAI账号" platform="openai" @@ -528,12 +537,26 @@ v-model="form.bedrockAccountId" :accounts="localAccounts.bedrock" default-option-text="使用共享账号池" - :disabled="form.permissions === 'gemini' || form.permissions === 'openai'" + :disabled="form.permissions !== 'all' && form.permissions !== 'openai'" :groups="[]" placeholder="请选择Bedrock账号" platform="bedrock" /> +

+ + +

修改绑定账号将影响此API Key的请求路由 @@ -717,7 +740,18 @@ const props = defineProps({ }, accounts: { type: Object, - default: () => ({ claude: [], gemini: [] }) + default: () => ({ + claude: [], + gemini: [], + openai: [], + bedrock: [], + droid: [], + claudeGroups: [], + geminiGroups: [], + openaiGroups: [], + droidGroups: [], + openaiResponses: [] + }) } }) @@ -732,10 +766,12 @@ const localAccounts = ref({ claude: [], gemini: [], openai: [], - bedrock: [], // 添加 Bedrock 账号列表 + bedrock: [], + droid: [], claudeGroups: [], geminiGroups: [], - openaiGroups: [] + openaiGroups: [], + droidGroups: [] }) // 支持的客户端列表 @@ -768,7 +804,8 @@ const form = reactive({ claudeAccountId: '', geminiAccountId: '', openaiAccountId: '', - bedrockAccountId: '', // 添加 Bedrock 账号ID + bedrockAccountId: '', + droidAccountId: '', enableModelRestriction: false, restrictedModels: [], modelInput: '', @@ -930,6 +967,12 @@ const updateApiKey = async () => { data.bedrockAccountId = null } + if (form.droidAccountId) { + data.droidAccountId = form.droidAccountId + } else { + data.droidAccountId = null + } + // 模型限制 - 始终提交这些字段 data.enableModelRestriction = form.enableModelRestriction data.restrictedModels = form.restrictedModels @@ -972,14 +1015,16 @@ const refreshAccounts = async () => { openaiData, openaiResponsesData, bedrockData, + droidData, groupsData ] = await Promise.all([ apiClient.get('/admin/claude-accounts'), apiClient.get('/admin/claude-console-accounts'), apiClient.get('/admin/gemini-accounts'), apiClient.get('/admin/openai-accounts'), - apiClient.get('/admin/openai-responses-accounts'), // 获取 OpenAI-Responses 账号 - apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取 + apiClient.get('/admin/openai-responses-accounts'), + apiClient.get('/admin/bedrock-accounts'), + apiClient.get('/admin/droid-accounts'), apiClient.get('/admin/account-groups') ]) @@ -1047,12 +1092,21 @@ const refreshAccounts = async () => { })) } + if (droidData.success) { + localAccounts.value.droid = (droidData.data || []).map((account) => ({ + ...account, + platform: 'droid', + isDedicated: account.accountType === 'dedicated' + })) + } + // 处理分组数据 if (groupsData.success) { const allGroups = groupsData.data || [] localAccounts.value.claudeGroups = allGroups.filter((g) => g.platform === 'claude') localAccounts.value.geminiGroups = allGroups.filter((g) => g.platform === 'gemini') localAccounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai') + localAccounts.value.droidGroups = allGroups.filter((g) => g.platform === 'droid') } showToast('账号列表已刷新', 'success') @@ -1128,10 +1182,15 @@ onMounted(async () => { claude: props.accounts.claude || [], gemini: props.accounts.gemini || [], openai: openaiAccounts, - bedrock: props.accounts.bedrock || [], // 添加 Bedrock 账号 + bedrock: props.accounts.bedrock || [], + droid: (props.accounts.droid || []).map((account) => ({ + ...account, + platform: 'droid' + })), claudeGroups: props.accounts.claudeGroups || [], geminiGroups: props.accounts.geminiGroups || [], - openaiGroups: props.accounts.openaiGroups || [] + openaiGroups: props.accounts.openaiGroups || [], + droidGroups: props.accounts.droidGroups || [] } } @@ -1168,7 +1227,8 @@ onMounted(async () => { // 处理 OpenAI 账号 - 直接使用后端传来的值(已包含 responses: 前缀) form.openaiAccountId = props.apiKey.openaiAccountId || '' - form.bedrockAccountId = props.apiKey.bedrockAccountId || '' // 添加 Bedrock 账号ID初始化 + form.bedrockAccountId = props.apiKey.bedrockAccountId || '' + form.droidAccountId = props.apiKey.droidAccountId || '' form.restrictedModels = props.apiKey.restrictedModels || [] form.allowedClients = props.apiKey.allowedClients || [] form.tags = props.apiKey.tags || [] diff --git a/web/admin-spa/src/components/common/AccountSelector.vue b/web/admin-spa/src/components/common/AccountSelector.vue index ac9e4894..78b458da 100644 --- a/web/admin-spa/src/components/common/AccountSelector.vue +++ b/web/admin-spa/src/components/common/AccountSelector.vue @@ -104,7 +104,9 @@ ? 'Claude OAuth 专属账号' : platform === 'openai' ? 'OpenAI 专属账号' - : 'OAuth 专属账号' + : platform === 'droid' + ? 'Droid 专属账号' + : 'OAuth 专属账号' }}

['claude', 'gemini', 'openai', 'bedrock'].includes(value) + validator: (value) => ['claude', 'gemini', 'openai', 'bedrock', 'droid'].includes(value) }, accounts: { type: Array, @@ -383,6 +385,8 @@ const filteredOAuthAccounts = computed(() => { } else if (props.platform === 'openai') { // 对于 OpenAI,只显示 openai 类型的账号 accounts = sortedAccounts.value.filter((a) => a.platform === 'openai') + } else if (props.platform === 'droid') { + accounts = sortedAccounts.value.filter((a) => a.platform === 'droid') } else { // 其他平台显示所有非特殊类型的账号 accounts = sortedAccounts.value.filter( diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index 0f07460c..8993c359 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -1740,13 +1740,15 @@ const groupOptions = computed(() => { accountGroups.value.forEach((group) => { options.push({ value: group.id, - label: `${group.name} (${group.platform === 'claude' ? 'Claude' : group.platform === 'gemini' ? 'Gemini' : 'OpenAI'})`, + label: `${group.name} (${group.platform === 'claude' ? 'Claude' : group.platform === 'gemini' ? 'Gemini' : group.platform === 'openai' ? 'OpenAI' : 'Droid'})`, icon: group.platform === 'claude' ? 'fa-brain' : group.platform === 'gemini' ? 'fa-robot' - : 'fa-openai' + : group.platform === 'openai' + ? 'fa-openai' + : 'fa-robot' }) }) return options @@ -2303,8 +2305,11 @@ const loadAccounts = async (forceReload = false) => { // Droid 账户 if (droidData && droidData.success) { const droidAccounts = (droidData.data || []).map((acc) => { - // Droid 不支持 API Key 绑定,固定为 0 - return { ...acc, platform: 'droid', boundApiKeysCount: 0 } + return { + ...acc, + platform: 'droid', + boundApiKeysCount: acc.boundApiKeysCount ?? 0 + } }) allAccounts.push(...droidAccounts) } diff --git a/web/admin-spa/src/views/ApiKeysView.vue b/web/admin-spa/src/views/ApiKeysView.vue index 444e20a6..f24d66bf 100644 --- a/web/admin-spa/src/views/ApiKeysView.vue +++ b/web/admin-spa/src/views/ApiKeysView.vue @@ -511,6 +511,18 @@ {{ getBedrockBindingInfo(key) }}
+ +
+ + + Droid + + + {{ getDroidBindingInfo(key) }} + +
@@ -1182,6 +1195,18 @@ {{ getBedrockBindingInfo(key) }}
+ +
+ + + Droid + + + {{ getDroidBindingInfo(key) }} + +
@@ -1921,9 +1947,11 @@ const accounts = ref({ openai: [], openaiResponses: [], // 添加 OpenAI-Responses 账号列表 bedrock: [], + droid: [], claudeGroups: [], geminiGroups: [], - openaiGroups: [] + openaiGroups: [], + droidGroups: [] }) const editingExpiryKey = ref(null) const expiryEditModalRef = ref(null) @@ -2031,12 +2059,17 @@ const getBindingDisplayStrings = (key) => { appendBindingRow('Bedrock', getBedrockBindingInfo(key)) } + if (key.droidAccountId) { + appendBindingRow('Droid', getDroidBindingInfo(key)) + } + if ( !key.claudeAccountId && !key.claudeConsoleAccountId && !key.geminiAccountId && !key.openaiAccountId && - !key.bedrockAccountId + !key.bedrockAccountId && + !key.droidAccountId ) { collect('共享池') } @@ -2196,6 +2229,7 @@ const loadAccounts = async () => { openaiData, openaiResponsesData, bedrockData, + droidData, groupsData ] = await Promise.all([ apiClient.get('/admin/claude-accounts'), @@ -2204,6 +2238,7 @@ const loadAccounts = async () => { apiClient.get('/admin/openai-accounts'), apiClient.get('/admin/openai-responses-accounts'), // 加载 OpenAI-Responses 账号 apiClient.get('/admin/bedrock-accounts'), + apiClient.get('/admin/droid-accounts'), apiClient.get('/admin/account-groups') ]) @@ -2260,12 +2295,21 @@ const loadAccounts = async () => { })) } + if (droidData.success) { + accounts.value.droid = (droidData.data || []).map((account) => ({ + ...account, + platform: 'droid', + isDedicated: account.accountType === 'dedicated' + })) + } + if (groupsData.success) { // 处理分组数据 const allGroups = groupsData.data || [] accounts.value.claudeGroups = allGroups.filter((g) => g.platform === 'claude') accounts.value.geminiGroups = allGroups.filter((g) => g.platform === 'gemini') accounts.value.openaiGroups = allGroups.filter((g) => g.platform === 'openai') + accounts.value.droidGroups = allGroups.filter((g) => g.platform === 'droid') } } catch (error) { // console.error('加载账户列表失败:', error) @@ -2381,6 +2425,11 @@ const getBoundAccountName = (accountId) => { return `分组-${openaiGroup.name}` } + const droidGroup = accounts.value.droidGroups.find((g) => g.id === groupId) + if (droidGroup) { + return `分组-${droidGroup.name}` + } + // 如果找不到分组,返回分组ID的前8位 return `分组-${groupId.substring(0, 8)}` } @@ -2428,6 +2477,11 @@ const getBoundAccountName = (accountId) => { return `${bedrockAccount.name}` } + const droidAccount = accounts.value.droid.find((acc) => acc.id === accountId) + if (droidAccount) { + return `${droidAccount.name}` + } + // 如果找不到,返回账户ID的前8位 return `${accountId.substring(0, 8)}` } @@ -2530,6 +2584,24 @@ const getBedrockBindingInfo = (key) => { return '' } +const getDroidBindingInfo = (key) => { + if (key.droidAccountId) { + const info = getBoundAccountName(key.droidAccountId) + if (key.droidAccountId.startsWith('group:')) { + return info + } + const account = accounts.value.droid.find((acc) => acc.id === key.droidAccountId) + if (!account) { + return `⚠️ ${info} (账户不存在)` + } + if (account.accountType === 'dedicated') { + return `🔒 专属-${info}` + } + return info + } + return '' +} + // 检查API Key是否过期 const isApiKeyExpired = (expiresAt) => { if (!expiresAt) return false @@ -3654,7 +3726,9 @@ const exportToExcel = () => { ? '仅Gemini' : key.permissions === 'openai' ? '仅OpenAI' - : key.permissions || '', + : key.permissions === 'droid' + ? '仅Droid' + : key.permissions || '', // 限制配置 令牌限制: key.tokenLimit === '0' || key.tokenLimit === 0 ? '无限制' : key.tokenLimit || '', @@ -3686,6 +3760,7 @@ const exportToExcel = () => { OpenAI专属账户: key.openaiAccountId || '', 'Azure OpenAI专属账户': key.azureOpenaiAccountId || '', Bedrock专属账户: key.bedrockAccountId || '', + Droid专属账户: key.droidAccountId || '', // 模型和客户端限制 启用模型限制: key.enableModelRestriction ? '是' : '否',