From 08946c67ea0b554d9d27cbe5dd240482861b6d0b Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 10 Sep 2025 15:41:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20OpenAI-Responses?= =?UTF-8?q?=20=E8=B4=A6=E6=88=B7=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=92=8C=E7=8B=AC=E7=AB=8B=E8=87=AA=E5=8A=A8=E5=81=9C=E6=AD=A2?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 功能新增 - 实现 OpenAI-Responses 账户服务(openaiResponsesAccountService.js) - 支持使用账户内置 API Key 进行请求转发 - 实现每日额度管理和重置机制 - 支持代理配置和优先级设置 - 实现 OpenAI-Responses 中继服务(openaiResponsesRelayService.js) - 处理请求转发和响应流处理 - 自动记录使用统计信息 - 支持流式和非流式响应 - 新增管理界面的 OpenAI-Responses 账户管理功能 - 完整的 CRUD 操作支持 - 实时额度监控和状态管理 - 支持手动重置限流和每日额度 ## 架构改进 - 引入独立的自动停止标记机制,区分不同原因的自动停止 - rateLimitAutoStopped: 限流自动停止 - fiveHourAutoStopped: 5小时限制自动停止 - tempErrorAutoStopped: 临时错误自动停止 - quotaAutoStopped: 额度耗尽自动停止 - 修复手动修改调度状态时自动恢复的问题 - 统一清理逻辑,防止状态冲突 ## 其他优化 - getAccountUsageStats 支持不同账户类型参数 - 统一调度器支持 OpenAI-Responses 账户类型 - WebHook 通知增强,支持新账户类型的事件 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/models/redis.js | 23 +- src/routes/admin.js | 343 ++++++++- src/routes/openaiRoutes.js | 105 ++- src/services/claudeAccountService.js | 95 ++- src/services/claudeConsoleAccountService.js | 49 +- src/services/openaiResponsesAccountService.js | 574 ++++++++++++++ src/services/openaiResponsesRelayService.js | 708 +++++++++++++++++ src/services/unifiedOpenAIScheduler.js | 134 +++- src/utils/webhookNotifier.js | 18 + .../src/components/accounts/AccountForm.vue | 716 +++++++++++++++--- .../components/apikeys/CreateApiKeyModal.vue | 76 +- .../components/apikeys/EditApiKeyModal.vue | 85 ++- .../src/components/apikeys/NewApiKeyModal.vue | 2 +- .../src/components/common/AccountSelector.vue | 125 ++- web/admin-spa/src/stores/accounts.js | 76 +- web/admin-spa/src/views/AccountsView.vue | 104 ++- web/admin-spa/src/views/ApiKeysView.vue | 66 +- 17 files changed, 3061 insertions(+), 238 deletions(-) create mode 100644 src/services/openaiResponsesAccountService.js create mode 100644 src/services/openaiResponsesRelayService.js diff --git a/src/models/redis.js b/src/models/redis.js index 7ef64ebf..7fa91e2b 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -780,7 +780,7 @@ class RedisClient { } // 📊 获取账户使用统计 - async getAccountUsageStats(accountId) { + async getAccountUsageStats(accountId, accountType = null) { const accountKey = `account_usage:${accountId}` const today = getDateStringInTimezone() const accountDailyKey = `account_usage:daily:${accountId}:${today}` @@ -794,8 +794,25 @@ class RedisClient { this.client.hgetall(accountMonthlyKey) ]) - // 获取账户创建时间来计算平均值 - const accountData = await this.client.hgetall(`claude_account:${accountId}`) + // 获取账户创建时间来计算平均值 - 支持不同类型的账号 + let accountData = {} + 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}`) + } else { + // 尝试多个前缀 + accountData = await this.client.hgetall(`claude_account:${accountId}`) + if (!accountData.createdAt) { + accountData = await this.client.hgetall(`openai:account:${accountId}`) + } + if (!accountData.createdAt) { + accountData = await this.client.hgetall(`openai_responses_account:${accountId}`) + } + if (!accountData.createdAt) { + accountData = await this.client.hgetall(`openai_account:${accountId}`) + } + } const createdAt = accountData.createdAt ? new Date(accountData.createdAt) : new Date() const now = new Date() const daysSinceCreated = Math.max(1, Math.ceil((now - createdAt) / (1000 * 60 * 60 * 24))) diff --git a/src/routes/admin.js b/src/routes/admin.js index 699be503..e4a46fc5 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 geminiAccountService = require('../services/geminiAccountService') const openaiAccountService = require('../services/openaiAccountService') +const openaiResponsesAccountService = require('../services/openaiResponsesAccountService') const azureOpenaiAccountService = require('../services/azureOpenaiAccountService') const accountGroupService = require('../services/accountGroupService') const redis = require('../models/redis') @@ -1946,7 +1947,7 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => { const accountsWithStats = await Promise.all( accounts.map(async (account) => { try { - const usageStats = await redis.getAccountUsageStats(account.id) + const usageStats = await redis.getAccountUsageStats(account.id, 'openai') const groupInfos = await accountGroupService.getAccountGroups(account.id) // 获取会话窗口使用统计(仅对有活跃窗口的账户) @@ -2381,7 +2382,7 @@ router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => { const accountsWithStats = await Promise.all( accounts.map(async (account) => { try { - const usageStats = await redis.getAccountUsageStats(account.id) + const usageStats = await redis.getAccountUsageStats(account.id, 'openai') const groupInfos = await accountGroupService.getAccountGroups(account.id) return { @@ -2784,7 +2785,7 @@ router.get('/bedrock-accounts', authenticateAdmin, async (req, res) => { const accountsWithStats = await Promise.all( accounts.map(async (account) => { try { - const usageStats = await redis.getAccountUsageStats(account.id) + const usageStats = await redis.getAccountUsageStats(account.id, 'openai') const groupInfos = await accountGroupService.getAccountGroups(account.id) return { @@ -3234,7 +3235,7 @@ router.get('/gemini-accounts', authenticateAdmin, async (req, res) => { const accountsWithStats = await Promise.all( accounts.map(async (account) => { try { - const usageStats = await redis.getAccountUsageStats(account.id) + const usageStats = await redis.getAccountUsageStats(account.id, 'openai') const groupInfos = await accountGroupService.getAccountGroups(account.id) return { @@ -5762,7 +5763,7 @@ router.get('/openai-accounts', authenticateAdmin, async (req, res) => { const accountsWithStats = await Promise.all( accounts.map(async (account) => { try { - const usageStats = await redis.getAccountUsageStats(account.id) + const usageStats = await redis.getAccountUsageStats(account.id, 'openai') return { ...account, usage: { @@ -6309,7 +6310,7 @@ router.get('/azure-openai-accounts', authenticateAdmin, async (req, res) => { const accountsWithStats = await Promise.all( accounts.map(async (account) => { try { - const usageStats = await redis.getAccountUsageStats(account.id) + const usageStats = await redis.getAccountUsageStats(account.id, 'openai') const groupInfos = await accountGroupService.getAccountGroups(account.id) return { ...account, @@ -6709,4 +6710,334 @@ router.post('/claude-code-version/clear', authenticateAdmin, async (req, res) => } }) +// ==================== OpenAI-Responses 账户管理 API ==================== + +// 获取所有 OpenAI-Responses 账户 +router.get('/openai-responses-accounts', authenticateAdmin, async (req, res) => { + try { + const { platform, groupId } = req.query + let accounts = await openaiResponsesAccountService.getAllAccounts(true) + + // 根据查询参数进行筛选 + if (platform && platform !== 'openai-responses') { + accounts = [] + } + + // 根据分组ID筛选 + if (groupId) { + const group = await accountGroupService.getGroup(groupId) + if (group && group.platform === 'openai' && group.memberIds && group.memberIds.length > 0) { + accounts = accounts.filter((account) => group.memberIds.includes(account.id)) + } else { + accounts = [] + } + } + + // 处理额度信息、使用统计和绑定的 API Key 数量 + const accountsWithStats = await Promise.all( + accounts.map(async (account) => { + try { + // 检查是否需要重置额度 + const today = redis.getDateStringInTimezone() + if (account.lastResetDate !== today) { + // 今天还没重置过,需要重置 + await openaiResponsesAccountService.updateAccount(account.id, { + dailyUsage: '0', + lastResetDate: today, + quotaStoppedAt: '' + }) + account.dailyUsage = '0' + account.lastResetDate = today + account.quotaStoppedAt = '' + } + + // 检查并清除过期的限流状态 + await openaiResponsesAccountService.checkAndClearRateLimit(account.id) + + // 获取使用统计信息 + let usageStats + try { + usageStats = await redis.getAccountUsageStats(account.id, 'openai-responses') + } catch (error) { + logger.debug( + `Failed to get usage stats for OpenAI-Responses account ${account.id}:`, + error + ) + usageStats = { + daily: { requests: 0, tokens: 0, allTokens: 0 }, + total: { requests: 0, tokens: 0, allTokens: 0 }, + monthly: { requests: 0, tokens: 0, allTokens: 0 } + } + } + + // 计算绑定的API Key数量(支持 responses: 前缀) + const allKeys = await redis.getAllApiKeys() + let boundCount = 0 + + for (const key of allKeys) { + // 检查是否绑定了该账户(包括 responses: 前缀) + if ( + key.openaiAccountId === account.id || + key.openaiAccountId === `responses:${account.id}` + ) { + boundCount++ + } + } + + // 调试日志:检查绑定计数 + if (boundCount > 0) { + logger.info(`OpenAI-Responses account ${account.id} has ${boundCount} bound API keys`) + } + + return { + ...account, + boundApiKeysCount: boundCount, + usage: { + daily: usageStats.daily, + total: usageStats.total, + monthly: usageStats.monthly + } + } + } catch (error) { + logger.error(`Failed to process OpenAI-Responses account ${account.id}:`, error) + return { + ...account, + boundApiKeysCount: 0, + usage: { + daily: { requests: 0, tokens: 0, allTokens: 0 }, + total: { requests: 0, tokens: 0, allTokens: 0 }, + monthly: { requests: 0, tokens: 0, allTokens: 0 } + } + } + } + }) + ) + + res.json({ success: true, data: accountsWithStats }) + } catch (error) { + logger.error('Failed to get OpenAI-Responses accounts:', error) + res.status(500).json({ success: false, message: error.message }) + } +}) + +// 创建 OpenAI-Responses 账户 +router.post('/openai-responses-accounts', authenticateAdmin, async (req, res) => { + try { + const account = await openaiResponsesAccountService.createAccount(req.body) + res.json({ success: true, account }) + } catch (error) { + logger.error('Failed to create OpenAI-Responses account:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 更新 OpenAI-Responses 账户 +router.put('/openai-responses-accounts/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + const updates = req.body + + // 验证priority的有效性(1-100) + if (updates.priority !== undefined) { + const priority = parseInt(updates.priority) + if (isNaN(priority) || priority < 1 || priority > 100) { + return res.status(400).json({ + success: false, + message: 'Priority must be a number between 1 and 100' + }) + } + updates.priority = priority.toString() + } + + const result = await openaiResponsesAccountService.updateAccount(id, updates) + + if (!result.success) { + return res.status(400).json(result) + } + + res.json({ success: true, ...result }) + } catch (error) { + logger.error('Failed to update OpenAI-Responses account:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 删除 OpenAI-Responses 账户 +router.delete('/openai-responses-accounts/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + const account = await openaiResponsesAccountService.getAccount(id) + if (!account) { + return res.status(404).json({ + success: false, + message: 'Account not found' + }) + } + + // 检查是否在分组中 + const groups = await accountGroupService.getAllGroups() + for (const group of groups) { + if (group.platform === 'openai' && group.memberIds && group.memberIds.includes(id)) { + await accountGroupService.removeMemberFromGroup(group.id, id) + logger.info(`Removed OpenAI-Responses account ${id} from group ${group.id}`) + } + } + + const result = await openaiResponsesAccountService.deleteAccount(id) + res.json({ success: true, ...result }) + } catch (error) { + logger.error('Failed to delete OpenAI-Responses account:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 切换 OpenAI-Responses 账户调度状态 +router.put( + '/openai-responses-accounts/:id/toggle-schedulable', + authenticateAdmin, + async (req, res) => { + try { + const { id } = req.params + + const result = await openaiResponsesAccountService.toggleSchedulable(id) + + if (!result.success) { + return res.status(400).json(result) + } + + // 仅在停止调度时发送通知 + if (!result.schedulable) { + await webhookNotifier.sendAccountEvent('account.status_changed', { + accountId: id, + platform: 'openai-responses', + schedulable: result.schedulable, + changedBy: 'admin', + action: 'stopped_scheduling' + }) + } + + res.json(result) + } catch (error) { + logger.error('Failed to toggle OpenAI-Responses account schedulable status:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } + } +) + +// 切换 OpenAI-Responses 账户激活状态 +router.put('/openai-responses-accounts/:id/toggle', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + const account = await openaiResponsesAccountService.getAccount(id) + if (!account) { + return res.status(404).json({ + success: false, + message: 'Account not found' + }) + } + + const newActiveStatus = account.isActive === 'true' ? 'false' : 'true' + await openaiResponsesAccountService.updateAccount(id, { + isActive: newActiveStatus + }) + + res.json({ + success: true, + isActive: newActiveStatus === 'true' + }) + } catch (error) { + logger.error('Failed to toggle OpenAI-Responses account status:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 重置 OpenAI-Responses 账户限流状态 +router.post( + '/openai-responses-accounts/:id/reset-rate-limit', + authenticateAdmin, + async (req, res) => { + try { + const { id } = req.params + + await openaiResponsesAccountService.updateAccount(id, { + rateLimitedAt: '', + rateLimitStatus: '', + status: 'active', + errorMessage: '' + }) + + logger.info(`🔄 Admin manually reset rate limit for OpenAI-Responses account ${id}`) + + res.json({ + success: true, + message: 'Rate limit reset successfully' + }) + } catch (error) { + logger.error('Failed to reset OpenAI-Responses account rate limit:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } + } +) + +// 重置 OpenAI-Responses 账户状态(清除所有异常状态) +router.post('/openai-responses-accounts/:id/reset-status', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + const result = await openaiResponsesAccountService.resetAccountStatus(id) + + logger.success(`✅ Admin reset status for OpenAI-Responses account: ${id}`) + return res.json({ success: true, data: result }) + } catch (error) { + logger.error('❌ Failed to reset OpenAI-Responses account status:', error) + return res.status(500).json({ error: 'Failed to reset status', message: error.message }) + } +}) + +// 手动重置 OpenAI-Responses 账户的每日使用量 +router.post('/openai-responses-accounts/:id/reset-usage', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + await openaiResponsesAccountService.updateAccount(id, { + dailyUsage: '0', + lastResetDate: redis.getDateStringInTimezone(), + quotaStoppedAt: '' + }) + + logger.success(`✅ Admin manually reset daily usage for OpenAI-Responses account ${id}`) + + res.json({ + success: true, + message: 'Daily usage reset successfully' + }) + } catch (error) { + logger.error('Failed to reset OpenAI-Responses account usage:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + module.exports = router diff --git a/src/routes/openaiRoutes.js b/src/routes/openaiRoutes.js index dfe27719..d43b09bd 100644 --- a/src/routes/openaiRoutes.js +++ b/src/routes/openaiRoutes.js @@ -6,6 +6,8 @@ const config = require('../../config/config') const { authenticateApiKey } = require('../middleware/auth') const unifiedOpenAIScheduler = require('../services/unifiedOpenAIScheduler') const openaiAccountService = require('../services/openaiAccountService') +const openaiResponsesAccountService = require('../services/openaiResponsesAccountService') +const openaiResponsesRelayService = require('../services/openaiResponsesRelayService') const apiKeyService = require('../services/apiKeyService') const crypto = require('crypto') const ProxyHelper = require('../utils/proxyHelper') @@ -34,51 +36,81 @@ async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel = throw new Error('No available OpenAI account found') } - // 获取账户详情 - let account = await openaiAccountService.getAccount(result.accountId) - if (!account || !account.accessToken) { - throw new Error(`OpenAI account ${result.accountId} has no valid accessToken`) - } + // 根据账户类型获取账户详情 + let account, + accessToken, + proxy = null - // 检查 token 是否过期并自动刷新(双重保护) - if (openaiAccountService.isTokenExpired(account)) { - if (account.refreshToken) { - logger.info(`🔄 Token expired, auto-refreshing for account ${account.name} (fallback)`) + if (result.accountType === 'openai-responses') { + // 处理 OpenAI-Responses 账户 + account = await openaiResponsesAccountService.getAccount(result.accountId) + if (!account || !account.apiKey) { + throw new Error(`OpenAI-Responses account ${result.accountId} has no valid apiKey`) + } + + // OpenAI-Responses 账户不需要 accessToken,直接返回账户信息 + accessToken = null // OpenAI-Responses 使用账户内的 apiKey + + // 解析代理配置 + if (account.proxy) { try { - await openaiAccountService.refreshAccountToken(result.accountId) - // 重新获取更新后的账户 - account = await openaiAccountService.getAccount(result.accountId) - logger.info(`✅ Token refreshed successfully in route handler`) - } catch (refreshError) { - logger.error(`Failed to refresh token for ${account.name}:`, refreshError) - throw new Error(`Token expired and refresh failed: ${refreshError.message}`) + proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy + } catch (e) { + logger.warn('Failed to parse proxy configuration:', e) } - } else { - throw new Error(`Token expired and no refresh token available for account ${account.name}`) } - } - // 解密 accessToken(account.accessToken 是加密的) - const accessToken = openaiAccountService.decrypt(account.accessToken) - if (!accessToken) { - throw new Error('Failed to decrypt OpenAI accessToken') - } - - // 解析代理配置 - let proxy = null - if (account.proxy) { - try { - proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy - } catch (e) { - logger.warn('Failed to parse proxy configuration:', e) + logger.info(`Selected OpenAI-Responses account: ${account.name} (${result.accountId})`) + } else { + // 处理普通 OpenAI 账户 + account = await openaiAccountService.getAccount(result.accountId) + if (!account || !account.accessToken) { + throw new Error(`OpenAI account ${result.accountId} has no valid accessToken`) } + + // 检查 token 是否过期并自动刷新(双重保护) + if (openaiAccountService.isTokenExpired(account)) { + if (account.refreshToken) { + logger.info(`🔄 Token expired, auto-refreshing for account ${account.name} (fallback)`) + try { + await openaiAccountService.refreshAccountToken(result.accountId) + // 重新获取更新后的账户 + account = await openaiAccountService.getAccount(result.accountId) + logger.info(`✅ Token refreshed successfully in route handler`) + } catch (refreshError) { + logger.error(`Failed to refresh token for ${account.name}:`, refreshError) + throw new Error(`Token expired and refresh failed: ${refreshError.message}`) + } + } else { + throw new Error( + `Token expired and no refresh token available for account ${account.name}` + ) + } + } + + // 解密 accessToken(account.accessToken 是加密的) + accessToken = openaiAccountService.decrypt(account.accessToken) + if (!accessToken) { + throw new Error('Failed to decrypt OpenAI accessToken') + } + + // 解析代理配置 + if (account.proxy) { + try { + proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy + } catch (e) { + logger.warn('Failed to parse proxy configuration:', e) + } + } + + logger.info(`Selected OpenAI account: ${account.name} (${result.accountId})`) } - logger.info(`Selected OpenAI account: ${account.name} (${result.accountId})`) return { accessToken, accountId: result.accountId, accountName: account.name, + accountType: result.accountType, proxy, account } @@ -151,9 +183,16 @@ const handleResponses = async (req, res) => { accessToken, accountId, accountName: _accountName, + accountType, proxy, account } = await getOpenAIAuthToken(apiKeyData, sessionId, requestedModel) + + // 如果是 OpenAI-Responses 账户,使用专门的中继服务处理 + if (accountType === 'openai-responses') { + logger.info(`🔀 Using OpenAI-Responses relay service for account: ${account.name}`) + return await openaiResponsesRelayService.handleRequest(req, res, account, apiKeyData) + } // 基于白名单构造上游所需的请求头,确保键为小写且值受控 const incoming = req.headers || {} diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 9219a7f5..c9331fe6 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -603,6 +603,25 @@ class ClaudeAccountService { updatedData.updatedAt = new Date().toISOString() + // 如果是手动修改调度状态,清除所有自动停止相关的字段 + if (Object.prototype.hasOwnProperty.call(updates, 'schedulable')) { + // 清除所有自动停止的标记,防止自动恢复 + delete updatedData.rateLimitAutoStopped + delete updatedData.fiveHourAutoStopped + delete updatedData.fiveHourStoppedAt + delete updatedData.tempErrorAutoStopped + // 兼容旧的标记(逐步迁移) + delete updatedData.autoStoppedAt + delete updatedData.stoppedReason + + // 如果是手动启用调度,记录日志 + if (updates.schedulable === true || updates.schedulable === 'true') { + logger.info(`✅ Manually enabled scheduling for account ${accountId}`) + } else { + logger.info(`⛔ Manually disabled scheduling for account ${accountId}`) + } + } + // 检查是否手动禁用了账号,如果是则发送webhook通知 if (updates.isActive === 'false' && accountData.isActive === 'true') { try { @@ -1088,7 +1107,9 @@ class ClaudeAccountService { updatedAccountData.rateLimitedAt = new Date().toISOString() updatedAccountData.rateLimitStatus = 'limited' // 限流时停止调度,与 OpenAI 账号保持一致 - updatedAccountData.schedulable = false + updatedAccountData.schedulable = 'false' + // 使用独立的限流自动停止标记,避免与其他自动停止冲突 + updatedAccountData.rateLimitAutoStopped = 'true' // 如果提供了准确的限流重置时间戳(来自API响应头) if (rateLimitResetTimestamp) { @@ -1173,13 +1194,16 @@ class ClaudeAccountService { delete accountData.rateLimitedAt delete accountData.rateLimitStatus delete accountData.rateLimitEndAt // 清除限流结束时间 - // 恢复可调度状态,与 OpenAI 账号保持一致 - accountData.schedulable = true + + // 只恢复因限流而自动停止的账户 + if (accountData.rateLimitAutoStopped === 'true' && accountData.schedulable === 'false') { + accountData.schedulable = 'true' + delete accountData.rateLimitAutoStopped + logger.info(`✅ Auto-resuming scheduling for account ${accountId} after rate limit cleared`) + } await redis.setClaudeAccount(accountId, accountData) - logger.success( - `✅ Rate limit removed for account: ${accountData.name} (${accountId}), schedulable restored` - ) + logger.success(`✅ Rate limit removed for account: ${accountData.name} (${accountId})`) return { success: true } } catch (error) { @@ -1331,17 +1355,13 @@ class ClaudeAccountService { } // 如果账户因为5小时限制被自动停止,现在恢复调度 - if ( - accountData.autoStoppedAt && - accountData.schedulable === 'false' && - accountData.stoppedReason === '5小时使用量接近限制,自动停止调度' - ) { + if (accountData.fiveHourAutoStopped === 'true' && accountData.schedulable === 'false') { logger.info( `✅ Auto-resuming scheduling for account ${accountData.name} (${accountId}) - new session window started` ) accountData.schedulable = 'true' - delete accountData.stoppedReason - delete accountData.autoStoppedAt + delete accountData.fiveHourAutoStopped + delete accountData.fiveHourStoppedAt // 发送Webhook通知 try { @@ -1823,8 +1843,16 @@ class ClaudeAccountService { updatedAccountData.status = 'created' } - // 恢复可调度状态 + // 恢复可调度状态(管理员手动重置时恢复调度是合理的) updatedAccountData.schedulable = 'true' + // 清除所有自动停止相关的标记 + delete updatedAccountData.rateLimitAutoStopped + delete updatedAccountData.fiveHourAutoStopped + delete updatedAccountData.fiveHourStoppedAt + delete updatedAccountData.tempErrorAutoStopped + // 兼容旧的标记 + delete updatedAccountData.autoStoppedAt + delete updatedAccountData.stoppedReason // 清除错误相关字段 delete updatedAccountData.errorMessage @@ -1850,7 +1878,15 @@ class ClaudeAccountService { 'rateLimitEndAt', 'tempErrorAt', 'sessionWindowStart', - 'sessionWindowEnd' + 'sessionWindowEnd', + // 新的独立标记 + 'rateLimitAutoStopped', + 'fiveHourAutoStopped', + 'fiveHourStoppedAt', + 'tempErrorAutoStopped', + // 兼容旧的标记 + 'autoStoppedAt', + 'stoppedReason' ] await redis.client.hdel(`claude:account:${accountId}`, ...fieldsToDelete) @@ -1901,13 +1937,22 @@ class ClaudeAccountService { // 如果临时错误状态超过指定时间,尝试重新激活 if (minutesSinceTempError > TEMP_ERROR_RECOVERY_MINUTES) { account.status = 'active' // 恢复为 active 状态 - account.schedulable = 'true' // 恢复为可调度 + // 只恢复因临时错误而自动停止的账户 + if (account.tempErrorAutoStopped === 'true') { + account.schedulable = 'true' // 恢复为可调度 + delete account.tempErrorAutoStopped + } delete account.errorMessage delete account.tempErrorAt await redis.setClaudeAccount(account.id, account) // 显式从 Redis 中删除这些字段(因为 HSET 不会删除现有字段) - await redis.client.hdel(`claude:account:${account.id}`, 'errorMessage', 'tempErrorAt') + await redis.client.hdel( + `claude:account:${account.id}`, + 'errorMessage', + 'tempErrorAt', + 'tempErrorAutoStopped' + ) // 同时清除500错误计数 await this.clearInternalErrors(account.id) @@ -1992,6 +2037,8 @@ class ClaudeAccountService { updatedAccountData.schedulable = 'false' // 设置为不可调度 updatedAccountData.errorMessage = 'Account temporarily disabled due to consecutive 500 errors' updatedAccountData.tempErrorAt = new Date().toISOString() + // 使用独立的临时错误自动停止标记 + updatedAccountData.tempErrorAutoStopped = 'true' // 保存更新后的账户数据 await redis.setClaudeAccount(accountId, updatedAccountData) @@ -2010,7 +2057,11 @@ class ClaudeAccountService { if (minutesSince >= 5) { // 恢复账户 account.status = 'active' - account.schedulable = 'true' + // 只恢复因临时错误而自动停止的账户 + if (account.tempErrorAutoStopped === 'true') { + account.schedulable = 'true' + delete account.tempErrorAutoStopped + } delete account.errorMessage delete account.tempErrorAt @@ -2020,7 +2071,8 @@ class ClaudeAccountService { await redis.client.hdel( `claude:account:${accountId}`, 'errorMessage', - 'tempErrorAt' + 'tempErrorAt', + 'tempErrorAutoStopped' ) // 清除 500 错误计数 @@ -2108,8 +2160,9 @@ class ClaudeAccountService { `⚠️ Account ${accountData.name} (${accountId}) approaching 5h limit, auto-stopping scheduling` ) accountData.schedulable = 'false' - accountData.stoppedReason = '5小时使用量接近限制,自动停止调度' - accountData.autoStoppedAt = new Date().toISOString() + // 使用独立的5小时限制自动停止标记 + accountData.fiveHourAutoStopped = 'true' + accountData.fiveHourStoppedAt = new Date().toISOString() // 发送Webhook通知 try { diff --git a/src/services/claudeConsoleAccountService.js b/src/services/claudeConsoleAccountService.js index cd4f4f72..85d27376 100644 --- a/src/services/claudeConsoleAccountService.js +++ b/src/services/claudeConsoleAccountService.js @@ -285,6 +285,20 @@ class ClaudeConsoleAccountService { } if (updates.schedulable !== undefined) { updatedData.schedulable = updates.schedulable.toString() + // 如果是手动修改调度状态,清除所有自动停止相关的字段 + // 防止自动恢复 + updatedData.rateLimitAutoStopped = '' + updatedData.quotaAutoStopped = '' + // 兼容旧的标记 + updatedData.autoStoppedAt = '' + updatedData.stoppedReason = '' + + // 记录日志 + if (updates.schedulable === true || updates.schedulable === 'true') { + logger.info(`✅ Manually enabled scheduling for Claude Console account ${accountId}`) + } else { + logger.info(`⛔ Manually disabled scheduling for Claude Console account ${accountId}`) + } } // 额度管理相关字段 @@ -401,7 +415,9 @@ class ClaudeConsoleAccountService { rateLimitStatus: 'limited', isActive: 'false', // 禁用账户 schedulable: 'false', // 停止调度,与其他平台保持一致 - errorMessage: `Rate limited at ${new Date().toISOString()}` + errorMessage: `Rate limited at ${new Date().toISOString()}`, + // 使用独立的限流自动停止标记 + rateLimitAutoStopped: 'true' } // 只有当前状态不是quota_exceeded时才设置为rate_limited @@ -467,12 +483,24 @@ class ClaudeConsoleAccountService { logger.info(`⚠️ Rate limit removed but quota exceeded remains for account: ${accountId}`) } else { // 没有额度限制,完全恢复 - await client.hset(accountKey, { + const accountData = await client.hgetall(accountKey) + const updateData = { isActive: 'true', - schedulable: 'true', // 恢复调度,与其他平台保持一致 status: 'active', errorMessage: '' - }) + } + + // 只恢复因限流而自动停止的账户 + if (accountData.rateLimitAutoStopped === 'true' && accountData.schedulable === 'false') { + updateData.schedulable = 'true' // 恢复调度 + // 删除限流自动停止标记 + await client.hdel(accountKey, 'rateLimitAutoStopped') + logger.info( + `✅ Auto-resuming scheduling for Claude Console account ${accountId} after rate limit cleared` + ) + } + + await client.hset(accountKey, updateData) logger.success(`✅ Rate limit removed and account re-enabled: ${accountId}`) } } else { @@ -995,7 +1023,10 @@ class ClaudeConsoleAccountService { const updates = { isActive: false, quotaStoppedAt: new Date().toISOString(), - errorMessage: `Daily quota exceeded: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}` + errorMessage: `Daily quota exceeded: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}`, + schedulable: false, // 停止调度 + // 使用独立的额度超限自动停止标记 + quotaAutoStopped: 'true' } // 只有当前状态是active时才改为quota_exceeded @@ -1060,11 +1091,17 @@ class ClaudeConsoleAccountService { updates.errorMessage = '' updates.quotaStoppedAt = '' + // 只恢复因额度超限而自动停止的账户 + if (accountData.quotaAutoStopped === 'true') { + updates.schedulable = true + updates.quotaAutoStopped = '' + } + // 如果是rate_limited状态,也清除限流相关字段 if (accountData.status === 'rate_limited') { const client = redis.getClientSafe() const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` - await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus') + await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus', 'rateLimitAutoStopped') } logger.info( diff --git a/src/services/openaiResponsesAccountService.js b/src/services/openaiResponsesAccountService.js new file mode 100644 index 00000000..75a27625 --- /dev/null +++ b/src/services/openaiResponsesAccountService.js @@ -0,0 +1,574 @@ +const { v4: uuidv4 } = require('uuid') +const crypto = require('crypto') +const redis = require('../models/redis') +const logger = require('../utils/logger') +const config = require('../../config/config') +const LRUCache = require('../utils/lruCache') + +class OpenAIResponsesAccountService { + constructor() { + // 加密相关常量 + this.ENCRYPTION_ALGORITHM = 'aes-256-cbc' + this.ENCRYPTION_SALT = 'openai-responses-salt' + + // Redis 键前缀 + this.ACCOUNT_KEY_PREFIX = 'openai_responses_account:' + this.SHARED_ACCOUNTS_KEY = 'shared_openai_responses_accounts' + + // 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算 + this._encryptionKeyCache = null + + // 🔄 解密结果缓存,提高解密性能 + this._decryptCache = new LRUCache(500) + + // 🧹 定期清理缓存(每10分钟) + setInterval( + () => { + this._decryptCache.cleanup() + logger.info( + '🧹 OpenAI-Responses decrypt cache cleanup completed', + this._decryptCache.getStats() + ) + }, + 10 * 60 * 1000 + ) + } + + // 创建账户 + async createAccount(options = {}) { + const { + name = 'OpenAI Responses Account', + description = '', + baseApi = '', // 必填:API 基础地址 + apiKey = '', // 必填:API 密钥 + userAgent = '', // 可选:自定义 User-Agent,空则透传原始请求 + priority = 50, // 调度优先级 (1-100) + proxy = null, + isActive = true, + accountType = 'shared', // 'dedicated' or 'shared' + schedulable = true, // 是否可被调度 + dailyQuota = 0, // 每日额度限制(美元),0表示不限制 + quotaResetTime = '00:00', // 额度重置时间(HH:mm格式) + rateLimitDuration = 60 // 限流时间(分钟) + } = options + + // 验证必填字段 + if (!baseApi || !apiKey) { + throw new Error('Base API URL and API Key are required for OpenAI-Responses account') + } + + // 规范化 baseApi(确保不以 / 结尾) + const normalizedBaseApi = baseApi.endsWith('/') ? baseApi.slice(0, -1) : baseApi + + const accountId = uuidv4() + + const accountData = { + id: accountId, + platform: 'openai-responses', + name, + description, + baseApi: normalizedBaseApi, + apiKey: this._encryptSensitiveData(apiKey), + userAgent, + priority: priority.toString(), + proxy: proxy ? JSON.stringify(proxy) : '', + isActive: isActive.toString(), + accountType, + schedulable: schedulable.toString(), + createdAt: new Date().toISOString(), + lastUsedAt: '', + status: 'active', + errorMessage: '', + // 限流相关 + rateLimitedAt: '', + rateLimitStatus: '', + rateLimitDuration: rateLimitDuration.toString(), + // 额度管理 + dailyQuota: dailyQuota.toString(), + dailyUsage: '0', + lastResetDate: redis.getDateStringInTimezone(), + quotaResetTime, + quotaStoppedAt: '' + } + + // 保存到 Redis + await this._saveAccount(accountId, accountData) + + logger.success(`🚀 Created OpenAI-Responses account: ${name} (${accountId})`) + + return { + ...accountData, + apiKey: '***' // 返回时隐藏敏感信息 + } + } + + // 获取账户 + async getAccount(accountId) { + const client = redis.getClientSafe() + const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + const accountData = await client.hgetall(key) + + if (!accountData || !accountData.id) { + return null + } + + // 解密敏感数据 + accountData.apiKey = this._decryptSensitiveData(accountData.apiKey) + + // 解析 JSON 字段 + if (accountData.proxy) { + try { + accountData.proxy = JSON.parse(accountData.proxy) + } catch (e) { + accountData.proxy = null + } + } + + return accountData + } + + // 更新账户 + async updateAccount(accountId, updates) { + const account = await this.getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + // 处理敏感字段加密 + if (updates.apiKey) { + updates.apiKey = this._encryptSensitiveData(updates.apiKey) + } + + // 处理 JSON 字段 + if (updates.proxy !== undefined) { + updates.proxy = updates.proxy ? JSON.stringify(updates.proxy) : '' + } + + // 规范化 baseApi + if (updates.baseApi) { + updates.baseApi = updates.baseApi.endsWith('/') + ? updates.baseApi.slice(0, -1) + : updates.baseApi + } + + // 更新 Redis + const client = redis.getClientSafe() + const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + await client.hset(key, updates) + + logger.info(`📝 Updated OpenAI-Responses account: ${account.name}`) + + return { success: true } + } + + // 删除账户 + async deleteAccount(accountId) { + const client = redis.getClientSafe() + const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + + // 从共享账户列表中移除 + await client.srem(this.SHARED_ACCOUNTS_KEY, accountId) + + // 删除账户数据 + await client.del(key) + + logger.info(`🗑️ Deleted OpenAI-Responses account: ${accountId}`) + + return { success: true } + } + + // 获取所有账户 + async getAllAccounts(includeInactive = false) { + const client = redis.getClientSafe() + const accountIds = await client.smembers(this.SHARED_ACCOUNTS_KEY) + const accounts = [] + + for (const accountId of accountIds) { + const account = await this.getAccount(accountId) + if (account) { + // 过滤非活跃账户 + if (includeInactive || account.isActive === 'true') { + // 隐藏敏感信息 + account.apiKey = '***' + + // 获取限流状态信息(与普通OpenAI账号保持一致的格式) + const rateLimitInfo = this._getRateLimitInfo(account) + + // 格式化 rateLimitStatus 为对象(与普通 OpenAI 账号一致) + account.rateLimitStatus = rateLimitInfo.isRateLimited + ? { + isRateLimited: true, + rateLimitedAt: account.rateLimitedAt || null, + minutesRemaining: rateLimitInfo.remainingMinutes || 0 + } + : { + isRateLimited: false, + rateLimitedAt: null, + minutesRemaining: 0 + } + + // 转换 schedulable 字段为布尔值(前端需要布尔值来判断) + account.schedulable = account.schedulable !== 'false' + // 转换 isActive 字段为布尔值 + account.isActive = account.isActive === 'true' + + accounts.push(account) + } + } + } + + // 直接从 Redis 获取所有账户(包括非共享账户) + const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`) + for (const key of keys) { + const accountId = key.replace(this.ACCOUNT_KEY_PREFIX, '') + if (!accountIds.includes(accountId)) { + const accountData = await client.hgetall(key) + if (accountData && accountData.id) { + // 过滤非活跃账户 + if (includeInactive || accountData.isActive === 'true') { + // 隐藏敏感信息 + accountData.apiKey = '***' + // 解析 JSON 字段 + if (accountData.proxy) { + try { + accountData.proxy = JSON.parse(accountData.proxy) + } catch (e) { + accountData.proxy = null + } + } + + // 获取限流状态信息(与普通OpenAI账号保持一致的格式) + const rateLimitInfo = this._getRateLimitInfo(accountData) + + // 格式化 rateLimitStatus 为对象(与普通 OpenAI 账号一致) + accountData.rateLimitStatus = rateLimitInfo.isRateLimited + ? { + isRateLimited: true, + rateLimitedAt: accountData.rateLimitedAt || null, + minutesRemaining: rateLimitInfo.remainingMinutes || 0 + } + : { + isRateLimited: false, + rateLimitedAt: null, + minutesRemaining: 0 + } + + // 转换 schedulable 字段为布尔值(前端需要布尔值来判断) + accountData.schedulable = accountData.schedulable !== 'false' + // 转换 isActive 字段为布尔值 + accountData.isActive = accountData.isActive === 'true' + + accounts.push(accountData) + } + } + } + } + + return accounts + } + + // 标记账户限流 + async markAccountRateLimited(accountId, duration = null) { + const account = await this.getAccount(accountId) + if (!account) { + return + } + + const rateLimitDuration = duration || parseInt(account.rateLimitDuration) || 60 + const now = new Date() + const resetAt = new Date(now.getTime() + rateLimitDuration * 60000) + + await this.updateAccount(accountId, { + rateLimitedAt: now.toISOString(), + rateLimitStatus: 'limited', + rateLimitResetAt: resetAt.toISOString(), + rateLimitDuration: rateLimitDuration.toString(), + status: 'rateLimited', + schedulable: 'false', // 防止被调度 + errorMessage: `Rate limited until ${resetAt.toISOString()}` + }) + + logger.warn( + `⏳ Account ${account.name} marked as rate limited for ${rateLimitDuration} minutes (until ${resetAt.toISOString()})` + ) + } + + // 检查并清除过期的限流状态 + async checkAndClearRateLimit(accountId) { + const account = await this.getAccount(accountId) + if (!account || account.rateLimitStatus !== 'limited') { + return false + } + + const now = new Date() + let shouldClear = false + + // 优先使用 rateLimitResetAt 字段 + if (account.rateLimitResetAt) { + const resetAt = new Date(account.rateLimitResetAt) + shouldClear = now >= resetAt + } else { + // 如果没有 rateLimitResetAt,使用旧的逻辑 + const rateLimitedAt = new Date(account.rateLimitedAt) + const rateLimitDuration = parseInt(account.rateLimitDuration) || 60 + shouldClear = now - rateLimitedAt > rateLimitDuration * 60000 + } + + if (shouldClear) { + // 限流已过期,清除状态 + await this.updateAccount(accountId, { + rateLimitedAt: '', + rateLimitStatus: '', + rateLimitResetAt: '', + status: 'active', + schedulable: 'true', // 恢复调度 + errorMessage: '' + }) + + logger.info(`✅ Rate limit cleared for account ${account.name}`) + return true + } + + return false + } + + // 切换调度状态 + async toggleSchedulable(accountId) { + const account = await this.getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + const newSchedulableStatus = account.schedulable === 'true' ? 'false' : 'true' + await this.updateAccount(accountId, { + schedulable: newSchedulableStatus + }) + + logger.info( + `🔄 Toggled schedulable status for account ${account.name}: ${newSchedulableStatus}` + ) + + return { + success: true, + schedulable: newSchedulableStatus === 'true' + } + } + + // 更新使用额度 + async updateUsageQuota(accountId, amount) { + const account = await this.getAccount(accountId) + if (!account) { + return + } + + // 检查是否需要重置额度 + const today = redis.getDateStringInTimezone() + if (account.lastResetDate !== today) { + // 重置额度 + await this.updateAccount(accountId, { + dailyUsage: amount.toString(), + lastResetDate: today, + quotaStoppedAt: '' + }) + } else { + // 累加使用额度 + const currentUsage = parseFloat(account.dailyUsage) || 0 + const newUsage = currentUsage + amount + const dailyQuota = parseFloat(account.dailyQuota) || 0 + + const updates = { + dailyUsage: newUsage.toString() + } + + // 检查是否超出额度 + if (dailyQuota > 0 && newUsage >= dailyQuota) { + updates.status = 'quotaExceeded' + updates.quotaStoppedAt = new Date().toISOString() + updates.errorMessage = `Daily quota exceeded: $${newUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}` + logger.warn(`💸 Account ${account.name} exceeded daily quota`) + } + + await this.updateAccount(accountId, updates) + } + } + + // 更新账户使用统计(记录 token 使用量) + async updateAccountUsage(accountId, tokens = 0) { + const account = await this.getAccount(accountId) + if (!account) { + return + } + + const updates = { + lastUsedAt: new Date().toISOString() + } + + // 如果有 tokens 参数且大于0,同时更新使用统计 + if (tokens > 0) { + const currentTokens = parseInt(account.totalUsedTokens) || 0 + updates.totalUsedTokens = (currentTokens + tokens).toString() + } + + await this.updateAccount(accountId, updates) + } + + // 记录使用量(为了兼容性的别名) + async recordUsage(accountId, tokens = 0) { + return this.updateAccountUsage(accountId, tokens) + } + + // 重置账户状态(清除所有异常状态) + async resetAccountStatus(accountId) { + const account = await this.getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + const updates = { + // 根据是否有有效的 apiKey 来设置 status + status: account.apiKey ? 'active' : 'created', + // 恢复可调度状态 + schedulable: 'true', + // 清除错误相关字段 + errorMessage: '', + rateLimitedAt: '', + rateLimitStatus: '', + rateLimitResetAt: '', + rateLimitDuration: '' + } + + await this.updateAccount(accountId, updates) + logger.info(`✅ Reset all error status for OpenAI-Responses account ${accountId}`) + + // 发送 Webhook 通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || accountId, + platform: 'openai-responses', + status: 'recovered', + errorCode: 'STATUS_RESET', + reason: 'Account status manually reset', + timestamp: new Date().toISOString() + }) + logger.info( + `📢 Webhook notification sent for OpenAI-Responses account ${account.name} status reset` + ) + } catch (webhookError) { + logger.error('Failed to send status reset webhook notification:', webhookError) + } + + return { success: true, message: 'Account status reset successfully' } + } + + // 获取限流信息 + _getRateLimitInfo(accountData) { + if (accountData.rateLimitStatus !== 'limited') { + return { isRateLimited: false } + } + + const now = new Date() + let willBeAvailableAt + let remainingMinutes + + // 优先使用 rateLimitResetAt 字段 + if (accountData.rateLimitResetAt) { + willBeAvailableAt = new Date(accountData.rateLimitResetAt) + remainingMinutes = Math.max(0, Math.ceil((willBeAvailableAt - now) / 60000)) + } else { + // 如果没有 rateLimitResetAt,使用旧的逻辑 + const rateLimitedAt = new Date(accountData.rateLimitedAt) + const rateLimitDuration = parseInt(accountData.rateLimitDuration) || 60 + const elapsedMinutes = Math.floor((now - rateLimitedAt) / 60000) + remainingMinutes = Math.max(0, rateLimitDuration - elapsedMinutes) + willBeAvailableAt = new Date(rateLimitedAt.getTime() + rateLimitDuration * 60000) + } + + return { + isRateLimited: remainingMinutes > 0, + remainingMinutes, + willBeAvailableAt + } + } + + // 加密敏感数据 + _encryptSensitiveData(text) { + if (!text) { + return '' + } + + const key = this._getEncryptionKey() + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + + let encrypted = cipher.update(text) + encrypted = Buffer.concat([encrypted, cipher.final()]) + + return `${iv.toString('hex')}:${encrypted.toString('hex')}` + } + + // 解密敏感数据 + _decryptSensitiveData(text) { + if (!text || text === '') { + return '' + } + + // 检查缓存 + const cacheKey = crypto.createHash('sha256').update(text).digest('hex') + const cached = this._decryptCache.get(cacheKey) + if (cached !== undefined) { + return cached + } + + try { + const key = this._getEncryptionKey() + const [ivHex, encryptedHex] = text.split(':') + + const iv = Buffer.from(ivHex, 'hex') + const encryptedText = Buffer.from(encryptedHex, 'hex') + + const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + let decrypted = decipher.update(encryptedText) + decrypted = Buffer.concat([decrypted, decipher.final()]) + + const result = decrypted.toString() + + // 存入缓存(5分钟过期) + this._decryptCache.set(cacheKey, result, 5 * 60 * 1000) + + return result + } catch (error) { + logger.error('Decryption error:', error) + return '' + } + } + + // 获取加密密钥 + _getEncryptionKey() { + if (!this._encryptionKeyCache) { + this._encryptionKeyCache = crypto.scryptSync( + config.security.encryptionKey, + this.ENCRYPTION_SALT, + 32 + ) + } + return this._encryptionKeyCache + } + + // 保存账户到 Redis + async _saveAccount(accountId, accountData) { + const client = redis.getClientSafe() + const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + + // 保存账户数据 + await client.hset(key, accountData) + + // 添加到共享账户列表 + if (accountData.accountType === 'shared') { + await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId) + } + } +} + +module.exports = new OpenAIResponsesAccountService() diff --git a/src/services/openaiResponsesRelayService.js b/src/services/openaiResponsesRelayService.js new file mode 100644 index 00000000..f71a01b5 --- /dev/null +++ b/src/services/openaiResponsesRelayService.js @@ -0,0 +1,708 @@ +const axios = require('axios') +const ProxyHelper = require('../utils/proxyHelper') +const logger = require('../utils/logger') +const openaiResponsesAccountService = require('./openaiResponsesAccountService') +const apiKeyService = require('./apiKeyService') +const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler') +const config = require('../../config/config') +const crypto = require('crypto') + +class OpenAIResponsesRelayService { + constructor() { + this.defaultTimeout = config.requestTimeout || 600000 + } + + // 处理请求转发 + async handleRequest(req, res, account, apiKeyData) { + let abortController = null + // 获取会话哈希(如果有的话) + const sessionId = req.headers['session_id'] || req.body?.session_id + const sessionHash = sessionId + ? crypto.createHash('sha256').update(sessionId).digest('hex') + : null + + try { + // 获取完整的账户信息(包含解密的 API Key) + const fullAccount = await openaiResponsesAccountService.getAccount(account.id) + if (!fullAccount) { + throw new Error('Account not found') + } + + // 创建 AbortController 用于取消请求 + abortController = new AbortController() + + // 设置客户端断开监听器 + const handleClientDisconnect = () => { + logger.info('🔌 Client disconnected, aborting OpenAI-Responses request') + if (abortController && !abortController.signal.aborted) { + abortController.abort() + } + } + + // 监听客户端断开事件 + req.once('close', handleClientDisconnect) + res.once('close', handleClientDisconnect) + + // 构建目标 URL + const targetUrl = `${fullAccount.baseApi}${req.path}` + logger.info(`🎯 Forwarding to: ${targetUrl}`) + + // 构建请求头 + const headers = { + ...this._filterRequestHeaders(req.headers), + Authorization: `Bearer ${fullAccount.apiKey}`, + 'Content-Type': 'application/json' + } + + // 处理 User-Agent + if (fullAccount.userAgent) { + // 使用自定义 User-Agent + headers['User-Agent'] = fullAccount.userAgent + logger.debug(`📱 Using custom User-Agent: ${fullAccount.userAgent}`) + } else if (req.headers['user-agent']) { + // 透传原始 User-Agent + headers['User-Agent'] = req.headers['user-agent'] + logger.debug(`📱 Forwarding original User-Agent: ${req.headers['user-agent']}`) + } + + // 配置请求选项 + const requestOptions = { + method: req.method, + url: targetUrl, + headers, + data: req.body, + timeout: this.defaultTimeout, + responseType: req.body?.stream ? 'stream' : 'json', + validateStatus: () => true, // 允许处理所有状态码 + signal: abortController.signal + } + + // 配置代理(如果有) + if (fullAccount.proxy) { + const proxyAgent = ProxyHelper.createProxyAgent(fullAccount.proxy) + if (proxyAgent) { + requestOptions.httpsAgent = proxyAgent + requestOptions.proxy = false + logger.info( + `🌐 Using proxy for OpenAI-Responses: ${ProxyHelper.getProxyDescription(fullAccount.proxy)}` + ) + } + } + + // 记录请求信息 + logger.info('📤 OpenAI-Responses relay request', { + accountId: account.id, + accountName: account.name, + targetUrl, + method: req.method, + stream: req.body?.stream || false, + model: req.body?.model || 'unknown', + userAgent: headers['User-Agent'] || 'not set' + }) + + // 发送请求 + const response = await axios(requestOptions) + + // 处理 429 限流错误 + if (response.status === 429) { + const { resetsInSeconds, errorData } = await this._handle429Error( + account, + response, + req.body?.stream, + sessionHash + ) + + // 返回错误响应(使用处理后的数据,避免循环引用) + const errorResponse = errorData || { + error: { + message: 'Rate limit exceeded', + type: 'rate_limit_error', + code: 'rate_limit_exceeded', + resets_in_seconds: resetsInSeconds + } + } + return res.status(429).json(errorResponse) + } + + // 处理其他错误状态码 + if (response.status >= 400) { + // 处理流式错误响应 + let errorData = response.data + if (response.data && typeof response.data.pipe === 'function') { + // 流式响应需要先读取内容 + const chunks = [] + await new Promise((resolve) => { + response.data.on('data', (chunk) => chunks.push(chunk)) + response.data.on('end', resolve) + response.data.on('error', resolve) + setTimeout(resolve, 5000) // 超时保护 + }) + const fullResponse = Buffer.concat(chunks).toString() + + // 尝试解析错误响应 + try { + if (fullResponse.includes('data: ')) { + // SSE格式 + const lines = fullResponse.split('\n') + for (const line of lines) { + if (line.startsWith('data: ')) { + const jsonStr = line.slice(6).trim() + if (jsonStr && jsonStr !== '[DONE]') { + errorData = JSON.parse(jsonStr) + break + } + } + } + } else { + // 普通JSON + errorData = JSON.parse(fullResponse) + } + } catch (e) { + logger.error('Failed to parse error response:', e) + errorData = { error: { message: fullResponse || 'Unknown error' } } + } + } + + logger.error('OpenAI-Responses API error', { + status: response.status, + statusText: response.statusText, + errorData + }) + + // 清理监听器 + req.removeListener('close', handleClientDisconnect) + res.removeListener('close', handleClientDisconnect) + + return res.status(response.status).json(errorData) + } + + // 更新最后使用时间 + await openaiResponsesAccountService.updateAccount(account.id, { + lastUsedAt: new Date().toISOString() + }) + + // 处理流式响应 + if (req.body?.stream && response.data && typeof response.data.pipe === 'function') { + return this._handleStreamResponse( + response, + res, + account, + apiKeyData, + req.body?.model, + handleClientDisconnect, + req + ) + } + + // 处理非流式响应 + return this._handleNormalResponse(response, res, account, apiKeyData, req.body?.model) + } catch (error) { + // 清理 AbortController + if (abortController && !abortController.signal.aborted) { + abortController.abort() + } + + // 安全地记录错误,避免循环引用 + const errorInfo = { + message: error.message, + code: error.code, + status: error.response?.status, + statusText: error.response?.statusText + } + logger.error('OpenAI-Responses relay error:', errorInfo) + + // 检查是否是网络错误 + if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') { + await openaiResponsesAccountService.updateAccount(account.id, { + status: 'error', + errorMessage: `Connection error: ${error.code}` + }) + } + + // 如果已经发送了响应头,直接结束 + if (res.headersSent) { + return res.end() + } + + // 检查是否是axios错误并包含响应 + if (error.response) { + // 处理axios错误响应 + const status = error.response.status || 500 + let errorData = { + error: { + message: error.response.statusText || 'Request failed', + type: 'api_error', + code: error.code || 'unknown' + } + } + + // 如果响应包含数据,尝试使用它 + if (error.response.data) { + // 检查是否是流 + if (typeof error.response.data === 'object' && !error.response.data.pipe) { + errorData = error.response.data + } else if (typeof error.response.data === 'string') { + try { + errorData = JSON.parse(error.response.data) + } catch (e) { + errorData.error.message = error.response.data + } + } + } + + return res.status(status).json(errorData) + } + + // 其他错误 + return res.status(500).json({ + error: { + message: 'Internal server error', + type: 'internal_error', + details: error.message + } + }) + } + } + + // 处理流式响应 + async _handleStreamResponse( + response, + res, + account, + apiKeyData, + requestedModel, + handleClientDisconnect, + req + ) { + // 设置 SSE 响应头 + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + + let usageData = null + let actualModel = null + let buffer = '' + let rateLimitDetected = false + let rateLimitResetsInSeconds = null + let streamEnded = false + + // 解析 SSE 事件以捕获 usage 数据和 model + const parseSSEForUsage = (data) => { + const lines = data.split('\n') + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const jsonStr = line.slice(6) + if (jsonStr === '[DONE]') { + continue + } + + const eventData = JSON.parse(jsonStr) + + // 检查是否是 response.completed 事件(OpenAI-Responses 格式) + if (eventData.type === 'response.completed' && eventData.response) { + // 从响应中获取真实的 model + if (eventData.response.model) { + actualModel = eventData.response.model + logger.debug(`📊 Captured actual model from response.completed: ${actualModel}`) + } + + // 获取 usage 数据 - OpenAI-Responses 格式在 response.usage 下 + if (eventData.response.usage) { + usageData = eventData.response.usage + logger.info('📊 Successfully captured usage data from OpenAI-Responses:', { + input_tokens: usageData.input_tokens, + output_tokens: usageData.output_tokens, + total_tokens: usageData.total_tokens + }) + } + } + + // 检查是否有限流错误 + if (eventData.error) { + // 检查多种可能的限流错误类型 + if ( + eventData.error.type === 'rate_limit_error' || + eventData.error.type === 'usage_limit_reached' || + eventData.error.type === 'rate_limit_exceeded' + ) { + rateLimitDetected = true + if (eventData.error.resets_in_seconds) { + rateLimitResetsInSeconds = eventData.error.resets_in_seconds + logger.warn( + `🚫 Rate limit detected in stream, resets in ${rateLimitResetsInSeconds} seconds (${Math.ceil(rateLimitResetsInSeconds / 60)} minutes)` + ) + } + } + } + } catch (e) { + // 忽略解析错误 + } + } + } + } + + // 监听数据流 + response.data.on('data', (chunk) => { + try { + const chunkStr = chunk.toString() + + // 转发数据给客户端 + if (!res.destroyed && !streamEnded) { + res.write(chunk) + } + + // 同时解析数据以捕获 usage 信息 + buffer += chunkStr + + // 处理完整的 SSE 事件 + if (buffer.includes('\n\n')) { + const events = buffer.split('\n\n') + buffer = events.pop() || '' + + for (const event of events) { + if (event.trim()) { + parseSSEForUsage(event) + } + } + } + } catch (error) { + logger.error('Error processing stream chunk:', error) + } + }) + + response.data.on('end', async () => { + streamEnded = true + + // 处理剩余的 buffer + if (buffer.trim()) { + parseSSEForUsage(buffer) + } + + // 记录使用统计 + if (usageData) { + try { + // OpenAI-Responses 使用 input_tokens/output_tokens,标准 OpenAI 使用 prompt_tokens/completion_tokens + const inputTokens = usageData.input_tokens || usageData.prompt_tokens || 0 + const outputTokens = usageData.output_tokens || usageData.completion_tokens || 0 + + // 提取缓存相关的 tokens(如果存在) + const cacheCreateTokens = usageData.input_tokens_details?.cache_creation_tokens || 0 + const cacheReadTokens = usageData.input_tokens_details?.cached_tokens || 0 + + const totalTokens = usageData.total_tokens || inputTokens + outputTokens + const modelToRecord = actualModel || requestedModel || 'gpt-4' + + await apiKeyService.recordUsage( + apiKeyData.id, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + modelToRecord, + account.id + ) + + logger.info( + `📊 Recorded usage - Input: ${inputTokens}, Output: ${outputTokens}, CacheRead: ${cacheReadTokens}, CacheCreate: ${cacheCreateTokens}, Total: ${totalTokens}, Model: ${modelToRecord}` + ) + + // 更新账户的 token 使用统计 + await openaiResponsesAccountService.updateAccountUsage(account.id, totalTokens) + + // 更新账户使用额度(如果设置了额度限制) + if (parseFloat(account.dailyQuota) > 0) { + // 估算费用(根据模型和token数量) + const estimatedCost = this._estimateCost(modelToRecord, inputTokens, outputTokens) + await openaiResponsesAccountService.updateUsageQuota(account.id, estimatedCost) + } + } catch (error) { + logger.error('Failed to record usage:', error) + } + } + + // 如果在流式响应中检测到限流 + if (rateLimitDetected) { + // 使用统一调度器处理限流(与非流式响应保持一致) + const sessionId = req.headers['session_id'] || req.body?.session_id + const sessionHash = sessionId + ? crypto.createHash('sha256').update(sessionId).digest('hex') + : null + + await unifiedOpenAIScheduler.markAccountRateLimited( + account.id, + 'openai-responses', + sessionHash, + rateLimitResetsInSeconds + ) + + logger.warn( + `🚫 Processing rate limit for OpenAI-Responses account ${account.id} from stream` + ) + } + + // 清理监听器 + req.removeListener('close', handleClientDisconnect) + res.removeListener('close', handleClientDisconnect) + + if (!res.destroyed) { + res.end() + } + + logger.info('Stream response completed', { + accountId: account.id, + hasUsage: !!usageData, + actualModel: actualModel || 'unknown' + }) + }) + + response.data.on('error', (error) => { + streamEnded = true + logger.error('Stream error:', error) + + // 清理监听器 + req.removeListener('close', handleClientDisconnect) + res.removeListener('close', handleClientDisconnect) + + if (!res.headersSent) { + res.status(502).json({ error: { message: 'Upstream stream error' } }) + } else if (!res.destroyed) { + res.end() + } + }) + + // 处理客户端断开连接 + const cleanup = () => { + streamEnded = true + try { + response.data?.unpipe?.(res) + response.data?.destroy?.() + } catch (_) { + // 忽略清理错误 + } + } + + req.on('close', cleanup) + req.on('aborted', cleanup) + } + + // 处理非流式响应 + async _handleNormalResponse(response, res, account, apiKeyData, requestedModel) { + const responseData = response.data + + // 提取 usage 数据和实际 model + // 支持两种格式:直接的 usage 或嵌套在 response 中的 usage + const usageData = responseData?.usage || responseData?.response?.usage + const actualModel = + responseData?.model || responseData?.response?.model || requestedModel || 'gpt-4' + + // 记录使用统计 + if (usageData) { + try { + // OpenAI-Responses 使用 input_tokens/output_tokens,标准 OpenAI 使用 prompt_tokens/completion_tokens + const inputTokens = usageData.input_tokens || usageData.prompt_tokens || 0 + const outputTokens = usageData.output_tokens || usageData.completion_tokens || 0 + + // 提取缓存相关的 tokens(如果存在) + const cacheCreateTokens = usageData.input_tokens_details?.cache_creation_tokens || 0 + const cacheReadTokens = usageData.input_tokens_details?.cached_tokens || 0 + + const totalTokens = usageData.total_tokens || inputTokens + outputTokens + + await apiKeyService.recordUsage( + apiKeyData.id, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + actualModel, + account.id + ) + + logger.info( + `📊 Recorded non-stream usage - Input: ${inputTokens}, Output: ${outputTokens}, CacheRead: ${cacheReadTokens}, CacheCreate: ${cacheCreateTokens}, Total: ${totalTokens}, Model: ${actualModel}` + ) + + // 更新账户的 token 使用统计 + await openaiResponsesAccountService.updateAccountUsage(account.id, totalTokens) + + // 更新账户使用额度(如果设置了额度限制) + if (parseFloat(account.dailyQuota) > 0) { + // 估算费用(根据模型和token数量) + const estimatedCost = this._estimateCost(actualModel, inputTokens, outputTokens) + await openaiResponsesAccountService.updateUsageQuota(account.id, estimatedCost) + } + } catch (error) { + logger.error('Failed to record usage:', error) + } + } + + // 返回响应 + res.status(response.status).json(responseData) + + logger.info('Normal response completed', { + accountId: account.id, + status: response.status, + hasUsage: !!usageData, + model: actualModel + }) + } + + // 处理 429 限流错误 + async _handle429Error(account, response, isStream = false, sessionHash = null) { + let resetsInSeconds = null + let errorData = null + + try { + // 对于429错误,响应可能是JSON或SSE格式 + if (isStream && response.data && typeof response.data.pipe === 'function') { + // 流式响应需要先收集数据 + const chunks = [] + await new Promise((resolve, reject) => { + response.data.on('data', (chunk) => chunks.push(chunk)) + response.data.on('end', resolve) + response.data.on('error', reject) + // 设置超时防止无限等待 + setTimeout(resolve, 5000) + }) + + const fullResponse = Buffer.concat(chunks).toString() + + // 尝试解析SSE格式的错误响应 + if (fullResponse.includes('data: ')) { + const lines = fullResponse.split('\n') + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const jsonStr = line.slice(6).trim() + if (jsonStr && jsonStr !== '[DONE]') { + errorData = JSON.parse(jsonStr) + break + } + } catch (e) { + // 继续尝试下一行 + } + } + } + } + + // 如果SSE解析失败,尝试直接解析为JSON + if (!errorData) { + try { + errorData = JSON.parse(fullResponse) + } catch (e) { + logger.error('Failed to parse 429 error response:', e) + logger.debug('Raw response:', fullResponse) + } + } + } else if (response.data && typeof response.data !== 'object') { + // 如果response.data是字符串,尝试解析为JSON + try { + errorData = JSON.parse(response.data) + } catch (e) { + logger.error('Failed to parse 429 error response as JSON:', e) + errorData = { error: { message: response.data } } + } + } else if (response.data && typeof response.data === 'object' && !response.data.pipe) { + // 非流式响应,且是对象,直接使用 + errorData = response.data + } + + // 从响应体中提取重置时间(OpenAI 标准格式) + if (errorData && errorData.error) { + if (errorData.error.resets_in_seconds) { + resetsInSeconds = errorData.error.resets_in_seconds + logger.info( + `🕐 Rate limit will reset in ${resetsInSeconds} seconds (${Math.ceil(resetsInSeconds / 60)} minutes / ${Math.ceil(resetsInSeconds / 3600)} hours)` + ) + } else if (errorData.error.resets_in) { + // 某些 API 可能使用不同的字段名 + resetsInSeconds = parseInt(errorData.error.resets_in) + logger.info( + `🕐 Rate limit will reset in ${resetsInSeconds} seconds (${Math.ceil(resetsInSeconds / 60)} minutes / ${Math.ceil(resetsInSeconds / 3600)} hours)` + ) + } + } + + if (!resetsInSeconds) { + logger.warn('⚠️ Could not extract reset time from 429 response, using default 60 minutes') + } + } catch (e) { + logger.error('⚠️ Failed to parse rate limit error:', e) + } + + // 使用统一调度器标记账户为限流状态(与普通OpenAI账号保持一致) + await unifiedOpenAIScheduler.markAccountRateLimited( + account.id, + 'openai-responses', + sessionHash, + resetsInSeconds + ) + + logger.warn('OpenAI-Responses account rate limited', { + accountId: account.id, + accountName: account.name, + resetsInSeconds: resetsInSeconds || 'unknown', + resetInMinutes: resetsInSeconds ? Math.ceil(resetsInSeconds / 60) : 60, + resetInHours: resetsInSeconds ? Math.ceil(resetsInSeconds / 3600) : 1 + }) + + // 返回处理后的数据,避免循环引用 + return { resetsInSeconds, errorData } + } + + // 过滤请求头 + _filterRequestHeaders(headers) { + const filtered = {} + const skipHeaders = [ + 'host', + 'content-length', + 'authorization', + 'x-api-key', + 'x-cr-api-key', + 'connection', + 'upgrade', + 'sec-websocket-key', + 'sec-websocket-version', + 'sec-websocket-extensions' + ] + + for (const [key, value] of Object.entries(headers)) { + if (!skipHeaders.includes(key.toLowerCase())) { + filtered[key] = value + } + } + + return filtered + } + + // 估算费用(简化版本,实际应该根据不同的定价模型) + _estimateCost(model, inputTokens, outputTokens) { + // 这是一个简化的费用估算,实际应该根据不同的 API 提供商和模型定价 + const rates = { + 'gpt-4': { input: 0.03, output: 0.06 }, // per 1K tokens + 'gpt-4-turbo': { input: 0.01, output: 0.03 }, + 'gpt-3.5-turbo': { input: 0.0005, output: 0.0015 }, + 'claude-3-opus': { input: 0.015, output: 0.075 }, + 'claude-3-sonnet': { input: 0.003, output: 0.015 }, + 'claude-3-haiku': { input: 0.00025, output: 0.00125 } + } + + // 查找匹配的模型定价 + let rate = rates['gpt-3.5-turbo'] // 默认使用 GPT-3.5 的价格 + for (const [modelKey, modelRate] of Object.entries(rates)) { + if (model.toLowerCase().includes(modelKey.toLowerCase())) { + rate = modelRate + break + } + } + + const inputCost = (inputTokens / 1000) * rate.input + const outputCost = (outputTokens / 1000) * rate.output + return inputCost + outputCost + } +} + +module.exports = new OpenAIResponsesRelayService() diff --git a/src/services/unifiedOpenAIScheduler.js b/src/services/unifiedOpenAIScheduler.js index 99c21f32..ae709e1d 100644 --- a/src/services/unifiedOpenAIScheduler.js +++ b/src/services/unifiedOpenAIScheduler.js @@ -1,4 +1,5 @@ const openaiAccountService = require('./openaiAccountService') +const openaiResponsesAccountService = require('./openaiResponsesAccountService') const accountGroupService = require('./accountGroupService') const redis = require('../models/redis') const logger = require('../utils/logger') @@ -32,23 +33,53 @@ class UnifiedOpenAIScheduler { return await this.selectAccountFromGroup(groupId, sessionHash, requestedModel, apiKeyData) } - // 普通专属账户 - const boundAccount = await openaiAccountService.getAccount(apiKeyData.openaiAccountId) + // 普通专属账户 - 根据前缀判断是 OpenAI 还是 OpenAI-Responses 类型 + let boundAccount = null + let accountType = 'openai' + + // 检查是否有 responses: 前缀(用于区分 OpenAI-Responses 账户) + if (apiKeyData.openaiAccountId.startsWith('responses:')) { + const accountId = apiKeyData.openaiAccountId.replace('responses:', '') + boundAccount = await openaiResponsesAccountService.getAccount(accountId) + accountType = 'openai-responses' + } else { + // 普通 OpenAI 账户 + boundAccount = await openaiAccountService.getAccount(apiKeyData.openaiAccountId) + accountType = 'openai' + } + if ( boundAccount && (boundAccount.isActive === true || boundAccount.isActive === 'true') && boundAccount.status !== 'error' ) { // 检查是否被限流 - const isRateLimited = await this.isAccountRateLimited(boundAccount.id) - if (isRateLimited) { - const errorMsg = `Dedicated account ${boundAccount.name} is currently rate limited` - logger.warn(`⚠️ ${errorMsg}`) - throw new Error(errorMsg) + if (accountType === 'openai') { + const isRateLimited = await this.isAccountRateLimited(boundAccount.id) + if (isRateLimited) { + const errorMsg = `Dedicated account ${boundAccount.name} is currently rate limited` + logger.warn(`⚠️ ${errorMsg}`) + throw new Error(errorMsg) + } + } else if ( + accountType === 'openai-responses' && + boundAccount.rateLimitStatus === 'limited' + ) { + // OpenAI-Responses 账户的限流检查 + const isRateLimitCleared = await openaiResponsesAccountService.checkAndClearRateLimit( + boundAccount.id + ) + if (!isRateLimitCleared) { + const errorMsg = `Dedicated account ${boundAccount.name} is currently rate limited` + logger.warn(`⚠️ ${errorMsg}`) + throw new Error(errorMsg) + } } // 专属账户:可选的模型检查(只有明确配置了supportedModels且不为空才检查) + // OpenAI-Responses 账户默认支持所有模型 if ( + accountType === 'openai' && requestedModel && boundAccount.supportedModels && boundAccount.supportedModels.length > 0 @@ -62,13 +93,19 @@ class UnifiedOpenAIScheduler { } logger.info( - `🎯 Using bound dedicated OpenAI account: ${boundAccount.name} (${apiKeyData.openaiAccountId}) for API key ${apiKeyData.name}` + `🎯 Using bound dedicated ${accountType} account: ${boundAccount.name} (${boundAccount.id}) for API key ${apiKeyData.name}` ) // 更新账户的最后使用时间 - await openaiAccountService.recordUsage(apiKeyData.openaiAccountId, 0) + if (accountType === 'openai') { + await openaiAccountService.recordUsage(boundAccount.id, 0) + } else { + await openaiResponsesAccountService.updateAccount(boundAccount.id, { + lastUsedAt: new Date().toISOString() + }) + } return { - accountId: apiKeyData.openaiAccountId, - accountType: 'openai' + accountId: boundAccount.id, + accountType } } else { // 专属账户不可用时直接报错,不降级到共享池 @@ -230,6 +267,40 @@ class UnifiedOpenAIScheduler { } } + // 获取所有 OpenAI-Responses 账户(共享池) + const openaiResponsesAccounts = await openaiResponsesAccountService.getAllAccounts() + for (const account of openaiResponsesAccounts) { + if ( + (account.isActive === true || account.isActive === 'true') && + account.status !== 'error' && + account.status !== 'rateLimited' && + (account.accountType === 'shared' || !account.accountType) && // 兼容旧数据 + this._isSchedulable(account.schedulable) + ) { + // 检查并清除过期的限流状态 + const isRateLimitCleared = await openaiResponsesAccountService.checkAndClearRateLimit( + account.id + ) + + // 如果仍然处于限流状态,跳过 + if (account.rateLimitStatus === 'limited' && !isRateLimitCleared) { + logger.debug(`⏭️ Skipping OpenAI-Responses account ${account.name} - rate limited`) + continue + } + + // OpenAI-Responses 账户默认支持所有模型 + // 因为它们是第三方兼容 API,模型支持由第三方决定 + + availableAccounts.push({ + ...account, + accountId: account.id, + accountType: 'openai-responses', + priority: parseInt(account.priority) || 50, + lastUsedAt: account.lastUsedAt || '0' + }) + } + } + return availableAccounts } @@ -262,6 +333,24 @@ class UnifiedOpenAIScheduler { return false } return !(await this.isAccountRateLimited(accountId)) + } else if (accountType === 'openai-responses') { + const account = await openaiResponsesAccountService.getAccount(accountId) + if ( + !account || + (account.isActive !== true && account.isActive !== 'true') || + account.status === 'error' + ) { + return false + } + // 检查是否可调度 + if (!this._isSchedulable(account.schedulable)) { + logger.info(`🚫 OpenAI-Responses account ${accountId} is not schedulable`) + return false + } + // 检查并清除过期的限流状态 + const isRateLimitCleared = + await openaiResponsesAccountService.checkAndClearRateLimit(accountId) + return account.rateLimitStatus !== 'limited' || isRateLimitCleared } return false } catch (error) { @@ -307,6 +396,18 @@ class UnifiedOpenAIScheduler { try { if (accountType === 'openai') { await openaiAccountService.setAccountRateLimited(accountId, true, resetsInSeconds) + } else if (accountType === 'openai-responses') { + // 对于 OpenAI-Responses 账户,使用与普通 OpenAI 账户类似的处理方式 + const duration = resetsInSeconds ? Math.ceil(resetsInSeconds / 60) : null + await openaiResponsesAccountService.markAccountRateLimited(accountId, duration) + + // 同时更新调度状态,避免继续被调度 + await openaiResponsesAccountService.updateAccount(accountId, { + schedulable: 'false', + rateLimitResetAt: resetsInSeconds + ? new Date(Date.now() + resetsInSeconds * 1000).toISOString() + : new Date(Date.now() + 3600000).toISOString() // 默认1小时 + }) } // 删除会话映射 @@ -329,6 +430,17 @@ class UnifiedOpenAIScheduler { try { if (accountType === 'openai') { await openaiAccountService.setAccountRateLimited(accountId, false) + } else if (accountType === 'openai-responses') { + // 清除 OpenAI-Responses 账户的限流状态 + await openaiResponsesAccountService.updateAccount(accountId, { + rateLimitedAt: '', + rateLimitStatus: '', + rateLimitResetAt: '', + status: 'active', + errorMessage: '', + schedulable: 'true' + }) + logger.info(`✅ Rate limit cleared for OpenAI-Responses account ${accountId}`) } return { success: true } diff --git a/src/utils/webhookNotifier.js b/src/utils/webhookNotifier.js index 34a4976d..0a2704bb 100644 --- a/src/utils/webhookNotifier.js +++ b/src/utils/webhookNotifier.js @@ -58,6 +58,24 @@ class WebhookNotifier { } } + /** + * 发送账号事件通知 + * @param {string} eventType - 事件类型 (account.created, account.updated, account.deleted, account.status_changed) + * @param {Object} data - 事件数据 + */ + async sendAccountEvent(eventType, data) { + try { + // 使用webhookService发送通知 + await webhookService.sendNotification('accountEvent', { + eventType, + ...data, + timestamp: data.timestamp || getISOStringWithTimezone(new Date()) + }) + } catch (error) { + logger.error(`Failed to send account event (${eventType}):`, error) + } + } + /** * 获取错误代码映射 * @param {string} platform - 平台类型 diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 4edd47b3..cc1289bc 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -66,63 +66,359 @@
选择平台 -
- - - - - - + +
+ +
+ +
+
+
+
+ +
+
+ +
+
+

+ Claude +

+

Anthropic

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

+ OpenAI +

+

GPT 系列

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

+ Gemini +

+

Google AI

+
+
+
+ + +
+

+ 选择具体平台类型: +

+
+ + + + + + + + +
+
@@ -131,7 +427,8 @@ !isEdit && form.platform !== 'claude-console' && form.platform !== 'bedrock' && - form.platform !== 'azure_openai' + form.platform !== 'azure_openai' && + form.platform !== 'openai-responses' " >
+ +
+
+ + +

+ 第三方 OpenAI 兼容 API 的基础地址,不要包含具体路径 +

+
+ +
+ +
+ + +
+

+ 第三方服务提供的 API 密钥 +

+
+ +
+ + +

+ 可选项。如果设置,所有请求将使用此 User-Agent;否则透传客户端的 User-Agent +

+
+ +
+ + +

+ 遇到 429 错误时的限流等待时间,默认 60 分钟 +

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

留空表示不更新 API Key

+
+ +
+ + +

+ 留空时将自动使用客户端的 User-Agent,仅在需要固定特定 UA 时填写 +

+
+ +
+ + +

遇到 429 错误时的限流等待时间,默认 60 分钟

+
+ + +
+
+ + +
+
+ + +
+
+
+
@@ -2259,6 +2721,7 @@ const show = ref(true) // OAuth步骤 const oauthStep = ref(1) const loading = ref(false) +const showApiKey = ref(false) // Setup Token 相关状态 const setupTokenLoading = ref(false) @@ -2274,6 +2737,21 @@ const clearingCache = ref(false) // 客户端标识编辑状态(已废弃,不再需要编辑功能) // const editingClientId = ref(false) +// 平台分组状态 +const platformGroup = ref('') + +// 根据现有平台确定分组 +const determinePlatformGroup = (platform) => { + if (['claude', 'claude-console', 'bedrock'].includes(platform)) { + return 'claude' + } else if (['openai', 'openai-responses', 'azure_openai'].includes(platform)) { + return 'openai' + } else if (platform === 'gemini') { + return 'gemini' + } + return '' +} + // 初始化代理配置 const initProxyConfig = () => { if (props.account?.proxy && props.account.proxy.host && props.account.proxy.port) { @@ -2323,6 +2801,9 @@ const form = ref({ apiUrl: props.account?.apiUrl || '', apiKey: props.account?.apiKey || '', priority: props.account?.priority || 50, + // OpenAI-Responses 特定字段 + baseApi: props.account?.baseApi || '', + rateLimitDuration: props.account?.rateLimitDuration || 60, supportedModels: (() => { const models = props.account?.supportedModels if (!models) return [] @@ -2338,7 +2819,6 @@ const form = ref({ })(), userAgent: props.account?.userAgent || '', enableRateLimit: props.account ? props.account.rateLimitDuration > 0 : true, - rateLimitDuration: props.account?.rateLimitDuration || 60, // 额度管理字段 dailyQuota: props.account?.dailyQuota || 0, dailyUsage: props.account?.dailyUsage || 0, @@ -2440,7 +2920,7 @@ const loadAccountUsage = async () => { form.value.dailyUsage = response.dailyUsage || 0 } } catch (error) { - console.warn('Failed to load account usage:', error) + // 静默处理使用量加载失败 } } @@ -2452,6 +2932,19 @@ const loadAccountUsage = async () => { // return form.value.name?.trim() // }) +// 选择平台分组 +const selectPlatformGroup = (group) => { + platformGroup.value = group + // 根据分组自动选择默认平台 + if (group === 'claude') { + form.value.platform = 'claude' + } else if (group === 'openai') { + form.value.platform = 'openai' + } else if (group === 'gemini') { + form.value.platform = 'gemini' + } +} + // 下一步 const nextStep = async () => { // 清除之前的错误 @@ -2702,14 +3195,7 @@ const handleOAuthSuccess = async (tokenInfo) => { showToast(fullMessage, 'error', '', 8000) - // 在控制台打印完整的错误信息以便调试 - console.error('账户创建失败:', { - message: errorMessage, - suggestion, - errorDetails, - errorCode: error.response?.data?.errorCode, - networkError: error.response?.data?.networkError - }) + // 错误已通过 toast 显示给用户 } finally { loading.value = false } @@ -2740,6 +3226,18 @@ const createAccount = async () => { errors.value.apiKey = '请填写 API Key' hasError = true } + } + + // OpenAI-Responses 验证 + if (form.value.platform === 'openai-responses') { + if (!form.value.baseApi || form.value.baseApi.trim() === '') { + errors.value.baseApi = '请填写 API 基础地址' + hasError = true + } + if (!form.value.apiKey || form.value.apiKey.trim() === '') { + errors.value.apiKey = '请填写 API 密钥' + hasError = true + } } else if (form.value.platform === 'bedrock') { // Bedrock 验证 if (!form.value.accessKeyId || form.value.accessKeyId.trim() === '') { @@ -2917,6 +3415,15 @@ const createAccount = async () => { // 额度管理字段 data.dailyQuota = form.value.dailyQuota || 0 data.quotaResetTime = form.value.quotaResetTime || '00:00' + } else if (form.value.platform === 'openai-responses') { + // OpenAI-Responses 账户特定数据 + data.baseApi = form.value.baseApi + data.apiKey = form.value.apiKey + data.userAgent = form.value.userAgent || '' + data.priority = form.value.priority || 50 + data.rateLimitDuration = form.value.rateLimitDuration || 60 + data.dailyQuota = form.value.dailyQuota || 0 + data.quotaResetTime = form.value.quotaResetTime || '00:00' } else if (form.value.platform === 'bedrock') { // Bedrock 账户特定数据 - 构造 awsCredentials 对象 data.awsCredentials = { @@ -2949,6 +3456,8 @@ const createAccount = async () => { result = await accountsStore.createClaudeAccount(data) } else if (form.value.platform === 'claude-console') { result = await accountsStore.createClaudeConsoleAccount(data) + } else if (form.value.platform === 'openai-responses') { + result = await accountsStore.createOpenAIResponsesAccount(data) } else if (form.value.platform === 'bedrock') { result = await accountsStore.createBedrockAccount(data) } else if (form.value.platform === 'openai') { @@ -2984,14 +3493,7 @@ const createAccount = async () => { showToast(fullMessage, 'error', '', 8000) - // 在控制台打印完整的错误信息以便调试 - console.error('账户创建失败:', { - message: errorMessage, - suggestion, - errorDetails, - errorCode: error.response?.data?.errorCode, - networkError: error.response?.data?.networkError - }) + // 错误已通过 toast 显示给用户 } finally { loading.value = false } @@ -3160,6 +3662,19 @@ const updateAccount = async () => { data.quotaResetTime = form.value.quotaResetTime || '00:00' } + // OpenAI-Responses 特定更新 + if (props.account.platform === 'openai-responses') { + data.baseApi = form.value.baseApi + if (form.value.apiKey) { + data.apiKey = form.value.apiKey + } + data.userAgent = form.value.userAgent || '' + data.priority = form.value.priority || 50 + data.rateLimitDuration = form.value.rateLimitDuration || 60 + data.dailyQuota = form.value.dailyQuota || 0 + data.quotaResetTime = form.value.quotaResetTime || '00:00' + } + // Bedrock 特定更新 if (props.account.platform === 'bedrock') { // 只有当有凭证变更时才构造 awsCredentials 对象 @@ -3205,6 +3720,8 @@ const updateAccount = async () => { await accountsStore.updateClaudeAccount(props.account.id, data) } else if (props.account.platform === 'claude-console') { await accountsStore.updateClaudeConsoleAccount(props.account.id, data) + } else if (props.account.platform === 'openai-responses') { + await accountsStore.updateOpenAIResponsesAccount(props.account.id, data) } else if (props.account.platform === 'bedrock') { await accountsStore.updateBedrockAccount(props.account.id, data) } else if (props.account.platform === 'openai') { @@ -3240,14 +3757,7 @@ const updateAccount = async () => { showToast(fullMessage, 'error', '', 8000) - // 在控制台打印完整的错误信息以便调试 - console.error('账户更新失败:', { - message: errorMessage, - suggestion, - errorDetails, - errorCode: error.response?.data?.errorCode, - networkError: error.response?.data?.networkError - }) + // 错误已通过 toast 显示给用户 } finally { loading.value = false } @@ -3359,8 +3869,12 @@ watch( () => form.value.platform, (newPlatform) => { // 处理添加方式的自动切换 - if (newPlatform === 'claude-console' || newPlatform === 'bedrock') { - form.value.addType = 'manual' // Claude Console 和 Bedrock 只支持手动模式 + if ( + newPlatform === 'claude-console' || + newPlatform === 'bedrock' || + newPlatform === 'openai-responses' + ) { + form.value.addType = 'manual' // Claude Console、Bedrock 和 OpenAI-Responses 只支持手动模式 } else if (newPlatform === 'claude') { // 切换到 Claude 时,使用 Setup Token 作为默认方式 form.value.addType = 'setup-token' @@ -3421,14 +3935,14 @@ watch(setupTokenAuthCode, (newValue) => { // 成功提取授权码 setupTokenAuthCode.value = code showToast('成功提取授权码!', 'success') - console.log('Successfully extracted authorization code from URL') + // Successfully extracted authorization code from URL } else { // URL 中没有 code 参数 showToast('URL 中未找到授权码参数,请检查链接是否正确', 'error') } } catch (error) { // URL 解析失败 - console.error('Failed to parse URL:', error) + // Failed to parse URL showToast('链接格式错误,请检查是否为完整的 URL', 'error') } } else { @@ -3658,7 +4172,7 @@ const fetchUnifiedUserAgent = async () => { unifiedUserAgent.value = '' } } catch (error) { - console.warn('Failed to fetch unified User-Agent:', error) + // Failed to fetch unified User-Agent unifiedUserAgent.value = '' } } @@ -3675,7 +4189,7 @@ const clearUnifiedCache = async () => { showToast('清除缓存失败', 'error') } } catch (error) { - console.error('Failed to clear unified User-Agent cache:', error) + // Failed to clear unified User-Agent cache showToast('清除缓存失败:' + (error.message || '未知错误'), 'error') } finally { clearingCache.value = false @@ -3710,6 +4224,9 @@ const handleUnifiedClientIdChange = () => { // 组件挂载时获取统一 User-Agent 信息 onMounted(() => { + // 初始化平台分组 + platformGroup.value = determinePlatformGroup(form.value.platform) + // 获取Claude Code统一User-Agent信息 fetchUnifiedUserAgent() // 如果是编辑模式且是Claude Console账户,加载使用情况 @@ -3728,3 +4245,20 @@ watch( } ) + + diff --git a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue index 4e78520f..96bdd0f3 100644 --- a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue @@ -886,31 +886,61 @@ onMounted(async () => { availableTags.value = await apiKeysStore.fetchTags() // 初始化账号数据 if (props.accounts) { + // 合并 OpenAI 和 OpenAI-Responses 账号 + const openaiAccounts = [] + if (props.accounts.openai) { + props.accounts.openai.forEach((account) => { + openaiAccounts.push({ + ...account, + platform: 'openai' + }) + }) + } + if (props.accounts.openaiResponses) { + props.accounts.openaiResponses.forEach((account) => { + openaiAccounts.push({ + ...account, + platform: 'openai-responses' + }) + }) + } + localAccounts.value = { claude: props.accounts.claude || [], gemini: props.accounts.gemini || [], - openai: props.accounts.openai || [], + openai: openaiAccounts, bedrock: props.accounts.bedrock || [], // 添加 Bedrock 账号 claudeGroups: props.accounts.claudeGroups || [], geminiGroups: props.accounts.geminiGroups || [], openaiGroups: props.accounts.openaiGroups || [] } } + + // 自动加载账号数据 + await refreshAccounts() }) // 刷新账号列表 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'), // 添加 Bedrock 账号获取 - apiClient.get('/admin/account-groups') - ]) + const [ + claudeData, + claudeConsoleData, + geminiData, + openaiData, + openaiResponsesData, + 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/openai-responses-accounts'), // 获取 OpenAI-Responses 账号 + apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取 + apiClient.get('/admin/account-groups') + ]) // 合并Claude OAuth账户和Claude Console账户 const claudeAccounts = [] @@ -944,13 +974,31 @@ const refreshAccounts = async () => { })) } + // 合并 OpenAI 和 OpenAI-Responses 账号 + const openaiAccounts = [] + if (openaiData.success) { - localAccounts.value.openai = (openaiData.data || []).map((account) => ({ - ...account, - isDedicated: account.accountType === 'dedicated' // 保留以便向后兼容 - })) + ;(openaiData.data || []).forEach((account) => { + openaiAccounts.push({ + ...account, + platform: 'openai', + isDedicated: account.accountType === 'dedicated' // 保留以便向后兼容 + }) + }) } + if (openaiResponsesData.success) { + ;(openaiResponsesData.data || []).forEach((account) => { + openaiAccounts.push({ + ...account, + platform: 'openai-responses', + isDedicated: account.accountType === 'dedicated' // 保留以便向后兼容 + }) + }) + } + + localAccounts.value.openai = openaiAccounts + if (bedrockData.success) { localAccounts.value.bedrock = (bedrockData.data || []).map((account) => ({ ...account, diff --git a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue index fda19ade..4da8d9e5 100644 --- a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue @@ -911,15 +911,23 @@ const updateApiKey = async () => { 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'), // 添加 Bedrock 账号获取 - apiClient.get('/admin/account-groups') - ]) + const [ + claudeData, + claudeConsoleData, + geminiData, + openaiData, + openaiResponsesData, + 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/openai-responses-accounts'), // 获取 OpenAI-Responses 账号 + apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取 + apiClient.get('/admin/account-groups') + ]) // 合并Claude OAuth账户和Claude Console账户 const claudeAccounts = [] @@ -953,13 +961,31 @@ const refreshAccounts = async () => { })) } + // 合并 OpenAI 和 OpenAI-Responses 账号 + const openaiAccounts = [] + if (openaiData.success) { - localAccounts.value.openai = (openaiData.data || []).map((account) => ({ - ...account, - isDedicated: account.accountType === 'dedicated' - })) + ;(openaiData.data || []).forEach((account) => { + openaiAccounts.push({ + ...account, + platform: 'openai', + isDedicated: account.accountType === 'dedicated' + }) + }) } + if (openaiResponsesData.success) { + ;(openaiResponsesData.data || []).forEach((account) => { + openaiAccounts.push({ + ...account, + platform: 'openai-responses', + isDedicated: account.accountType === 'dedicated' + }) + }) + } + + localAccounts.value.openai = openaiAccounts + if (bedrockData.success) { localAccounts.value.bedrock = (bedrockData.data || []).map((account) => ({ ...account, @@ -991,7 +1017,7 @@ const loadUsers = async () => { availableUsers.value = response.data || [] } } catch (error) { - console.error('Failed to load users:', error) + // console.error('Failed to load users:', error) availableUsers.value = [ { id: 'admin', @@ -1017,7 +1043,7 @@ onMounted(async () => { supportedClients.value = clients || [] availableTags.value = tags || [] } catch (error) { - console.error('Error loading initial data:', error) + // console.error('Error loading initial data:', error) // Fallback to empty arrays if loading fails supportedClients.value = [] availableTags.value = [] @@ -1025,10 +1051,29 @@ onMounted(async () => { // 初始化账号数据 if (props.accounts) { + // 合并 OpenAI 和 OpenAI-Responses 账号 + const openaiAccounts = [] + if (props.accounts.openai) { + props.accounts.openai.forEach((account) => { + openaiAccounts.push({ + ...account, + platform: 'openai' + }) + }) + } + if (props.accounts.openaiResponses) { + props.accounts.openaiResponses.forEach((account) => { + openaiAccounts.push({ + ...account, + platform: 'openai-responses' + }) + }) + } + localAccounts.value = { claude: props.accounts.claude || [], gemini: props.accounts.gemini || [], - openai: props.accounts.openai || [], + openai: openaiAccounts, bedrock: props.accounts.bedrock || [], // 添加 Bedrock 账号 claudeGroups: props.accounts.claudeGroups || [], geminiGroups: props.accounts.geminiGroups || [], @@ -1036,6 +1081,9 @@ onMounted(async () => { } } + // 自动加载账号数据 + await refreshAccounts() + form.name = props.apiKey.name // 处理速率限制迁移:如果有tokenLimit且没有rateLimitCost,提示用户 @@ -1045,7 +1093,7 @@ onMounted(async () => { // 如果有历史tokenLimit但没有rateLimitCost,提示用户需要重新设置 if (props.apiKey.tokenLimit > 0 && !props.apiKey.rateLimitCost) { // 可以根据需要添加提示,或者自动迁移(这里选择让用户手动设置) - console.log('检测到历史Token限制,请考虑设置费用限制') + // console.log('检测到历史Token限制,请考虑设置费用限制') } form.rateLimitWindow = props.apiKey.rateLimitWindow || '' @@ -1061,7 +1109,10 @@ onMounted(async () => { form.claudeAccountId = props.apiKey.claudeAccountId || '' } form.geminiAccountId = props.apiKey.geminiAccountId || '' + + // 处理 OpenAI 账号 - 直接使用后端传来的值(已包含 responses: 前缀) form.openaiAccountId = props.apiKey.openaiAccountId || '' + form.bedrockAccountId = props.apiKey.bedrockAccountId || '' // 添加 Bedrock 账号ID初始化 form.restrictedModels = props.apiKey.restrictedModels || [] form.allowedClients = props.apiKey.allowedClients || [] diff --git a/web/admin-spa/src/components/apikeys/NewApiKeyModal.vue b/web/admin-spa/src/components/apikeys/NewApiKeyModal.vue index 128c7736..dd41cd68 100644 --- a/web/admin-spa/src/components/apikeys/NewApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/NewApiKeyModal.vue @@ -167,7 +167,7 @@ const copyApiKey = async () => { await navigator.clipboard.writeText(key) showToast('API Key 已复制到剪贴板', 'success') } catch (error) { - console.error('Failed to copy:', error) + // console.error('Failed to copy:', error) // 降级方案:创建一个临时文本区域 const textArea = document.createElement('textarea') textArea.value = key diff --git a/web/admin-spa/src/components/common/AccountSelector.vue b/web/admin-spa/src/components/common/AccountSelector.vue index f5c18687..ac9e4894 100644 --- a/web/admin-spa/src/components/common/AccountSelector.vue +++ b/web/admin-spa/src/components/common/AccountSelector.vue @@ -99,7 +99,13 @@
- {{ platform === 'claude' ? 'Claude OAuth 专属账号' : 'OAuth 专属账号' }} + {{ + platform === 'claude' + ? 'Claude OAuth 专属账号' + : platform === 'openai' + ? 'OpenAI 专属账号' + : 'OAuth 专属账号' + }}
+ +
+
+ OpenAI-Responses 专属账号 +
+
+
+
+ {{ account.name }} + + {{ getAccountStatusText(account) }} + +
+ + {{ formatDate(account.createdAt) }} + +
+
+
+
['claude', 'gemini'].includes(value) + validator: (value) => ['claude', 'gemini', 'openai', 'bedrock'].includes(value) }, accounts: { type: Array, @@ -251,6 +296,15 @@ const selectedLabel = computed(() => { return account ? `${account.name} (${getAccountStatusText(account)})` : '' } + // OpenAI-Responses 账号 + if (props.modelValue.startsWith('responses:')) { + const accountId = props.modelValue.substring(10) + const account = props.accounts.find( + (a) => a.id === accountId && a.platform === 'openai-responses' + ) + return account ? `${account.name} (${getAccountStatusText(account)})` : '' + } + // OAuth 账号 const account = props.accounts.find((a) => a.id === props.modelValue) return account ? `${account.name} (${getAccountStatusText(account)})` : '' @@ -260,8 +314,11 @@ const selectedLabel = computed(() => { const getAccountStatusText = (account) => { if (!account) return '未知' + // 处理 OpenAI-Responses 账号(isActive 可能是字符串) + const isActive = account.isActive === 'true' || account.isActive === true + // 优先使用 isActive 判断 - if (account.isActive === false) { + if (!isActive) { // 根据 status 提供更详细的状态信息 switch (account.status) { case 'unauthorized': @@ -272,11 +329,18 @@ const getAccountStatusText = (account) => { return '待验证' case 'rate_limited': return '限流中' + case 'quota_exceeded': + return '额度超限' default: return '异常' } } + // 对于激活的账号,如果是限流状态也要显示 + if (account.status === 'rate_limited') { + return '限流中' + } + return '正常' } @@ -289,18 +353,42 @@ const sortedAccounts = computed(() => { }) }) -// 过滤的分组 +// 过滤的分组(根据平台类型过滤) const filteredGroups = computed(() => { - if (!searchQuery.value) return props.groups - const query = searchQuery.value.toLowerCase() - return props.groups.filter((group) => group.name.toLowerCase().includes(query)) + // 只显示与当前平台匹配的分组 + let groups = props.groups.filter((group) => { + // 如果分组有platform属性,则必须匹配当前平台 + // 如果没有platform属性,则认为是旧数据,根据平台判断 + if (group.platform) { + return group.platform === props.platform + } + // 向后兼容:如果没有platform字段,通过其他方式判断 + return true + }) + + if (searchQuery.value) { + const query = searchQuery.value.toLowerCase() + groups = groups.filter((group) => group.name.toLowerCase().includes(query)) + } + + return groups }) // 过滤的 OAuth 账号 const filteredOAuthAccounts = computed(() => { - let accounts = sortedAccounts.value.filter((a) => - props.platform === 'claude' ? a.platform === 'claude-oauth' : a.platform !== 'claude-console' - ) + let accounts = [] + + if (props.platform === 'claude') { + accounts = sortedAccounts.value.filter((a) => a.platform === 'claude-oauth') + } else if (props.platform === 'openai') { + // 对于 OpenAI,只显示 openai 类型的账号 + accounts = sortedAccounts.value.filter((a) => a.platform === 'openai') + } else { + // 其他平台显示所有非特殊类型的账号 + accounts = sortedAccounts.value.filter( + (a) => !['claude-oauth', 'claude-console', 'openai-responses'].includes(a.platform) + ) + } if (searchQuery.value) { const query = searchQuery.value.toLowerCase() @@ -324,12 +412,27 @@ const filteredConsoleAccounts = computed(() => { return accounts }) +// 过滤的 OpenAI-Responses 账号 +const filteredOpenAIResponsesAccounts = computed(() => { + if (props.platform !== 'openai') return [] + + let accounts = sortedAccounts.value.filter((a) => a.platform === 'openai-responses') + + if (searchQuery.value) { + const query = searchQuery.value.toLowerCase() + accounts = accounts.filter((account) => account.name.toLowerCase().includes(query)) + } + + return accounts +}) + // 是否有搜索结果 const hasResults = computed(() => { return ( filteredGroups.value.length > 0 || filteredOAuthAccounts.value.length > 0 || - filteredConsoleAccounts.value.length > 0 + filteredConsoleAccounts.value.length > 0 || + filteredOpenAIResponsesAccounts.value.length > 0 ) }) diff --git a/web/admin-spa/src/stores/accounts.js b/web/admin-spa/src/stores/accounts.js index f7653a15..f21caffc 100644 --- a/web/admin-spa/src/stores/accounts.js +++ b/web/admin-spa/src/stores/accounts.js @@ -10,6 +10,7 @@ export const useAccountsStore = defineStore('accounts', () => { const geminiAccounts = ref([]) const openaiAccounts = ref([]) const azureOpenaiAccounts = ref([]) + const openaiResponsesAccounts = ref([]) const loading = ref(false) const error = ref(null) const sortBy = ref('') @@ -131,6 +132,25 @@ export const useAccountsStore = defineStore('accounts', () => { } } + // 获取OpenAI-Responses账户列表 + const fetchOpenAIResponsesAccounts = async () => { + loading.value = true + error.value = null + try { + const response = await apiClient.get('/admin/openai-responses-accounts') + if (response.success) { + openaiResponsesAccounts.value = response.data || [] + } else { + throw new Error(response.message || '获取OpenAI-Responses账户失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + // 获取所有账户 const fetchAllAccounts = async () => { loading.value = true @@ -142,7 +162,8 @@ export const useAccountsStore = defineStore('accounts', () => { fetchBedrockAccounts(), fetchGeminiAccounts(), fetchOpenAIAccounts(), - fetchAzureOpenAIAccounts() + fetchAzureOpenAIAccounts(), + fetchOpenAIResponsesAccounts() ]) } catch (err) { error.value = err.message @@ -272,6 +293,26 @@ export const useAccountsStore = defineStore('accounts', () => { } } + // 创建OpenAI-Responses账户 + const createOpenAIResponsesAccount = async (data) => { + loading.value = true + error.value = null + try { + const response = await apiClient.post('/admin/openai-responses-accounts', data) + if (response.success) { + await fetchOpenAIResponsesAccounts() + return response.data + } else { + throw new Error(response.message || '创建OpenAI-Responses账户失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + // 更新Claude账户 const updateClaudeAccount = async (id, data) => { loading.value = true @@ -392,6 +433,26 @@ export const useAccountsStore = defineStore('accounts', () => { } } + // 更新OpenAI-Responses账户 + const updateOpenAIResponsesAccount = async (id, data) => { + loading.value = true + error.value = null + try { + const response = await apiClient.put(`/admin/openai-responses-accounts/${id}`, data) + if (response.success) { + await fetchOpenAIResponsesAccounts() + return response + } else { + throw new Error(response.message || '更新OpenAI-Responses账户失败') + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + // 切换账户状态 const toggleAccount = async (platform, id) => { loading.value = true @@ -410,6 +471,8 @@ export const useAccountsStore = defineStore('accounts', () => { endpoint = `/admin/openai-accounts/${id}/toggle` } else if (platform === 'azure_openai') { endpoint = `/admin/azure-openai-accounts/${id}/toggle` + } else if (platform === 'openai-responses') { + endpoint = `/admin/openai-responses-accounts/${id}/toggle` } else { endpoint = `/admin/openai-accounts/${id}/toggle` } @@ -428,6 +491,8 @@ export const useAccountsStore = defineStore('accounts', () => { await fetchOpenAIAccounts() } else if (platform === 'azure_openai') { await fetchAzureOpenAIAccounts() + } else if (platform === 'openai-responses') { + await fetchOpenAIResponsesAccounts() } else { await fetchOpenAIAccounts() } @@ -461,6 +526,8 @@ export const useAccountsStore = defineStore('accounts', () => { endpoint = `/admin/openai-accounts/${id}` } else if (platform === 'azure_openai') { endpoint = `/admin/azure-openai-accounts/${id}` + } else if (platform === 'openai-responses') { + endpoint = `/admin/openai-responses-accounts/${id}` } else { endpoint = `/admin/openai-accounts/${id}` } @@ -479,6 +546,8 @@ export const useAccountsStore = defineStore('accounts', () => { await fetchOpenAIAccounts() } else if (platform === 'azure_openai') { await fetchAzureOpenAIAccounts() + } else if (platform === 'openai-responses') { + await fetchOpenAIResponsesAccounts() } else { await fetchOpenAIAccounts() } @@ -658,6 +727,7 @@ export const useAccountsStore = defineStore('accounts', () => { geminiAccounts.value = [] openaiAccounts.value = [] azureOpenaiAccounts.value = [] + openaiResponsesAccounts.value = [] loading.value = false error.value = null sortBy.value = '' @@ -672,6 +742,7 @@ export const useAccountsStore = defineStore('accounts', () => { geminiAccounts, openaiAccounts, azureOpenaiAccounts, + openaiResponsesAccounts, loading, error, sortBy, @@ -684,6 +755,7 @@ export const useAccountsStore = defineStore('accounts', () => { fetchGeminiAccounts, fetchOpenAIAccounts, fetchAzureOpenAIAccounts, + fetchOpenAIResponsesAccounts, fetchAllAccounts, createClaudeAccount, createClaudeConsoleAccount, @@ -691,12 +763,14 @@ export const useAccountsStore = defineStore('accounts', () => { createGeminiAccount, createOpenAIAccount, createAzureOpenAIAccount, + createOpenAIResponsesAccount, updateClaudeAccount, updateClaudeConsoleAccount, updateBedrockAccount, updateGeminiAccount, updateOpenAIAccount, updateAzureOpenAIAccount, + updateOpenAIResponsesAccount, toggleAccount, deleteAccount, refreshClaudeToken, diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index 7a8719df..5275546c 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -7,7 +7,7 @@ 账户管理

- 管理您的 Claude、Gemini、OpenAI 和 Azure OpenAI 账户及代理配置 + 管理您的 Claude、Gemini、OpenAI、Azure OpenAI 和 OpenAI-Responses 账户及代理配置

@@ -350,6 +350,19 @@ >API Key
+
+ + OpenAI-Responses + + API Key +