mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 新增 OpenAI-Responses 账户管理功能和独立自动停止标记机制
## 功能新增 - 实现 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,8 +36,34 @@ async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel =
|
||||
throw new Error('No available OpenAI account found')
|
||||
}
|
||||
|
||||
// 获取账户详情
|
||||
let account = await openaiAccountService.getAccount(result.accountId)
|
||||
// 根据账户类型获取账户详情
|
||||
let account,
|
||||
accessToken,
|
||||
proxy = null
|
||||
|
||||
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 {
|
||||
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`)
|
||||
}
|
||||
@@ -54,18 +82,19 @@ async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel =
|
||||
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}`)
|
||||
throw new Error(
|
||||
`Token expired and no refresh token available for account ${account.name}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 解密 accessToken(account.accessToken 是加密的)
|
||||
const accessToken = openaiAccountService.decrypt(account.accessToken)
|
||||
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
|
||||
@@ -75,10 +104,13 @@ async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel =
|
||||
}
|
||||
|
||||
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 || {}
|
||||
|
||||
|
||||
@@ -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 状态
|
||||
// 只恢复因临时错误而自动停止的账户
|
||||
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'
|
||||
// 只恢复因临时错误而自动停止的账户
|
||||
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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
574
src/services/openaiResponsesAccountService.js
Normal file
574
src/services/openaiResponsesAccountService.js
Normal file
@@ -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()
|
||||
708
src/services/openaiResponsesRelayService.js
Normal file
708
src/services/openaiResponsesRelayService.js
Normal file
@@ -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()
|
||||
@@ -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'
|
||||
) {
|
||||
// 检查是否被限流
|
||||
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 }
|
||||
|
||||
@@ -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 - 平台类型
|
||||
|
||||
@@ -66,63 +66,359 @@
|
||||
<div class="space-y-6">
|
||||
<div v-if="!isEdit">
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>平台</label
|
||||
>选择平台</label
|
||||
>
|
||||
<!-- 平台分组选择器 -->
|
||||
<div class="space-y-3">
|
||||
<!-- 分组选择器 -->
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<!-- Claude 分组 -->
|
||||
<div
|
||||
class="group relative cursor-pointer overflow-hidden rounded-lg border-2 transition-all duration-200"
|
||||
:class="[
|
||||
platformGroup === 'claude'
|
||||
? 'border-indigo-500 bg-gradient-to-br from-indigo-50 to-purple-50 shadow-md dark:from-indigo-900/20 dark:to-purple-900/20'
|
||||
: 'border-gray-200 bg-white hover:border-indigo-300 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:hover:border-indigo-600'
|
||||
]"
|
||||
@click="selectPlatformGroup('claude')"
|
||||
>
|
||||
<div class="p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md bg-gradient-to-br from-indigo-500 to-purple-600"
|
||||
>
|
||||
<i class="fas fa-brain text-sm text-white"></i>
|
||||
</div>
|
||||
<div
|
||||
v-if="platformGroup === 'claude'"
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full bg-indigo-500"
|
||||
>
|
||||
<i class="fas fa-check text-xs text-white"></i>
|
||||
</div>
|
||||
</div>
|
||||
<h4 class="mt-2 text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Claude
|
||||
</h4>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">Anthropic</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI 分组 -->
|
||||
<div
|
||||
class="group relative cursor-pointer overflow-hidden rounded-lg border-2 transition-all duration-200"
|
||||
:class="[
|
||||
platformGroup === 'openai'
|
||||
? 'border-emerald-500 bg-gradient-to-br from-emerald-50 to-teal-50 shadow-md dark:from-emerald-900/20 dark:to-teal-900/20'
|
||||
: 'border-gray-200 bg-white hover:border-emerald-300 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:hover:border-emerald-600'
|
||||
]"
|
||||
@click="selectPlatformGroup('openai')"
|
||||
>
|
||||
<div class="p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md bg-gradient-to-br from-emerald-500 to-teal-600"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.8956zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.4069-.6813zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
v-if="platformGroup === 'openai'"
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full bg-emerald-500"
|
||||
>
|
||||
<i class="fas fa-check text-xs text-white"></i>
|
||||
</div>
|
||||
</div>
|
||||
<h4 class="mt-2 text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
OpenAI
|
||||
</h4>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">GPT 系列</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gemini 分组 -->
|
||||
<div
|
||||
class="group relative cursor-pointer overflow-hidden rounded-lg border-2 transition-all duration-200"
|
||||
:class="[
|
||||
platformGroup === 'gemini'
|
||||
? 'border-blue-500 bg-gradient-to-br from-blue-50 to-indigo-50 shadow-md dark:from-blue-900/20 dark:to-indigo-900/20'
|
||||
: 'border-gray-200 bg-white hover:border-blue-300 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:hover:border-blue-600'
|
||||
]"
|
||||
@click="selectPlatformGroup('gemini')"
|
||||
>
|
||||
<div class="p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md bg-gradient-to-br from-blue-500 to-indigo-600"
|
||||
>
|
||||
<i class="fab fa-google text-sm text-white"></i>
|
||||
</div>
|
||||
<div
|
||||
v-if="platformGroup === 'gemini'"
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full bg-blue-500"
|
||||
>
|
||||
<i class="fas fa-check text-xs text-white"></i>
|
||||
</div>
|
||||
</div>
|
||||
<h4 class="mt-2 text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Gemini
|
||||
</h4>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">Google AI</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 子平台选择器 -->
|
||||
<div
|
||||
v-if="platformGroup"
|
||||
class="animate-fadeIn rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50"
|
||||
>
|
||||
<p class="mb-2 text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
选择具体平台类型:
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<!-- Claude 子选项 -->
|
||||
<template v-if="platformGroup === 'claude'">
|
||||
<label
|
||||
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all"
|
||||
:class="[
|
||||
form.platform === 'claude'
|
||||
? 'border-indigo-500 bg-indigo-50 dark:border-indigo-400 dark:bg-indigo-900/30'
|
||||
: 'border-gray-300 bg-white hover:border-indigo-400 hover:bg-indigo-50/50 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-indigo-500 dark:hover:bg-indigo-900/20'
|
||||
]"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.platform"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
class="sr-only"
|
||||
type="radio"
|
||||
value="claude"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Claude</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-brain text-sm text-indigo-600 dark:text-indigo-400"></i>
|
||||
<div>
|
||||
<span class="block text-xs font-medium text-gray-900 dark:text-gray-100"
|
||||
>Claude Code</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">官方</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.platform === 'claude'"
|
||||
class="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-indigo-500"
|
||||
>
|
||||
<i class="fas fa-check text-xs text-white"></i>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
|
||||
<label
|
||||
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all"
|
||||
:class="[
|
||||
form.platform === 'claude-console'
|
||||
? 'border-purple-500 bg-purple-50 dark:border-purple-400 dark:bg-purple-900/30'
|
||||
: 'border-gray-300 bg-white hover:border-purple-400 hover:bg-purple-50/50 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-purple-500 dark:hover:bg-purple-900/20'
|
||||
]"
|
||||
>
|
||||
<input
|
||||
v-model="form.platform"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
class="sr-only"
|
||||
type="radio"
|
||||
value="claude-console"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Claude Console</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<i
|
||||
class="fas fa-terminal text-sm text-purple-600 dark:text-purple-400"
|
||||
></i>
|
||||
<div>
|
||||
<span class="block text-xs font-medium text-gray-900 dark:text-gray-100"
|
||||
>Claude Console</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">标准API</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.platform === 'claude-console'"
|
||||
class="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-purple-500"
|
||||
>
|
||||
<i class="fas fa-check text-xs text-white"></i>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
|
||||
<label
|
||||
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all"
|
||||
:class="[
|
||||
form.platform === 'bedrock'
|
||||
? 'border-orange-500 bg-orange-50 dark:border-orange-400 dark:bg-orange-900/30'
|
||||
: 'border-gray-300 bg-white hover:border-orange-400 hover:bg-orange-50/50 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-orange-500 dark:hover:bg-orange-900/20'
|
||||
]"
|
||||
>
|
||||
<input
|
||||
v-model="form.platform"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
value="gemini"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Gemini</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.platform"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
value="openai"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">OpenAI</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.platform"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
value="azure_openai"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Azure OpenAI</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.platform"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
class="sr-only"
|
||||
type="radio"
|
||||
value="bedrock"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Bedrock</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fab fa-aws text-sm text-orange-600 dark:text-orange-400"></i>
|
||||
<div>
|
||||
<span class="block text-xs font-medium text-gray-900 dark:text-gray-100"
|
||||
>Bedrock</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">AWS</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.platform === 'bedrock'"
|
||||
class="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-orange-500"
|
||||
>
|
||||
<i class="fas fa-check text-xs text-white"></i>
|
||||
</div>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<!-- OpenAI 子选项 -->
|
||||
<template v-if="platformGroup === 'openai'">
|
||||
<label
|
||||
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all"
|
||||
:class="[
|
||||
form.platform === 'openai'
|
||||
? 'border-emerald-500 bg-emerald-50 dark:border-emerald-400 dark:bg-emerald-900/30'
|
||||
: 'border-gray-300 bg-white hover:border-emerald-400 hover:bg-emerald-50/50 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-emerald-500 dark:hover:bg-emerald-900/20'
|
||||
]"
|
||||
>
|
||||
<input
|
||||
v-model="form.platform"
|
||||
class="sr-only"
|
||||
type="radio"
|
||||
value="openai"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<i
|
||||
class="fas fa-robot text-sm text-emerald-600 dark:text-emerald-400"
|
||||
></i>
|
||||
<div>
|
||||
<span class="block text-xs font-medium text-gray-900 dark:text-gray-100"
|
||||
>Codex Cli</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">官方</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.platform === 'openai'"
|
||||
class="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-emerald-500"
|
||||
>
|
||||
<i class="fas fa-check text-xs text-white"></i>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label
|
||||
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all"
|
||||
:class="[
|
||||
form.platform === 'openai-responses'
|
||||
? 'border-teal-500 bg-teal-50 dark:border-teal-400 dark:bg-teal-900/30'
|
||||
: 'border-gray-300 bg-white hover:border-teal-400 hover:bg-teal-50/50 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-teal-500 dark:hover:bg-teal-900/20'
|
||||
]"
|
||||
>
|
||||
<input
|
||||
v-model="form.platform"
|
||||
class="sr-only"
|
||||
type="radio"
|
||||
value="openai-responses"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-server text-sm text-teal-600 dark:text-teal-400"></i>
|
||||
<div>
|
||||
<span class="block text-xs font-medium text-gray-900 dark:text-gray-100"
|
||||
>Responses</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400"
|
||||
>Openai-Responses</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.platform === 'openai-responses'"
|
||||
class="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-teal-500"
|
||||
>
|
||||
<i class="fas fa-check text-xs text-white"></i>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label
|
||||
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all"
|
||||
:class="[
|
||||
form.platform === 'azure_openai'
|
||||
? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/30'
|
||||
: 'border-gray-300 bg-white hover:border-blue-400 hover:bg-blue-50/50 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-blue-500 dark:hover:bg-blue-900/20'
|
||||
]"
|
||||
>
|
||||
<input
|
||||
v-model="form.platform"
|
||||
class="sr-only"
|
||||
type="radio"
|
||||
value="azure_openai"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fab fa-microsoft text-sm text-blue-600 dark:text-blue-400"></i>
|
||||
<div>
|
||||
<span class="block text-xs font-medium text-gray-900 dark:text-gray-100"
|
||||
>Azure</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400"
|
||||
>Azure Openai</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.platform === 'azure_openai'"
|
||||
class="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-blue-500"
|
||||
>
|
||||
<i class="fas fa-check text-xs text-white"></i>
|
||||
</div>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<!-- Gemini 子选项 -->
|
||||
<template v-if="platformGroup === 'gemini'">
|
||||
<label
|
||||
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all"
|
||||
:class="[
|
||||
form.platform === 'gemini'
|
||||
? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/30'
|
||||
: 'border-gray-300 bg-white hover:border-blue-400 hover:bg-blue-50/50 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-blue-500 dark:hover:bg-blue-900/20'
|
||||
]"
|
||||
>
|
||||
<input
|
||||
v-model="form.platform"
|
||||
class="sr-only"
|
||||
type="radio"
|
||||
value="gemini"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fab fa-google text-sm text-blue-600 dark:text-blue-400"></i>
|
||||
<div>
|
||||
<span class="block text-xs font-medium text-gray-900 dark:text-gray-100"
|
||||
>Gemini Cli</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">官方</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.platform === 'gemini'"
|
||||
class="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-blue-500"
|
||||
>
|
||||
<i class="fas fa-check text-xs text-white"></i>
|
||||
</div>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -131,7 +427,8 @@
|
||||
!isEdit &&
|
||||
form.platform !== 'claude-console' &&
|
||||
form.platform !== 'bedrock' &&
|
||||
form.platform !== 'azure_openai'
|
||||
form.platform !== 'azure_openai' &&
|
||||
form.platform !== 'openai-responses'
|
||||
"
|
||||
>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
@@ -839,6 +1136,81 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI-Responses 特定字段 -->
|
||||
<div v-if="form.platform === 'openai-responses' && !isEdit" class="space-y-4">
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>API 基础地址 *</label
|
||||
>
|
||||
<input
|
||||
v-model="form.baseApi"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
placeholder="https://api.example.com/v1"
|
||||
required
|
||||
type="url"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
第三方 OpenAI 兼容 API 的基础地址,不要包含具体路径
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>API 密钥 *</label
|
||||
>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="form.apiKey"
|
||||
class="form-input w-full border-gray-300 pr-10 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
placeholder="sk-xxxxxxxxxxxx"
|
||||
required
|
||||
:type="showApiKey ? 'text' : 'password'"
|
||||
/>
|
||||
<button
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-400"
|
||||
type="button"
|
||||
@click="showApiKey = !showApiKey"
|
||||
>
|
||||
<i :class="showApiKey ? 'fas fa-eye-slash' : 'fas fa-eye'" />
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
第三方服务提供的 API 密钥
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>自定义 User-Agent (可选)</label
|
||||
>
|
||||
<input
|
||||
v-model="form.userAgent"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
placeholder="留空则透传原始请求的 User-Agent"
|
||||
type="text"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
可选项。如果设置,所有请求将使用此 User-Agent;否则透传客户端的 User-Agent
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>限流时长(分钟)</label
|
||||
>
|
||||
<input
|
||||
v-model.number="form.rateLimitDuration"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
min="1"
|
||||
placeholder="60"
|
||||
type="number"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
遇到 429 错误时的限流等待时间,默认 60 分钟
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Claude 订阅类型选择 -->
|
||||
<div v-if="form.platform === 'claude'">
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
@@ -1015,7 +1387,9 @@
|
||||
v-if="
|
||||
form.addType === 'manual' &&
|
||||
form.platform !== 'claude-console' &&
|
||||
form.platform !== 'bedrock'
|
||||
form.platform !== 'bedrock' &&
|
||||
form.platform !== 'azure_openai' &&
|
||||
form.platform !== 'openai-responses'
|
||||
"
|
||||
class="space-y-4 rounded-lg border border-blue-200 bg-blue-50 p-4"
|
||||
>
|
||||
@@ -1174,7 +1548,8 @@
|
||||
(form.addType === 'oauth' || form.addType === 'setup-token') &&
|
||||
form.platform !== 'claude-console' &&
|
||||
form.platform !== 'bedrock' &&
|
||||
form.platform !== 'azure_openai'
|
||||
form.platform !== 'azure_openai' &&
|
||||
form.platform !== 'openai-responses'
|
||||
"
|
||||
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
||||
:disabled="loading"
|
||||
@@ -1915,6 +2290,93 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI-Responses 特定字段(编辑模式)-->
|
||||
<div v-if="form.platform === 'openai-responses'" class="space-y-4">
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700">API 基础地址</label>
|
||||
<input
|
||||
v-model="form.baseApi"
|
||||
class="form-input w-full"
|
||||
placeholder="https://api.example.com/v1"
|
||||
type="url"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700">API 密钥</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="form.apiKey"
|
||||
class="form-input w-full pr-10"
|
||||
placeholder="留空表示不更新"
|
||||
:type="showApiKey ? 'text' : 'password'"
|
||||
/>
|
||||
<button
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
type="button"
|
||||
@click="showApiKey = !showApiKey"
|
||||
>
|
||||
<i :class="showApiKey ? 'fas fa-eye-slash' : 'fas fa-eye'" />
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">留空表示不更新 API Key</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700"
|
||||
>自定义 User-Agent</label
|
||||
>
|
||||
<input
|
||||
v-model="form.userAgent"
|
||||
class="form-input w-full"
|
||||
placeholder="留空则透传客户端 User-Agent"
|
||||
type="text"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
留空时将自动使用客户端的 User-Agent,仅在需要固定特定 UA 时填写
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700">限流时长(分钟)</label>
|
||||
<input
|
||||
v-model.number="form.rateLimitDuration"
|
||||
class="form-input w-full"
|
||||
min="1"
|
||||
placeholder="60"
|
||||
type="number"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">遇到 429 错误时的限流等待时间,默认 60 分钟</p>
|
||||
</div>
|
||||
|
||||
<!-- 额度管理字段 -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
每日额度限制 ($)
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.dailyQuota"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
min="0"
|
||||
placeholder="0 表示不限制"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
额度重置时间
|
||||
</label>
|
||||
<input
|
||||
v-model="form.quotaResetTime"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
type="time"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bedrock 特定字段(编辑模式)-->
|
||||
<div v-if="form.platform === 'bedrock'" class="space-y-4">
|
||||
<div>
|
||||
@@ -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(
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -886,28 +886,58 @@ 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([
|
||||
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')
|
||||
])
|
||||
@@ -944,13 +974,31 @@ const refreshAccounts = async () => {
|
||||
}))
|
||||
}
|
||||
|
||||
// 合并 OpenAI 和 OpenAI-Responses 账号
|
||||
const openaiAccounts = []
|
||||
|
||||
if (openaiData.success) {
|
||||
localAccounts.value.openai = (openaiData.data || []).map((account) => ({
|
||||
;(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,
|
||||
|
||||
@@ -911,12 +911,20 @@ const updateApiKey = async () => {
|
||||
const refreshAccounts = async () => {
|
||||
accountsLoading.value = true
|
||||
try {
|
||||
const [claudeData, claudeConsoleData, geminiData, openaiData, bedrockData, groupsData] =
|
||||
await Promise.all([
|
||||
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')
|
||||
])
|
||||
@@ -953,13 +961,31 @@ const refreshAccounts = async () => {
|
||||
}))
|
||||
}
|
||||
|
||||
// 合并 OpenAI 和 OpenAI-Responses 账号
|
||||
const openaiAccounts = []
|
||||
|
||||
if (openaiData.success) {
|
||||
localAccounts.value.openai = (openaiData.data || []).map((account) => ({
|
||||
;(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 || []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -99,7 +99,13 @@
|
||||
<div
|
||||
class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-400"
|
||||
>
|
||||
{{ platform === 'claude' ? 'Claude OAuth 专属账号' : 'OAuth 专属账号' }}
|
||||
{{
|
||||
platform === 'claude'
|
||||
? 'Claude OAuth 专属账号'
|
||||
: platform === 'openai'
|
||||
? 'OpenAI 专属账号'
|
||||
: 'OAuth 专属账号'
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
v-for="account in filteredOAuthAccounts"
|
||||
@@ -170,6 +176,45 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI-Responses 账号(仅 OpenAI) -->
|
||||
<div v-if="platform === 'openai' && filteredOpenAIResponsesAccounts.length > 0">
|
||||
<div
|
||||
class="bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-400"
|
||||
>
|
||||
OpenAI-Responses 专属账号
|
||||
</div>
|
||||
<div
|
||||
v-for="account in filteredOpenAIResponsesAccounts"
|
||||
:key="account.id"
|
||||
class="cursor-pointer px-4 py-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
:class="{
|
||||
'bg-blue-50 dark:bg-blue-900/20': modelValue === `responses:${account.id}`
|
||||
}"
|
||||
@click="selectAccount(`responses:${account.id}`)"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-gray-700 dark:text-gray-300">{{ account.name }}</span>
|
||||
<span
|
||||
class="ml-2 rounded-full px-2 py-0.5 text-xs"
|
||||
:class="
|
||||
account.isActive === 'true' || account.isActive === true
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: account.status === 'rate_limited'
|
||||
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
"
|
||||
>
|
||||
{{ getAccountStatusText(account) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ formatDate(account.createdAt) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无搜索结果 -->
|
||||
<div
|
||||
v-if="searchQuery && !hasResults"
|
||||
@@ -196,7 +241,7 @@ const props = defineProps({
|
||||
platform: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (value) => ['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
|
||||
// 只显示与当前平台匹配的分组
|
||||
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()
|
||||
return props.groups.filter((group) => group.name.toLowerCase().includes(query))
|
||||
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
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
账户管理
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 sm:text-base">
|
||||
管理您的 Claude、Gemini、OpenAI 和 Azure OpenAI 账户及代理配置
|
||||
管理您的 Claude、Gemini、OpenAI、Azure OpenAI 和 OpenAI-Responses 账户及代理配置
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -350,6 +350,19 @@
|
||||
>API Key</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="account.platform === 'openai-responses'"
|
||||
class="flex items-center gap-1.5 rounded-lg border border-teal-200 bg-gradient-to-r from-teal-100 to-green-100 px-2.5 py-1 dark:border-teal-700 dark:from-teal-900/20 dark:to-green-900/20"
|
||||
>
|
||||
<i class="fas fa-server text-xs text-teal-700 dark:text-teal-400" />
|
||||
<span class="text-xs font-semibold text-teal-800 dark:text-teal-300"
|
||||
>OpenAI-Responses</span
|
||||
>
|
||||
<span class="mx-1 h-4 w-px bg-teal-300 dark:bg-teal-600" />
|
||||
<span class="text-xs font-medium text-teal-700 dark:text-teal-400"
|
||||
>API Key</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="account.platform === 'claude' || account.platform === 'claude-oauth'"
|
||||
class="flex items-center gap-1.5 rounded-lg border border-indigo-200 bg-gradient-to-r from-indigo-100 to-blue-100 px-2.5 py-1"
|
||||
@@ -643,7 +656,8 @@
|
||||
v-if="
|
||||
(account.platform === 'claude' ||
|
||||
account.platform === 'claude-console' ||
|
||||
account.platform === 'openai') &&
|
||||
account.platform === 'openai' ||
|
||||
account.platform === 'openai-responses') &&
|
||||
(account.status === 'unauthorized' ||
|
||||
account.status !== 'active' ||
|
||||
account.rateLimitStatus?.isRateLimited ||
|
||||
@@ -1003,7 +1017,8 @@ const platformOptions = ref([
|
||||
{ value: 'gemini', label: 'Gemini', icon: 'fa-google' },
|
||||
{ value: 'openai', label: 'OpenAi', icon: 'fa-openai' },
|
||||
{ value: 'azure_openai', label: 'Azure OpenAI', icon: 'fab fa-microsoft' },
|
||||
{ value: 'bedrock', label: 'Bedrock', icon: 'fab fa-aws' }
|
||||
{ value: 'bedrock', label: 'Bedrock', icon: 'fab fa-aws' },
|
||||
{ value: 'openai-responses', label: 'OpenAI-Responses', icon: 'fa-server' }
|
||||
])
|
||||
|
||||
const groupOptions = computed(() => {
|
||||
@@ -1108,7 +1123,8 @@ const loadAccounts = async (forceReload = false) => {
|
||||
apiClient.get('/admin/bedrock-accounts', { params }),
|
||||
apiClient.get('/admin/gemini-accounts', { params }),
|
||||
apiClient.get('/admin/openai-accounts', { params }),
|
||||
apiClient.get('/admin/azure-openai-accounts', { params })
|
||||
apiClient.get('/admin/azure-openai-accounts', { params }),
|
||||
apiClient.get('/admin/openai-responses-accounts', { params })
|
||||
)
|
||||
} else {
|
||||
// 只请求指定平台,其他平台设为null占位
|
||||
@@ -1120,7 +1136,8 @@ const loadAccounts = async (forceReload = false) => {
|
||||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||||
Promise.resolve({ success: true, data: [] }) // azure-openai 占位
|
||||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
||||
Promise.resolve({ success: true, data: [] }) // openai-responses 占位
|
||||
)
|
||||
break
|
||||
case 'claude-console':
|
||||
@@ -1130,7 +1147,8 @@ const loadAccounts = async (forceReload = false) => {
|
||||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||||
Promise.resolve({ success: true, data: [] }) // azure-openai 占位
|
||||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
||||
Promise.resolve({ success: true, data: [] }) // openai-responses 占位
|
||||
)
|
||||
break
|
||||
case 'bedrock':
|
||||
@@ -1140,7 +1158,8 @@ const loadAccounts = async (forceReload = false) => {
|
||||
apiClient.get('/admin/bedrock-accounts', { params }),
|
||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||||
Promise.resolve({ success: true, data: [] }) // azure-openai 占位
|
||||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
||||
Promise.resolve({ success: true, data: [] }) // openai-responses 占位
|
||||
)
|
||||
break
|
||||
case 'gemini':
|
||||
@@ -1150,7 +1169,8 @@ const loadAccounts = async (forceReload = false) => {
|
||||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
||||
apiClient.get('/admin/gemini-accounts', { params }),
|
||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||||
Promise.resolve({ success: true, data: [] }) // azure-openai 占位
|
||||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
||||
Promise.resolve({ success: true, data: [] }) // openai-responses 占位
|
||||
)
|
||||
break
|
||||
case 'openai':
|
||||
@@ -1160,7 +1180,8 @@ const loadAccounts = async (forceReload = false) => {
|
||||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||||
apiClient.get('/admin/openai-accounts', { params }),
|
||||
Promise.resolve({ success: true, data: [] }) // azure-openai 占位
|
||||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
||||
Promise.resolve({ success: true, data: [] }) // openai-responses 占位
|
||||
)
|
||||
break
|
||||
case 'azure_openai':
|
||||
@@ -1170,7 +1191,19 @@ const loadAccounts = async (forceReload = false) => {
|
||||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||||
apiClient.get('/admin/azure-openai-accounts', { params })
|
||||
apiClient.get('/admin/azure-openai-accounts', { params }),
|
||||
Promise.resolve({ success: true, data: [] }) // openai-responses 占位
|
||||
)
|
||||
break
|
||||
case 'openai-responses':
|
||||
requests.push(
|
||||
Promise.resolve({ success: true, data: [] }), // claude 占位
|
||||
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
||||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
||||
apiClient.get('/admin/openai-responses-accounts', { params })
|
||||
)
|
||||
break
|
||||
default:
|
||||
@@ -1181,6 +1214,7 @@ const loadAccounts = async (forceReload = false) => {
|
||||
Promise.resolve({ success: true, data: [] }),
|
||||
Promise.resolve({ success: true, data: [] }),
|
||||
Promise.resolve({ success: true, data: [] }),
|
||||
Promise.resolve({ success: true, data: [] }),
|
||||
Promise.resolve({ success: true, data: [] })
|
||||
)
|
||||
break
|
||||
@@ -1193,8 +1227,15 @@ const loadAccounts = async (forceReload = false) => {
|
||||
// 后端账户API已经包含分组信息,不需要单独加载分组成员关系
|
||||
// await loadGroupMembers(forceReload)
|
||||
|
||||
const [claudeData, claudeConsoleData, bedrockData, geminiData, openaiData, azureOpenaiData] =
|
||||
await Promise.all(requests)
|
||||
const [
|
||||
claudeData,
|
||||
claudeConsoleData,
|
||||
bedrockData,
|
||||
geminiData,
|
||||
openaiData,
|
||||
azureOpenaiData,
|
||||
openaiResponsesData
|
||||
] = await Promise.all(requests)
|
||||
|
||||
const allAccounts = []
|
||||
|
||||
@@ -1262,6 +1303,19 @@ const loadAccounts = async (forceReload = false) => {
|
||||
allAccounts.push(...azureOpenaiAccounts)
|
||||
}
|
||||
|
||||
if (openaiResponsesData && openaiResponsesData.success) {
|
||||
const openaiResponsesAccounts = (openaiResponsesData.data || []).map((acc) => {
|
||||
// 计算每个OpenAI-Responses账户绑定的API Key数量
|
||||
// OpenAI-Responses账户使用 responses: 前缀
|
||||
const boundApiKeysCount = apiKeys.value.filter(
|
||||
(key) => key.openaiAccountId === `responses:${acc.id}`
|
||||
).length
|
||||
// 后端已经包含了groupInfos,直接使用
|
||||
return { ...acc, platform: 'openai-responses', boundApiKeysCount }
|
||||
})
|
||||
allAccounts.push(...openaiResponsesAccounts)
|
||||
}
|
||||
|
||||
// 根据分组筛选器过滤账户
|
||||
let filteredAccounts = allAccounts
|
||||
if (groupFilter.value !== 'all') {
|
||||
@@ -1515,6 +1569,8 @@ const deleteAccount = async (account) => {
|
||||
endpoint = `/admin/openai-accounts/${account.id}`
|
||||
} else if (account.platform === 'azure_openai') {
|
||||
endpoint = `/admin/azure-openai-accounts/${account.id}`
|
||||
} else if (account.platform === 'openai-responses') {
|
||||
endpoint = `/admin/openai-responses-accounts/${account.id}`
|
||||
} else {
|
||||
endpoint = `/admin/gemini-accounts/${account.id}`
|
||||
}
|
||||
@@ -1559,6 +1615,8 @@ const resetAccountStatus = async (account) => {
|
||||
let endpoint = ''
|
||||
if (account.platform === 'openai') {
|
||||
endpoint = `/admin/openai-accounts/${account.id}/reset-status`
|
||||
} else if (account.platform === 'openai-responses') {
|
||||
endpoint = `/admin/openai-responses-accounts/${account.id}/reset-status`
|
||||
} else if (account.platform === 'claude') {
|
||||
endpoint = `/admin/claude-accounts/${account.id}/reset-status`
|
||||
} else if (account.platform === 'claude-console') {
|
||||
@@ -1605,6 +1663,8 @@ const toggleSchedulable = async (account) => {
|
||||
endpoint = `/admin/openai-accounts/${account.id}/toggle-schedulable`
|
||||
} else if (account.platform === 'azure_openai') {
|
||||
endpoint = `/admin/azure-openai-accounts/${account.id}/toggle-schedulable`
|
||||
} else if (account.platform === 'openai-responses') {
|
||||
endpoint = `/admin/openai-responses-accounts/${account.id}/toggle-schedulable`
|
||||
} else {
|
||||
showToast('该账户类型暂不支持调度控制', 'warning')
|
||||
return
|
||||
@@ -1752,6 +1812,26 @@ const getSchedulableReason = (account) => {
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI-Responses 账户的错误状态
|
||||
if (account.platform === 'openai-responses') {
|
||||
if (account.status === 'unauthorized') {
|
||||
return '认证失败(401错误)'
|
||||
}
|
||||
// 检查限流状态 - 兼容嵌套的 rateLimitStatus 对象
|
||||
if (
|
||||
(account.rateLimitStatus && account.rateLimitStatus.isRateLimited) ||
|
||||
account.isRateLimited
|
||||
) {
|
||||
return '触发限流(429错误)'
|
||||
}
|
||||
if (account.status === 'error' && account.errorMessage) {
|
||||
return account.errorMessage
|
||||
}
|
||||
if (account.status === 'rateLimited') {
|
||||
return '触发限流(429错误)'
|
||||
}
|
||||
}
|
||||
|
||||
// 通用原因
|
||||
if (account.stoppedReason) {
|
||||
return account.stoppedReason
|
||||
|
||||
@@ -1849,6 +1849,7 @@ const accounts = ref({
|
||||
claude: [],
|
||||
gemini: [],
|
||||
openai: [],
|
||||
openaiResponses: [], // 添加 OpenAI-Responses 账号列表
|
||||
bedrock: [],
|
||||
claudeGroups: [],
|
||||
geminiGroups: [],
|
||||
@@ -2016,12 +2017,20 @@ const paginatedApiKeys = computed(() => {
|
||||
// 加载账户列表
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
const [claudeData, claudeConsoleData, geminiData, openaiData, bedrockData, groupsData] =
|
||||
await Promise.all([
|
||||
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'),
|
||||
apiClient.get('/admin/account-groups')
|
||||
])
|
||||
@@ -2065,6 +2074,13 @@ const loadAccounts = async () => {
|
||||
}))
|
||||
}
|
||||
|
||||
if (openaiResponsesData.success) {
|
||||
accounts.value.openaiResponses = (openaiResponsesData.data || []).map((account) => ({
|
||||
...account,
|
||||
isDedicated: account.accountType === 'dedicated'
|
||||
}))
|
||||
}
|
||||
|
||||
if (bedrockData.success) {
|
||||
accounts.value.bedrock = (bedrockData.data || []).map((account) => ({
|
||||
...account,
|
||||
@@ -2209,12 +2225,31 @@ const getBoundAccountName = (accountId) => {
|
||||
return `${geminiAccount.name}`
|
||||
}
|
||||
|
||||
// 处理 responses: 前缀的 OpenAI-Responses 账户
|
||||
if (accountId.startsWith('responses:')) {
|
||||
const realAccountId = accountId.replace('responses:', '')
|
||||
const openaiResponsesAccount = accounts.value.openaiResponses.find(
|
||||
(acc) => acc.id === realAccountId
|
||||
)
|
||||
if (openaiResponsesAccount) {
|
||||
return `${openaiResponsesAccount.name}`
|
||||
}
|
||||
// 如果找不到,返回ID的前8位
|
||||
return `${realAccountId.substring(0, 8)}`
|
||||
}
|
||||
|
||||
// 从OpenAI账户列表中查找
|
||||
const openaiAccount = accounts.value.openai.find((acc) => acc.id === accountId)
|
||||
if (openaiAccount) {
|
||||
return `${openaiAccount.name}`
|
||||
}
|
||||
|
||||
// 从 OpenAI-Responses 账户列表中查找(兼容没有前缀的情况)
|
||||
const openaiResponsesAccount = accounts.value.openaiResponses.find((acc) => acc.id === accountId)
|
||||
if (openaiResponsesAccount) {
|
||||
return `${openaiResponsesAccount.name}`
|
||||
}
|
||||
|
||||
// 从Bedrock账户列表中查找
|
||||
const bedrockAccount = accounts.value.bedrock.find((acc) => acc.id === accountId)
|
||||
if (bedrockAccount) {
|
||||
@@ -2281,8 +2316,17 @@ const getOpenAIBindingInfo = (key) => {
|
||||
if (key.openaiAccountId.startsWith('group:')) {
|
||||
return info
|
||||
}
|
||||
// 检查账户是否存在
|
||||
const account = accounts.value.openai.find((acc) => acc.id === key.openaiAccountId)
|
||||
|
||||
// 处理 responses: 前缀的 OpenAI-Responses 账户
|
||||
let account = null
|
||||
if (key.openaiAccountId.startsWith('responses:')) {
|
||||
const realAccountId = key.openaiAccountId.replace('responses:', '')
|
||||
account = accounts.value.openaiResponses.find((acc) => acc.id === realAccountId)
|
||||
} else {
|
||||
// 查找普通 OpenAI 账户
|
||||
account = accounts.value.openai.find((acc) => acc.id === key.openaiAccountId)
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
return `⚠️ ${info} (账户不存在)`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user