From 2fc84a6acaa6217ba558aba8d9aa3644bd008f8c Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 9 Oct 2025 23:05:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9EDroid=20cli=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- src/app.js | 3 + src/models/redis.js | 29 + src/routes/admin.js | 217 +++++ src/routes/droidRoutes.js | 135 ++++ src/services/droidAccountService.js | 761 ++++++++++++++++++ src/services/droidRelayService.js | 743 +++++++++++++++++ src/utils/tokenMask.js | 12 +- src/utils/workosOAuthHelper.js | 170 ++++ .../src/components/accounts/AccountForm.vue | 188 ++++- .../src/components/accounts/OAuthFlow.vue | 319 +++++++- web/admin-spa/src/stores/accounts.js | 98 ++- web/admin-spa/src/views/AccountsView.vue | 93 ++- 13 files changed, 2734 insertions(+), 36 deletions(-) create mode 100644 src/routes/droidRoutes.js create mode 100644 src/services/droidAccountService.js create mode 100644 src/services/droidRelayService.js create mode 100644 src/utils/workosOAuthHelper.js diff --git a/package.json b/package.json index 79fa3e23..72ea4720 100644 --- a/package.json +++ b/package.json @@ -69,10 +69,10 @@ "ora": "^5.4.1", "rate-limiter-flexible": "^5.0.5", "socks-proxy-agent": "^8.0.2", + "string-similarity": "^4.0.4", "table": "^6.8.1", "uuid": "^9.0.1", "winston": "^3.11.0", - "string-similarity": "^4.0.4", "winston-daily-rotate-file": "^4.7.1" }, "devDependencies": { diff --git a/src/app.js b/src/app.js index f0bdadb8..13d4d331 100644 --- a/src/app.js +++ b/src/app.js @@ -22,6 +22,7 @@ const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes') const standardGeminiRoutes = require('./routes/standardGeminiRoutes') const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes') const openaiRoutes = require('./routes/openaiRoutes') +const droidRoutes = require('./routes/droidRoutes') const userRoutes = require('./routes/userRoutes') const azureOpenaiRoutes = require('./routes/azureOpenaiRoutes') const webhookRoutes = require('./routes/webhook') @@ -262,6 +263,8 @@ class Application { this.app.use('/openai/gemini', openaiGeminiRoutes) this.app.use('/openai/claude', openaiClaudeRoutes) this.app.use('/openai', openaiRoutes) + // Droid 路由:支持多种 Factory.ai 端点 + this.app.use('/droid', droidRoutes) // Droid (Factory.ai) API 转发 this.app.use('/azure', azureOpenaiRoutes) this.app.use('/admin/webhook', webhookRoutes) diff --git a/src/models/redis.js b/src/models/redis.js index 602d0bca..e90f2d4a 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -1066,6 +1066,35 @@ class RedisClient { const key = `claude:account:${accountId}` return await this.client.del(key) } + + // 🤖 Droid 账户相关操作 + async setDroidAccount(accountId, accountData) { + const key = `droid:account:${accountId}` + await this.client.hset(key, accountData) + } + + async getDroidAccount(accountId) { + const key = `droid:account:${accountId}` + return await this.client.hgetall(key) + } + + async getAllDroidAccounts() { + const keys = await this.client.keys('droid:account:*') + const accounts = [] + for (const key of keys) { + const accountData = await this.client.hgetall(key) + if (accountData && Object.keys(accountData).length > 0) { + accounts.push({ id: key.replace('droid:account:', ''), ...accountData }) + } + } + return accounts + } + + async deleteDroidAccount(accountId) { + const key = `droid:account:${accountId}` + return await this.client.del(key) + } + async setOpenAiAccount(accountId, accountData) { const key = `openai:account:${accountId}` await this.client.hset(key, accountData) diff --git a/src/routes/admin.js b/src/routes/admin.js index f55d092d..119d457d 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -5,6 +5,7 @@ const claudeConsoleAccountService = require('../services/claudeConsoleAccountSer const bedrockAccountService = require('../services/bedrockAccountService') const ccrAccountService = require('../services/ccrAccountService') const geminiAccountService = require('../services/geminiAccountService') +const droidAccountService = require('../services/droidAccountService') const openaiAccountService = require('../services/openaiAccountService') const openaiResponsesAccountService = require('../services/openaiResponsesAccountService') const azureOpenaiAccountService = require('../services/azureOpenaiAccountService') @@ -13,6 +14,11 @@ const redis = require('../models/redis') const { authenticateAdmin } = require('../middleware/auth') const logger = require('../utils/logger') const oauthHelper = require('../utils/oauthHelper') +const { + startDeviceAuthorization, + pollDeviceAuthorization, + WorkOSDeviceAuthError +} = require('../utils/workosOAuthHelper') const CostCalculator = require('../utils/costCalculator') const pricingService = require('../services/pricingService') const claudeCodeHeadersService = require('../services/claudeCodeHeadersService') @@ -8357,4 +8363,215 @@ router.post('/openai-responses-accounts/:id/reset-usage', authenticateAdmin, asy } }) +// 🤖 Droid 账户管理 + +// 生成 Droid OAuth 授权链接 +router.post('/droid-accounts/generate-auth-url', authenticateAdmin, async (req, res) => { + try { + const { proxy } = req.body || {} + const deviceAuth = await startDeviceAuthorization(proxy || null) + + const sessionId = crypto.randomUUID() + const expiresAt = new Date(Date.now() + deviceAuth.expiresIn * 1000).toISOString() + + await redis.setOAuthSession(sessionId, { + deviceCode: deviceAuth.deviceCode, + userCode: deviceAuth.userCode, + verificationUri: deviceAuth.verificationUri, + verificationUriComplete: deviceAuth.verificationUriComplete, + interval: deviceAuth.interval, + proxy: proxy || null, + createdAt: new Date().toISOString(), + expiresAt + }) + + logger.success('🤖 生成 Droid 设备码授权信息成功', { sessionId }) + return res.json({ + success: true, + data: { + sessionId, + userCode: deviceAuth.userCode, + verificationUri: deviceAuth.verificationUri, + verificationUriComplete: deviceAuth.verificationUriComplete, + expiresIn: deviceAuth.expiresIn, + interval: deviceAuth.interval, + instructions: [ + '1. 使用下方验证码进入授权页面并确认访问权限。', + '2. 在授权页面登录 Factory / Droid 账户并点击允许。', + '3. 回到此处点击“完成授权”完成凭证获取。' + ] + } + }) + } catch (error) { + const message = + error instanceof WorkOSDeviceAuthError ? error.message : error.message || '未知错误' + logger.error('❌ 生成 Droid 设备码授权失败:', message) + return res.status(500).json({ error: 'Failed to start Droid device authorization', message }) + } +}) + +// 交换 Droid 授权码 +router.post('/droid-accounts/exchange-code', authenticateAdmin, async (req, res) => { + const { sessionId, proxy } = req.body || {} + try { + if (!sessionId) { + return res.status(400).json({ error: 'Session ID is required' }) + } + + const oauthSession = await redis.getOAuthSession(sessionId) + if (!oauthSession) { + return res.status(400).json({ error: 'Invalid or expired OAuth session' }) + } + + if (oauthSession.expiresAt && new Date() > new Date(oauthSession.expiresAt)) { + await redis.deleteOAuthSession(sessionId) + return res + .status(400) + .json({ error: 'OAuth session has expired, please generate a new authorization URL' }) + } + + if (!oauthSession.deviceCode) { + await redis.deleteOAuthSession(sessionId) + return res.status(400).json({ error: 'OAuth session missing device code, please retry' }) + } + + const proxyConfig = proxy || oauthSession.proxy || null + const tokens = await pollDeviceAuthorization(oauthSession.deviceCode, proxyConfig) + + await redis.deleteOAuthSession(sessionId) + + logger.success('🤖 成功获取 Droid 访问令牌', { sessionId }) + return res.json({ success: true, data: { tokens } }) + } catch (error) { + if (error instanceof WorkOSDeviceAuthError) { + if (error.code === 'authorization_pending' || error.code === 'slow_down') { + const oauthSession = await redis.getOAuthSession(sessionId) + const expiresAt = oauthSession?.expiresAt ? new Date(oauthSession.expiresAt) : null + const remainingSeconds = + expiresAt instanceof Date && !Number.isNaN(expiresAt.getTime()) + ? Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1000)) + : null + + return res.json({ + success: false, + pending: true, + error: error.code, + message: error.message, + retryAfter: error.retryAfter || Number(oauthSession?.interval) || 5, + expiresIn: remainingSeconds + }) + } + + if (error.code === 'expired_token') { + await redis.deleteOAuthSession(sessionId) + return res.status(400).json({ + error: 'Device code expired', + message: '授权已过期,请重新生成设备码并再次授权' + }) + } + + logger.error('❌ Droid 授权失败:', error.message) + return res.status(500).json({ + error: 'Failed to exchange Droid authorization code', + message: error.message, + errorCode: error.code + }) + } + + logger.error('❌ 交换 Droid 授权码失败:', error) + return res.status(500).json({ + error: 'Failed to exchange Droid authorization code', + message: error.message + }) + } +}) + +// 获取所有 Droid 账户 +router.get('/droid-accounts', authenticateAdmin, async (req, res) => { + try { + const accounts = await droidAccountService.getAllAccounts() + + // 添加使用统计 + const accountsWithStats = await Promise.all( + accounts.map(async (account) => { + try { + const usageStats = await redis.getAccountUsageStats(account.id, 'droid') + return { + ...account, + schedulable: account.schedulable === 'true', + usage: { + daily: usageStats.daily, + total: usageStats.total, + averages: usageStats.averages + } + } + } catch (error) { + logger.warn(`Failed to get stats for Droid account ${account.id}:`, error.message) + return { + ...account, + usage: { + daily: { tokens: 0, requests: 0 }, + total: { tokens: 0, requests: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } + }) + ) + + return res.json({ success: true, data: accountsWithStats }) + } catch (error) { + logger.error('Failed to get Droid accounts:', error) + return res.status(500).json({ error: 'Failed to get Droid accounts', message: error.message }) + } +}) + +// 创建 Droid 账户 +router.post('/droid-accounts', authenticateAdmin, async (req, res) => { + try { + const account = await droidAccountService.createAccount(req.body) + logger.success(`Created Droid account: ${account.name} (${account.id})`) + return res.json({ success: true, data: account }) + } catch (error) { + logger.error('Failed to create Droid account:', error) + return res.status(500).json({ error: 'Failed to create Droid account', message: error.message }) + } +}) + +// 更新 Droid 账户 +router.put('/droid-accounts/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + const account = await droidAccountService.updateAccount(id, req.body) + return res.json({ success: true, data: account }) + } catch (error) { + logger.error(`Failed to update Droid account ${req.params.id}:`, error) + return res.status(500).json({ error: 'Failed to update Droid account', message: error.message }) + } +}) + +// 删除 Droid 账户 +router.delete('/droid-accounts/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + await droidAccountService.deleteAccount(id) + return res.json({ success: true, message: 'Droid account deleted successfully' }) + } catch (error) { + logger.error(`Failed to delete Droid account ${req.params.id}:`, error) + return res.status(500).json({ error: 'Failed to delete Droid account', message: error.message }) + } +}) + +// 刷新 Droid 账户 token +router.post('/droid-accounts/:id/refresh-token', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + const result = await droidAccountService.refreshAccessToken(id) + return res.json({ success: true, data: result }) + } catch (error) { + logger.error(`Failed to refresh Droid account token ${req.params.id}:`, error) + return res.status(500).json({ error: 'Failed to refresh token', message: error.message }) + } +}) + module.exports = router diff --git a/src/routes/droidRoutes.js b/src/routes/droidRoutes.js new file mode 100644 index 00000000..1a0dc014 --- /dev/null +++ b/src/routes/droidRoutes.js @@ -0,0 +1,135 @@ +const express = require('express') +const { authenticateApiKey } = require('../middleware/auth') +const droidRelayService = require('../services/droidRelayService') +const logger = require('../utils/logger') + +const router = express.Router() + +/** + * Droid API 转发路由 + * + * 支持多种 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 result = await droidRelayService.relayRequest( + req.body, + req.apiKey, + req, + res, + req.headers, + { endpointType: 'anthropic' } + ) + + // 如果是流式响应,已经在 relayService 中处理了 + if (result.streaming) { + return + } + + // 非流式响应 + res.status(result.statusCode).set(result.headers).send(result.body) + } catch (error) { + logger.error('Droid Claude relay error:', error) + res.status(500).json({ + error: 'internal_server_error', + message: error.message + }) + } +}) + +// OpenAI 端点 - /v1/responses +router.post('/openai/v1/responses', authenticateApiKey, async (req, res) => { + try { + const result = await droidRelayService.relayRequest( + req.body, + req.apiKey, + req, + res, + req.headers, + { endpointType: 'openai' } + ) + + if (result.streaming) { + return + } + + res.status(result.statusCode).set(result.headers).send(result.body) + } catch (error) { + logger.error('Droid OpenAI relay error:', error) + res.status(500).json({ + error: 'internal_server_error', + message: error.message + }) + } +}) + +// 通用 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 { + // 返回可用的模型列表 + const models = [ + { + id: 'claude-opus-4-1-20250805', + object: 'model', + created: Date.now(), + owned_by: 'anthropic' + }, + { + id: 'claude-sonnet-4-5-20250929', + object: 'model', + created: Date.now(), + owned_by: 'anthropic' + }, + { + id: 'gpt-5-2025-08-07', + object: 'model', + created: Date.now(), + owned_by: 'openai' + } + ] + + res.json({ + object: 'list', + data: models + }) + } catch (error) { + logger.error('Droid models list error:', error) + res.status(500).json({ + error: 'internal_server_error', + message: error.message + }) + } +}) + +module.exports = router diff --git a/src/services/droidAccountService.js b/src/services/droidAccountService.js new file mode 100644 index 00000000..3d0c743a --- /dev/null +++ b/src/services/droidAccountService.js @@ -0,0 +1,761 @@ +const { v4: uuidv4 } = require('uuid') +const crypto = require('crypto') +const axios = require('axios') +const redis = require('../models/redis') +const config = require('../../config/config') +const logger = require('../utils/logger') +const { maskToken } = require('../utils/tokenMask') +const ProxyHelper = require('../utils/proxyHelper') +const LRUCache = require('../utils/lruCache') + +/** + * Droid 账户管理服务 + * + * 支持 WorkOS OAuth 集成,管理 Droid (Factory.ai) 账户 + * 提供账户创建、token 刷新、代理配置等功能 + */ +class DroidAccountService { + constructor() { + // WorkOS OAuth 配置 + this.oauthTokenUrl = 'https://api.workos.com/user_management/authenticate' + this.factoryApiBaseUrl = 'https://app.factory.ai/api/llm' + + this.workosClientId = 'client_01HNM792M5G5G1A2THWPXKFMXB' + + // Token 刷新策略 + this.refreshIntervalHours = 6 // 每6小时刷新一次 + this.tokenValidHours = 8 // Token 有效期8小时 + + // 加密相关常量 + this.ENCRYPTION_ALGORITHM = 'aes-256-cbc' + this.ENCRYPTION_SALT = 'droid-account-salt' + + // 🚀 性能优化:缓存派生的加密密钥 + this._encryptionKeyCache = null + + // 🔄 解密结果缓存 + this._decryptCache = new LRUCache(500) + + // 🧹 定期清理缓存(每10分钟) + setInterval( + () => { + this._decryptCache.cleanup() + logger.info('🧹 Droid decrypt cache cleanup completed', this._decryptCache.getStats()) + }, + 10 * 60 * 1000 + ) + } + + /** + * 生成加密密钥(缓存优化) + */ + _generateEncryptionKey() { + if (!this._encryptionKeyCache) { + this._encryptionKeyCache = crypto.scryptSync( + config.security.encryptionKey, + this.ENCRYPTION_SALT, + 32 + ) + logger.info('🔑 Droid encryption key derived and cached for performance optimization') + } + return this._encryptionKeyCache + } + + /** + * 加密敏感数据 + */ + _encryptSensitiveData(text) { + if (!text) { + return '' + } + + const key = this._generateEncryptionKey() + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + + let encrypted = cipher.update(text, 'utf8', 'hex') + encrypted += cipher.final('hex') + + return `${iv.toString('hex')}:${encrypted}` + } + + /** + * 解密敏感数据(带缓存) + */ + _decryptSensitiveData(encryptedText) { + if (!encryptedText) { + return '' + } + + // 🎯 检查缓存 + const cacheKey = crypto.createHash('sha256').update(encryptedText).digest('hex') + const cached = this._decryptCache.get(cacheKey) + if (cached !== undefined) { + return cached + } + + try { + const key = this._generateEncryptionKey() + const parts = encryptedText.split(':') + const iv = Buffer.from(parts[0], 'hex') + const encrypted = parts[1] + + const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + + // 💾 存入缓存(5分钟过期) + this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000) + + return decrypted + } catch (error) { + logger.error('❌ Failed to decrypt Droid data:', error) + return '' + } + } + + /** + * 使用 WorkOS Refresh Token 刷新并验证凭证 + */ + async _refreshTokensWithWorkOS(refreshToken, proxyConfig = null) { + if (!refreshToken || typeof refreshToken !== 'string') { + throw new Error('Refresh Token 无效') + } + + const formData = new URLSearchParams() + formData.append('grant_type', 'refresh_token') + formData.append('refresh_token', refreshToken) + formData.append('client_id', this.workosClientId) + + const requestOptions = { + method: 'POST', + url: this.oauthTokenUrl, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + data: formData.toString(), + timeout: 30000 + } + + if (proxyConfig) { + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + requestOptions.httpAgent = proxyAgent + requestOptions.httpsAgent = proxyAgent + logger.info( + `🌐 使用代理验证 Droid Refresh Token: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } + } + + const response = await axios(requestOptions) + if (!response.data || !response.data.access_token) { + throw new Error('WorkOS OAuth 返回数据无效') + } + + const { + access_token, + refresh_token, + user, + organization_id, + expires_in, + token_type, + authentication_method + } = response.data + + let expiresAt = response.data.expires_at || '' + if (!expiresAt) { + const expiresInSeconds = + typeof expires_in === 'number' && Number.isFinite(expires_in) + ? expires_in + : this.tokenValidHours * 3600 + expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString() + } + + return { + accessToken: access_token, + refreshToken: refresh_token || refreshToken, + expiresAt, + expiresIn: typeof expires_in === 'number' && Number.isFinite(expires_in) ? expires_in : null, + user: user || null, + organizationId: organization_id || '', + tokenType: token_type || 'Bearer', + authenticationMethod: authentication_method || '' + } + } + + /** + * 创建 Droid 账户 + * + * @param {Object} options - 账户配置选项 + * @returns {Promise} 创建的账户信息 + */ + async createAccount(options = {}) { + const { + name = 'Unnamed Droid Account', + description = '', + refreshToken = '', // WorkOS refresh token + accessToken = '', // WorkOS access token (可选) + expiresAt = '', // Token 过期时间 + proxy = null, // { type: 'socks5', host: 'localhost', port: 1080, username: '', password: '' } + isActive = true, + accountType = 'shared', // 'dedicated' or 'shared' + platform = 'droid', + priority = 50, // 调度优先级 (1-100) + schedulable = true, // 是否可被调度 + endpointType = 'anthropic', // 默认端点类型: 'anthropic', 'openai', 'common' + organizationId = '', + ownerEmail = '', + ownerName = '', + userId = '', + tokenType = 'Bearer', + authenticationMethod = '', + expiresIn = null + } = options + + const accountId = uuidv4() + + let normalizedRefreshToken = refreshToken + let normalizedAccessToken = accessToken + let normalizedExpiresAt = expiresAt || '' + let normalizedExpiresIn = expiresIn + let normalizedOrganizationId = organizationId || '' + let normalizedOwnerEmail = ownerEmail || '' + let normalizedOwnerName = ownerName || '' + let normalizedOwnerDisplayName = ownerName || ownerEmail || '' + let normalizedUserId = userId || '' + let normalizedTokenType = tokenType || 'Bearer' + let normalizedAuthenticationMethod = authenticationMethod || '' + 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 refreshed = await this._refreshTokensWithWorkOS(normalizedRefreshToken, proxyConfig) + + normalizedAccessToken = refreshed.accessToken + normalizedRefreshToken = refreshed.refreshToken + normalizedExpiresAt = refreshed.expiresAt || normalizedExpiresAt + normalizedTokenType = refreshed.tokenType || normalizedTokenType + normalizedAuthenticationMethod = + refreshed.authenticationMethod || normalizedAuthenticationMethod + if (refreshed.expiresIn !== null) { + normalizedExpiresIn = refreshed.expiresIn + } + if (refreshed.organizationId) { + normalizedOrganizationId = refreshed.organizationId + } + + 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' + logger.success(`✅ 使用 Refresh Token 成功验证并刷新 Droid 账户: ${name} (${accountId})`) + } catch (error) { + logger.error('❌ 使用 Refresh Token 验证 Droid 账户失败:', error) + throw new Error(`Refresh Token 验证失败:${error.message}`) + } + } + + const accountData = { + id: accountId, + name, + description, + refreshToken: this._encryptSensitiveData(normalizedRefreshToken), + accessToken: this._encryptSensitiveData(normalizedAccessToken), + expiresAt: normalizedExpiresAt || '', + proxy: proxy ? JSON.stringify(proxy) : '', + isActive: isActive.toString(), + accountType, + platform, + priority: priority.toString(), + createdAt: new Date().toISOString(), + lastUsedAt: '', + lastRefreshAt, + status, // created, active, expired, error + errorMessage: '', + schedulable: schedulable.toString(), + endpointType, // anthropic, openai, common + organizationId: normalizedOrganizationId || '', + owner: normalizedOwnerName || normalizedOwnerEmail || '', + ownerEmail: normalizedOwnerEmail || '', + ownerName: normalizedOwnerName || '', + ownerDisplayName: + normalizedOwnerDisplayName || normalizedOwnerName || normalizedOwnerEmail || '', + userId: normalizedUserId || '', + tokenType: normalizedTokenType || 'Bearer', + authenticationMethod: normalizedAuthenticationMethod || '', + expiresIn: + normalizedExpiresIn !== null && normalizedExpiresIn !== undefined + ? String(normalizedExpiresIn) + : '' + } + + await redis.setDroidAccount(accountId, accountData) + + logger.success(`🏢 Created Droid account: ${name} (${accountId}) - Endpoint: ${endpointType}`) + return { id: accountId, ...accountData } + } + + /** + * 获取 Droid 账户信息 + */ + async getAccount(accountId) { + const account = await redis.getDroidAccount(accountId) + if (!account || Object.keys(account).length === 0) { + return null + } + + // 解密敏感数据 + return { + ...account, + refreshToken: this._decryptSensitiveData(account.refreshToken), + accessToken: this._decryptSensitiveData(account.accessToken) + } + } + + /** + * 获取所有 Droid 账户 + */ + async getAllAccounts() { + const accounts = await redis.getAllDroidAccounts() + return accounts.map((account) => ({ + ...account, + // 不解密完整 token,只返回掩码 + refreshToken: account.refreshToken ? '***ENCRYPTED***' : '', + accessToken: account.accessToken + ? maskToken(this._decryptSensitiveData(account.accessToken)) + : '' + })) + } + + /** + * 更新 Droid 账户 + */ + async updateAccount(accountId, updates) { + const account = await this.getAccount(accountId) + if (!account) { + throw new Error(`Droid account not found: ${accountId}`) + } + + const sanitizedUpdates = { ...updates } + + if (typeof sanitizedUpdates.accessToken === 'string') { + sanitizedUpdates.accessToken = sanitizedUpdates.accessToken.trim() + } + if (typeof sanitizedUpdates.refreshToken === 'string') { + sanitizedUpdates.refreshToken = sanitizedUpdates.refreshToken.trim() + } + + const parseProxyConfig = (value) => { + if (!value) { + return null + } + if (typeof value === 'object') { + return value + } + if (typeof value === 'string' && value.trim()) { + try { + return JSON.parse(value) + } catch (error) { + logger.warn('⚠️ Failed to parse stored Droid proxy config:', error.message) + } + } + return null + } + + let proxyConfig = null + if (updates.proxy !== undefined) { + if (updates.proxy && typeof updates.proxy === 'object') { + proxyConfig = updates.proxy + sanitizedUpdates.proxy = JSON.stringify(updates.proxy) + } else if (typeof updates.proxy === 'string' && updates.proxy.trim()) { + proxyConfig = parseProxyConfig(updates.proxy) + sanitizedUpdates.proxy = updates.proxy + } else { + sanitizedUpdates.proxy = '' + } + } else if (account.proxy) { + proxyConfig = parseProxyConfig(account.proxy) + } + + const hasNewRefreshToken = + typeof sanitizedUpdates.refreshToken === 'string' && sanitizedUpdates.refreshToken + + if (hasNewRefreshToken) { + try { + const refreshed = await this._refreshTokensWithWorkOS( + sanitizedUpdates.refreshToken, + proxyConfig + ) + + sanitizedUpdates.accessToken = refreshed.accessToken + sanitizedUpdates.refreshToken = refreshed.refreshToken || sanitizedUpdates.refreshToken + sanitizedUpdates.expiresAt = + refreshed.expiresAt || sanitizedUpdates.expiresAt || account.expiresAt || '' + + if (refreshed.expiresIn !== null && refreshed.expiresIn !== undefined) { + sanitizedUpdates.expiresIn = String(refreshed.expiresIn) + } + + sanitizedUpdates.tokenType = refreshed.tokenType || account.tokenType || 'Bearer' + sanitizedUpdates.authenticationMethod = + refreshed.authenticationMethod || account.authenticationMethod || '' + sanitizedUpdates.organizationId = + sanitizedUpdates.organizationId || + refreshed.organizationId || + account.organizationId || + '' + sanitizedUpdates.lastRefreshAt = new Date().toISOString() + sanitizedUpdates.status = 'active' + sanitizedUpdates.errorMessage = '' + + if (refreshed.user) { + const userInfo = refreshed.user + const email = typeof userInfo.email === 'string' ? userInfo.email.trim() : '' + if (email) { + sanitizedUpdates.ownerEmail = email + } + + 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) { + sanitizedUpdates.ownerName = derivedName + sanitizedUpdates.ownerDisplayName = derivedName + sanitizedUpdates.owner = derivedName + } else if (sanitizedUpdates.ownerEmail) { + sanitizedUpdates.ownerName = sanitizedUpdates.ownerName || sanitizedUpdates.ownerEmail + sanitizedUpdates.ownerDisplayName = + sanitizedUpdates.ownerDisplayName || sanitizedUpdates.ownerEmail + sanitizedUpdates.owner = sanitizedUpdates.owner || sanitizedUpdates.ownerEmail + } + + if (typeof userInfo.id === 'string' && userInfo.id.trim()) { + sanitizedUpdates.userId = userInfo.id.trim() + } + } + } catch (error) { + logger.error('❌ 使用新的 Refresh Token 更新 Droid 账户失败:', error) + throw new Error(`Refresh Token 验证失败:${error.message || '未知错误'}`) + } + } + + if (sanitizedUpdates.proxy === undefined) { + sanitizedUpdates.proxy = account.proxy || '' + } + + const encryptedUpdates = { ...sanitizedUpdates } + + if (sanitizedUpdates.refreshToken !== undefined) { + encryptedUpdates.refreshToken = this._encryptSensitiveData(sanitizedUpdates.refreshToken) + } + if (sanitizedUpdates.accessToken !== undefined) { + encryptedUpdates.accessToken = this._encryptSensitiveData(sanitizedUpdates.accessToken) + } + + const updatedData = { + ...account, + ...encryptedUpdates, + refreshToken: + encryptedUpdates.refreshToken || this._encryptSensitiveData(account.refreshToken), + accessToken: encryptedUpdates.accessToken || this._encryptSensitiveData(account.accessToken), + proxy: encryptedUpdates.proxy + } + + await redis.setDroidAccount(accountId, updatedData) + logger.info(`✅ Updated Droid account: ${accountId}`) + + return this.getAccount(accountId) + } + + /** + * 删除 Droid 账户 + */ + async deleteAccount(accountId) { + await redis.deleteDroidAccount(accountId) + logger.success(`🗑️ Deleted Droid account: ${accountId}`) + } + + /** + * 刷新 Droid 账户的 access token + * + * 使用 WorkOS OAuth refresh token 刷新 access token + */ + async refreshAccessToken(accountId, proxyConfig = null) { + const account = await this.getAccount(accountId) + if (!account) { + throw new Error(`Droid account not found: ${accountId}`) + } + + if (!account.refreshToken) { + throw new Error(`Droid account ${accountId} has no refresh token`) + } + + logger.info(`🔄 Refreshing Droid account token: ${account.name} (${accountId})`) + + try { + const proxy = proxyConfig || (account.proxy ? JSON.parse(account.proxy) : null) + const refreshed = await this._refreshTokensWithWorkOS(account.refreshToken, proxy) + + // 更新账户信息 + await this.updateAccount(accountId, { + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken || account.refreshToken, + expiresAt: refreshed.expiresAt, + expiresIn: + refreshed.expiresIn !== null && refreshed.expiresIn !== undefined + ? String(refreshed.expiresIn) + : account.expiresIn, + tokenType: refreshed.tokenType || account.tokenType || 'Bearer', + authenticationMethod: refreshed.authenticationMethod || account.authenticationMethod || '', + organizationId: refreshed.organizationId || account.organizationId, + lastRefreshAt: new Date().toISOString(), + status: 'active', + errorMessage: '' + }) + + // 记录用户信息 + if (refreshed.user) { + const { user } = refreshed + const updates = {} + logger.info( + `✅ Droid token refreshed for: ${user.email} (${user.first_name} ${user.last_name})` + ) + logger.info(` Organization ID: ${refreshed.organizationId || 'N/A'}`) + + if (typeof user.email === 'string' && user.email.trim()) { + updates.ownerEmail = user.email.trim() + } + const nameParts = [] + if (typeof user.first_name === 'string' && user.first_name.trim()) { + nameParts.push(user.first_name.trim()) + } + if (typeof user.last_name === 'string' && user.last_name.trim()) { + nameParts.push(user.last_name.trim()) + } + const derivedName = + nameParts.join(' ').trim() || + (typeof user.name === 'string' ? user.name.trim() : '') || + (typeof user.display_name === 'string' ? user.display_name.trim() : '') + + if (derivedName) { + updates.ownerName = derivedName + updates.ownerDisplayName = derivedName + updates.owner = derivedName + } else if (updates.ownerEmail) { + updates.owner = updates.ownerEmail + updates.ownerName = updates.ownerEmail + updates.ownerDisplayName = updates.ownerEmail + } + + if (typeof user.id === 'string' && user.id.trim()) { + updates.userId = user.id.trim() + } + + if (Object.keys(updates).length > 0) { + await this.updateAccount(accountId, updates) + } + } + + logger.success(`✅ Droid account token refreshed successfully: ${accountId}`) + + return { + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken || account.refreshToken, + expiresAt: refreshed.expiresAt + } + } catch (error) { + logger.error(`❌ Failed to refresh Droid account token: ${accountId}`, error) + + // 更新账户状态为错误 + await this.updateAccount(accountId, { + status: 'error', + errorMessage: error.message || 'Token refresh failed' + }) + + throw error + } + } + + /** + * 检查 token 是否需要刷新 + */ + shouldRefreshToken(account) { + if (!account.lastRefreshAt) { + return true // 从未刷新过 + } + + const lastRefreshTime = new Date(account.lastRefreshAt).getTime() + const hoursSinceRefresh = (Date.now() - lastRefreshTime) / (1000 * 60 * 60) + + return hoursSinceRefresh >= this.refreshIntervalHours + } + + /** + * 获取有效的 access token(自动刷新) + */ + async getValidAccessToken(accountId) { + let account = await this.getAccount(accountId) + if (!account) { + throw new Error(`Droid account not found: ${accountId}`) + } + + // 检查是否需要刷新 + if (this.shouldRefreshToken(account)) { + logger.info(`🔄 Droid account token needs refresh: ${accountId}`) + const proxyConfig = account.proxy ? JSON.parse(account.proxy) : null + await this.refreshAccessToken(accountId, proxyConfig) + account = await this.getAccount(accountId) + } + + if (!account.accessToken) { + throw new Error(`Droid account ${accountId} has no valid access token`) + } + + return account.accessToken + } + + /** + * 获取可调度的 Droid 账户列表 + */ + async getSchedulableAccounts(endpointType = null) { + const allAccounts = await redis.getAllDroidAccounts() + + return allAccounts + .filter((account) => { + // 基本过滤条件 + const isSchedulable = + account.isActive === 'true' && + account.schedulable === 'true' && + account.status === 'active' + + // 如果指定了端点类型,进一步过滤 + if (endpointType) { + return isSchedulable && account.endpointType === endpointType + } + + return isSchedulable + }) + .map((account) => ({ + ...account, + priority: parseInt(account.priority, 10) || 50, + // 解密 accessToken 用于使用 + accessToken: this._decryptSensitiveData(account.accessToken) + })) + .sort((a, b) => a.priority - b.priority) // 按优先级排序 + } + + /** + * 选择一个可用的 Droid 账户(简单轮询) + */ + async selectAccount(endpointType = null) { + let accounts = await this.getSchedulableAccounts(endpointType) + + if (accounts.length === 0 && endpointType) { + logger.warn( + `No Droid accounts found for endpoint ${endpointType}, falling back to any available account` + ) + accounts = await this.getSchedulableAccounts(null) + } + + if (accounts.length === 0) { + throw new Error( + `No schedulable Droid accounts available${endpointType ? ` for endpoint type: ${endpointType}` : ''}` + ) + } + + // 简单轮询:选择最高优先级且最久未使用的账户 + let selectedAccount = accounts[0] + for (const account of accounts) { + if (account.priority < selectedAccount.priority) { + selectedAccount = account + } else if (account.priority === selectedAccount.priority) { + // 相同优先级,选择最久未使用的 + const selectedLastUsed = new Date(selectedAccount.lastUsedAt || 0).getTime() + const accountLastUsed = new Date(account.lastUsedAt || 0).getTime() + if (accountLastUsed < selectedLastUsed) { + selectedAccount = account + } + } + } + + // 更新最后使用时间 + await this.updateAccount(selectedAccount.id, { + lastUsedAt: new Date().toISOString() + }) + + logger.info( + `✅ Selected Droid account: ${selectedAccount.name} (${selectedAccount.id}) - Endpoint: ${selectedAccount.endpointType}` + ) + + return selectedAccount + } + + /** + * 获取 Factory.ai API 的完整 URL + */ + getFactoryApiUrl(endpointType, endpoint) { + const baseUrls = { + anthropic: `${this.factoryApiBaseUrl}/a${endpoint}`, + openai: `${this.factoryApiBaseUrl}/o${endpoint}`, + common: `${this.factoryApiBaseUrl}/o${endpoint}` + } + + return baseUrls[endpointType] || baseUrls.common + } +} + +// 导出单例 +module.exports = new DroidAccountService() diff --git a/src/services/droidRelayService.js b/src/services/droidRelayService.js new file mode 100644 index 00000000..45914023 --- /dev/null +++ b/src/services/droidRelayService.js @@ -0,0 +1,743 @@ +const https = require('https') +const axios = require('axios') +const ProxyHelper = require('../utils/proxyHelper') +const droidAccountService = require('./droidAccountService') +const redis = require('../models/redis') +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 MODEL_REASONING_CONFIG = { + 'claude-opus-4-1-20250805': 'off', + 'claude-sonnet-4-20250514': 'medium', + 'claude-sonnet-4-5-20250929': 'high', + 'gpt-5-2025-08-07': 'high', + 'gpt-5-codex': 'off' +} + +const VALID_REASONING_LEVELS = new Set(['low', 'medium', 'high']) + +/** + * Droid API 转发服务 + */ + +class DroidRelayService { + constructor() { + this.factoryApiBaseUrl = 'https://app.factory.ai/api/llm' + + this.endpoints = { + anthropic: '/a/v1/messages', + openai: '/o/v1/responses', + common: '/o/v1/chat/completions' + } + + this.userAgent = 'factory-cli/0.19.4' + this.systemPrompt = SYSTEM_PROMPT + this.modelReasoningMap = new Map() + + Object.entries(MODEL_REASONING_CONFIG).forEach(([modelId, level]) => { + if (!modelId) { + return + } + const normalized = typeof level === 'string' ? level.toLowerCase() : '' + this.modelReasoningMap.set(modelId, normalized) + }) + } + + async relayRequest( + requestBody, + apiKeyData, + clientRequest, + clientResponse, + clientHeaders, + options = {} + ) { + const { endpointType = 'anthropic' } = options + const keyInfo = apiKeyData || {} + + try { + logger.info( + `📤 Processing Droid API request for key: ${keyInfo.name || keyInfo.id || 'unknown'}, endpoint: ${endpointType}` + ) + + // 选择一个可用的 Droid 账户 + const account = await droidAccountService.selectAccount(endpointType) + + if (!account) { + throw new Error(`No available Droid account for endpoint type: ${endpointType}`) + } + + // 获取有效的 access token(自动刷新) + const accessToken = await droidAccountService.getValidAccessToken(account.id) + + // 获取 Factory.ai API URL + const endpoint = this.endpoints[endpointType] + const apiUrl = `${this.factoryApiBaseUrl}${endpoint}` + + logger.info(`🌐 Forwarding to Factory.ai: ${apiUrl}`) + + // 获取代理配置 + const proxyConfig = account.proxy ? JSON.parse(account.proxy) : null + const proxyAgent = proxyConfig ? ProxyHelper.createProxyAgent(proxyConfig) : null + + if (proxyAgent) { + logger.info(`🌐 Using proxy: ${ProxyHelper.getProxyDescription(proxyConfig)}`) + } + + // 构建请求头 + const headers = this._buildHeaders(accessToken, requestBody, endpointType, clientHeaders) + + // 处理请求体(注入 system prompt 等) + const processedBody = this._processRequestBody(requestBody, endpointType) + + // 发送请求 + const isStreaming = processedBody.stream !== false + + // 根据是否流式选择不同的处理方式 + if (isStreaming) { + // 流式响应:使用原生 https 模块以更好地控制流 + return await this._handleStreamRequest( + apiUrl, + headers, + processedBody, + proxyAgent, + clientResponse, + account, + keyInfo, + requestBody, + endpointType + ) + } else { + // 非流式响应:使用 axios + const requestOptions = { + method: 'POST', + url: apiUrl, + headers, + data: processedBody, + timeout: 120000, // 2分钟超时 + responseType: 'json', + ...(proxyAgent && { + httpAgent: proxyAgent, + httpsAgent: proxyAgent + }) + } + + const response = await axios(requestOptions) + + logger.info(`✅ Factory.ai response status: ${response.status}`) + + // 处理非流式响应 + return this._handleNonStreamResponse(response, account, keyInfo, requestBody) + } + } catch (error) { + logger.error(`❌ Droid relay error: ${error.message}`, error) + + if (error.response) { + // HTTP 错误响应 + return { + statusCode: error.response.status, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify( + error.response.data || { + error: 'upstream_error', + message: error.message + } + ) + } + } + + // 网络错误或其他错误 + return { + statusCode: 500, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + error: 'relay_error', + message: error.message + }) + } + } + } + + /** + * 处理流式请求 + */ + async _handleStreamRequest( + apiUrl, + headers, + processedBody, + proxyAgent, + clientResponse, + account, + apiKeyData, + requestBody, + endpointType + ) { + return new Promise((resolve, reject) => { + const url = new URL(apiUrl) + const bodyString = JSON.stringify(processedBody) + const contentLength = Buffer.byteLength(bodyString) + const requestHeaders = { + ...headers, + 'content-length': contentLength.toString() + } + let responseStarted = false + let responseCompleted = false + let settled = false + let upstreamResponse = null + let completionWindow = '' + let hasForwardedData = false + + const resolveOnce = (value) => { + if (settled) { + return + } + settled = true + resolve(value) + } + + const rejectOnce = (error) => { + if (settled) { + return + } + settled = true + reject(error) + } + + const handleStreamError = (error) => { + if (responseStarted) { + const isConnectionReset = + error && (error.code === 'ECONNRESET' || error.message === 'aborted') + const upstreamComplete = + responseCompleted || upstreamResponse?.complete || clientResponse.writableEnded + + if (isConnectionReset && (upstreamComplete || hasForwardedData)) { + logger.debug('🔁 Droid stream连接在响应阶段被重置,视为正常结束:', { + message: error?.message, + code: error?.code + }) + if (!clientResponse.destroyed && !clientResponse.writableEnded) { + clientResponse.end() + } + resolveOnce({ statusCode: 200, streaming: true }) + return + } + + logger.error('❌ Droid stream error:', error) + if (!clientResponse.destroyed && !clientResponse.writableEnded) { + clientResponse.end() + } + resolveOnce({ statusCode: 500, streaming: true, error }) + } else { + rejectOnce(error) + } + } + + const options = { + hostname: url.hostname, + port: url.port || 443, + path: url.pathname, + method: 'POST', + headers: requestHeaders, + agent: proxyAgent, + timeout: 120000 + } + + const req = https.request(options, (res) => { + upstreamResponse = res + logger.info(`✅ Factory.ai stream response status: ${res.statusCode}`) + + // 错误响应 + if (res.statusCode !== 200) { + const chunks = [] + + res.on('data', (chunk) => { + chunks.push(chunk) + logger.info(`📦 got ${chunk.length} bytes of data`) + }) + + res.on('end', () => { + logger.info('✅ res.end() reached') + const body = Buffer.concat(chunks).toString() + logger.error(`❌ Factory.ai error response body: ${body || '(empty)'}`) + if (!clientResponse.headersSent) { + clientResponse.status(res.statusCode).json({ + error: 'upstream_error', + details: body + }) + } + resolveOnce({ statusCode: res.statusCode, streaming: true }) + }) + + res.on('close', () => { + logger.warn('⚠️ response closed before end event') + }) + + res.on('error', handleStreamError) + + return + } + + responseStarted = true + + // 设置流式响应头 + clientResponse.setHeader('Content-Type', 'text/event-stream') + clientResponse.setHeader('Cache-Control', 'no-cache') + clientResponse.setHeader('Connection', 'keep-alive') + + // Usage 数据收集 + let buffer = '' + const currentUsageData = {} + const model = requestBody.model || 'unknown' + + // 处理 SSE 流 + res.on('data', (chunk) => { + const chunkStr = chunk.toString() + completionWindow = (completionWindow + chunkStr).slice(-1024) + hasForwardedData = true + + // 转发数据到客户端 + clientResponse.write(chunk) + + // 解析 usage 数据(根据端点类型) + if (endpointType === 'anthropic') { + // Anthropic Messages API 格式 + this._parseAnthropicUsageFromSSE(chunkStr, buffer, currentUsageData) + } else if (endpointType === 'openai' || endpointType === 'common') { + // OpenAI Chat Completions 格式 + this._parseOpenAIUsageFromSSE(chunkStr, buffer, currentUsageData) + } + + if (!responseCompleted && this._detectStreamCompletion(completionWindow, endpointType)) { + responseCompleted = true + } + + buffer += chunkStr + }) + + res.on('end', async () => { + responseCompleted = true + clientResponse.end() + + // 记录 usage 数据 + await this._recordUsageFromStreamData(currentUsageData, apiKeyData, account, model) + + logger.success(`✅ Droid stream completed - Account: ${account.name}`) + resolveOnce({ statusCode: 200, streaming: true }) + }) + + res.on('error', handleStreamError) + + res.on('close', () => { + if (settled) { + return + } + + if (responseCompleted) { + if (!clientResponse.destroyed && !clientResponse.writableEnded) { + clientResponse.end() + } + resolveOnce({ statusCode: 200, streaming: true }) + } else { + handleStreamError(new Error('Upstream stream closed unexpectedly')) + } + }) + }) + + // 客户端断开连接时清理 + clientResponse.on('close', () => { + if (req && !req.destroyed) { + req.destroy() + } + }) + + req.on('error', handleStreamError) + + req.on('timeout', () => { + req.destroy() + logger.error('❌ Droid request timeout') + handleStreamError(new Error('Request timeout')) + }) + + // 写入请求体 + req.end(bodyString) + }) + } + + /** + * 从 SSE 流中解析 Anthropic usage 数据 + */ + _parseAnthropicUsageFromSSE(chunkStr, buffer, currentUsageData) { + try { + // 分割成行 + const lines = (buffer + chunkStr).split('\n') + + for (const line of lines) { + if (line.startsWith('data: ') && line.length > 6) { + try { + const jsonStr = line.slice(6) + const data = JSON.parse(jsonStr) + + // message_start 包含 input tokens 和 cache tokens + if (data.type === 'message_start' && data.message && data.message.usage) { + currentUsageData.input_tokens = data.message.usage.input_tokens || 0 + currentUsageData.cache_creation_input_tokens = + data.message.usage.cache_creation_input_tokens || 0 + currentUsageData.cache_read_input_tokens = + data.message.usage.cache_read_input_tokens || 0 + + // 详细的缓存类型 + if (data.message.usage.cache_creation) { + currentUsageData.cache_creation = { + ephemeral_5m_input_tokens: + data.message.usage.cache_creation.ephemeral_5m_input_tokens || 0, + ephemeral_1h_input_tokens: + data.message.usage.cache_creation.ephemeral_1h_input_tokens || 0 + } + } + + logger.debug('📊 Droid Anthropic input usage:', currentUsageData) + } + + // message_delta 包含 output tokens + if (data.type === 'message_delta' && data.usage) { + currentUsageData.output_tokens = data.usage.output_tokens || 0 + logger.debug('📊 Droid Anthropic output usage:', currentUsageData.output_tokens) + } + } catch (parseError) { + // 忽略解析错误 + } + } + } + } catch (error) { + logger.debug('Error parsing Anthropic usage:', error) + } + } + + /** + * 从 SSE 流中解析 OpenAI usage 数据 + */ + _parseOpenAIUsageFromSSE(chunkStr, buffer, currentUsageData) { + try { + // OpenAI Chat Completions 流式格式 + const lines = (buffer + chunkStr).split('\n') + + for (const line of lines) { + if (line.startsWith('data: ') && line.length > 6) { + try { + const jsonStr = line.slice(6) + if (jsonStr === '[DONE]') { + continue + } + + const data = JSON.parse(jsonStr) + + // OpenAI 格式在流结束时可能包含 usage + if (data.usage) { + currentUsageData.input_tokens = data.usage.prompt_tokens || 0 + currentUsageData.output_tokens = data.usage.completion_tokens || 0 + currentUsageData.total_tokens = data.usage.total_tokens || 0 + + logger.debug('📊 Droid OpenAI usage:', currentUsageData) + } + } catch (parseError) { + // 忽略解析错误 + } + } + } + } catch (error) { + logger.debug('Error parsing OpenAI usage:', error) + } + } + + /** + * 检测流式响应是否已经包含终止标记 + */ + _detectStreamCompletion(windowStr, endpointType) { + if (!windowStr) { + return false + } + + const lower = windowStr.toLowerCase() + const compact = lower.replace(/\s+/g, '') + + if (endpointType === 'anthropic') { + if (lower.includes('event: message_stop')) { + return true + } + if (compact.includes('"type":"message_stop"')) { + return true + } + return false + } + + if (endpointType === 'openai' || endpointType === 'common') { + if (lower.includes('data: [done]')) { + return true + } + + if (compact.includes('"finish_reason"')) { + return true + } + } + + return false + } + + /** + * 记录从流中解析的 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 + + if (totalTokens > 0) { + await this._recordUsage( + apiKeyData, + account, + model, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens + ) + } + } + + /** + * 构建请求头 + */ + _buildHeaders(accessToken, requestBody, endpointType, clientHeaders = {}) { + const headers = { + 'content-type': 'application/json', + authorization: `Bearer ${accessToken}`, + 'user-agent': this.userAgent, + 'x-factory-client': 'cli', + connection: 'keep-alive' + } + + // Anthropic 特定头 + if (endpointType === 'anthropic') { + headers['accept'] = 'application/json' + headers['anthropic-version'] = '2023-06-01' + headers['x-api-key'] = 'placeholder' + headers['x-api-provider'] = 'anthropic' + + // 处理 anthropic-beta 头 + const reasoningLevel = this._getReasoningLevel(requestBody) + if (reasoningLevel) { + headers['anthropic-beta'] = 'interleaved-thinking-2025-05-14' + } + } + + // OpenAI 特定头 + if (endpointType === 'openai' || endpointType === 'common') { + headers['x-api-provider'] = 'azure_openai' + } + + // 生成会话 ID(如果客户端没有提供) + headers['x-session-id'] = clientHeaders['x-session-id'] || this._generateUUID() + + return headers + } + + /** + * 处理请求体(注入 system prompt 等) + */ + _processRequestBody(requestBody, endpointType) { + const processedBody = { ...requestBody } + + // 确保 stream 字段存在 + if (processedBody.stream === undefined) { + processedBody.stream = true + } + + // Anthropic 端点:处理 thinking 字段 + if (endpointType === 'anthropic') { + if (this.systemPrompt) { + const promptBlock = { type: 'text', text: this.systemPrompt } + if (Array.isArray(processedBody.system)) { + const hasPrompt = processedBody.system.some( + (item) => item && item.type === 'text' && item.text === this.systemPrompt + ) + if (!hasPrompt) { + processedBody.system = [promptBlock, ...processedBody.system] + } + } else { + processedBody.system = [promptBlock] + } + } + + const reasoningLevel = this._getReasoningLevel(requestBody) + if (reasoningLevel) { + const budgetTokens = { + low: 4096, + medium: 12288, + high: 24576 + } + processedBody.thinking = { + type: 'enabled', + budget_tokens: budgetTokens[reasoningLevel] + } + } else { + delete processedBody.thinking + } + } + + // OpenAI 端点:处理 reasoning 字段 + if (endpointType === 'openai') { + if (this.systemPrompt) { + if (processedBody.instructions) { + if (!processedBody.instructions.startsWith(this.systemPrompt)) { + processedBody.instructions = `${this.systemPrompt}${processedBody.instructions}` + } + } else { + processedBody.instructions = this.systemPrompt + } + } + + const reasoningLevel = this._getReasoningLevel(requestBody) + if (reasoningLevel) { + processedBody.reasoning = { + effort: reasoningLevel, + summary: 'auto' + } + } else { + delete processedBody.reasoning + } + } + + return processedBody + } + + /** + * 获取推理级别(如果在 requestBody 中配置) + */ + _getReasoningLevel(requestBody) { + if (!requestBody || !requestBody.model) { + return null + } + + const configured = this.modelReasoningMap.get(requestBody.model) + if (!configured) { + return null + } + + if (!VALID_REASONING_LEVELS.has(configured)) { + return null + } + + return configured + } + + /** + * 处理非流式响应 + */ + async _handleNonStreamResponse(response, account, apiKeyData, requestBody) { + 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 + ) + } + + logger.success(`✅ Droid request completed - Account: ${account.name}, Tokens: ${totalTokens}`) + + return { + statusCode: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + } + } + + /** + * 记录使用统计 + */ + async _recordUsage( + apiKeyData, + account, + model, + inputTokens, + outputTokens, + cacheCreateTokens = 0, + cacheReadTokens = 0 + ) { + const totalTokens = inputTokens + outputTokens + + try { + const keyId = apiKeyData?.id + // 记录 API Key 级别的使用统计 + if (keyId) { + await redis.incrementTokenUsage( + keyId, + totalTokens, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + model, + 0, // ephemeral5mTokens + 0, // ephemeral1hTokens + false // isLongContextRequest + ) + } else { + logger.warn('⚠️ Skipping API Key usage recording: missing apiKeyData.id') + } + + // 记录账户级别的使用统计 + 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}` + ) + } catch (error) { + logger.error('❌ Failed to record Droid usage:', error) + } + } + + /** + * 生成 UUID + */ + _generateUUID() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0 + const v = c === 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) + } +} + +// 导出单例 +module.exports = new DroidRelayService() diff --git a/src/utils/tokenMask.js b/src/utils/tokenMask.js index 3ea94583..aa317052 100644 --- a/src/utils/tokenMask.js +++ b/src/utils/tokenMask.js @@ -17,8 +17,18 @@ function maskToken(token, visiblePercent = 70) { const { length } = token // 对于非常短的 token,至少隐藏一部分 + if (length <= 2) { + return '*'.repeat(length) + } + + if (length <= 5) { + return token.slice(0, 1) + '*'.repeat(length - 1) + } + if (length <= 10) { - return token.slice(0, 5) + '*'.repeat(length - 5) + const visibleLength = Math.min(5, length - 2) + const front = token.slice(0, visibleLength) + return front + '*'.repeat(length - visibleLength) } // 计算可见字符数量 diff --git a/src/utils/workosOAuthHelper.js b/src/utils/workosOAuthHelper.js new file mode 100644 index 00000000..8ce33c4d --- /dev/null +++ b/src/utils/workosOAuthHelper.js @@ -0,0 +1,170 @@ +const axios = require('axios') +const config = require('../../config/config') +const logger = require('./logger') +const ProxyHelper = require('./proxyHelper') + +const WORKOS_CONFIG = config.droid || {} + +const WORKOS_DEVICE_AUTHORIZE_URL = + WORKOS_CONFIG.deviceAuthorizeUrl || 'https://api.workos.com/user_management/authorize/device' +const WORKOS_TOKEN_URL = + WORKOS_CONFIG.tokenUrl || 'https://api.workos.com/user_management/authenticate' +const WORKOS_CLIENT_ID = WORKOS_CONFIG.clientId || 'client_01HNM792M5G5G1A2THWPXKFMXB' + +const DEFAULT_POLL_INTERVAL = 5 + +class WorkOSDeviceAuthError extends Error { + constructor(message, code, options = {}) { + super(message) + this.name = 'WorkOSDeviceAuthError' + this.code = code || 'unknown_error' + this.retryAfter = options.retryAfter || null + } +} + +/** + * 启动设备码授权流程 + * @param {object|null} proxyConfig - 代理配置 + * @returns {Promise} WorkOS 返回的数据 + */ +async function startDeviceAuthorization(proxyConfig = null) { + const form = new URLSearchParams({ + client_id: WORKOS_CLIENT_ID + }) + + const agent = ProxyHelper.createProxyAgent(proxyConfig) + + try { + logger.info('🔐 请求 WorkOS 设备码授权', { + url: WORKOS_DEVICE_AUTHORIZE_URL, + hasProxy: !!agent + }) + + const response = await axios.post(WORKOS_DEVICE_AUTHORIZE_URL, form.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + httpsAgent: agent, + timeout: 15000 + }) + + const data = response.data || {} + + if (!data.device_code || !data.verification_uri) { + throw new Error('WorkOS 返回数据缺少必要字段 (device_code / verification_uri)') + } + + logger.success('✅ 成功获取 WorkOS 设备码授权信息', { + verificationUri: data.verification_uri, + userCode: data.user_code + }) + + return { + deviceCode: data.device_code, + userCode: data.user_code, + verificationUri: data.verification_uri, + verificationUriComplete: data.verification_uri_complete || data.verification_uri, + expiresIn: data.expires_in || 300, + interval: data.interval || DEFAULT_POLL_INTERVAL + } + } catch (error) { + if (error.response) { + logger.error('❌ WorkOS 设备码授权失败', { + status: error.response.status, + data: error.response.data + }) + throw new WorkOSDeviceAuthError( + error.response.data?.error_description || + error.response.data?.error || + 'WorkOS 设备码授权失败', + error.response.data?.error + ) + } + + logger.error('❌ 请求 WorkOS 设备码授权异常', { + message: error.message + }) + throw new WorkOSDeviceAuthError(error.message) + } +} + +/** + * 轮询授权结果 + * @param {string} deviceCode - 设备码 + * @param {object|null} proxyConfig - 代理配置 + * @returns {Promise} WorkOS 返回的 token 数据 + */ +async function pollDeviceAuthorization(deviceCode, proxyConfig = null) { + if (!deviceCode) { + throw new WorkOSDeviceAuthError('缺少设备码,无法查询授权结果', 'missing_device_code') + } + + const form = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: deviceCode, + client_id: WORKOS_CLIENT_ID + }) + + const agent = ProxyHelper.createProxyAgent(proxyConfig) + + try { + const response = await axios.post(WORKOS_TOKEN_URL, form.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + httpsAgent: agent, + timeout: 15000 + }) + + const data = response.data || {} + + if (!data.access_token) { + throw new WorkOSDeviceAuthError('WorkOS 返回结果缺少 access_token', 'missing_access_token') + } + + logger.success('🤖 Droid 授权完成,获取到访问令牌', { + hasRefreshToken: !!data.refresh_token + }) + + return data + } catch (error) { + if (error.response) { + const responseData = error.response.data || {} + const errorCode = responseData.error || `http_${error.response.status}` + const errorDescription = + responseData.error_description || responseData.error || 'WorkOS 授权失败' + + if (errorCode === 'authorization_pending' || errorCode === 'slow_down') { + const retryAfter = + Number(responseData.interval) || + Number(error.response.headers?.['retry-after']) || + DEFAULT_POLL_INTERVAL + + throw new WorkOSDeviceAuthError(errorDescription, errorCode, { + retryAfter + }) + } + + if (errorCode === 'expired_token') { + throw new WorkOSDeviceAuthError(errorDescription, errorCode) + } + + logger.error('❌ WorkOS 设备授权轮询失败', { + status: error.response.status, + data: responseData + }) + throw new WorkOSDeviceAuthError(errorDescription, errorCode) + } + + logger.error('❌ WorkOS 设备授权轮询异常', { + message: error.message + }) + throw new WorkOSDeviceAuthError(error.message) + } +} + +module.exports = { + startDeviceAuthorization, + pollDeviceAuthorization, + WorkOSDeviceAuthError +} diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 80cd327c..e368f685 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -473,9 +473,11 @@ type="radio" value="oauth" /> - OAuth 授权 (用量可视化) + + OAuth 授权 + (用量可视化) +