mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 实现OpenAI账户管理和统一调度系统
- 新增 OpenAI 账户管理服务,支持多账户轮询和负载均衡 - 实现统一的 OpenAI API 调度器,智能选择最优账户 - 优化成本计算器,支持更精确的 token 计算 - 更新模型定价数据,包含最新的 OpenAI 模型价格 - 增强 API Key 管理,支持更灵活的配额控制 - 改进管理界面,添加教程视图和账户分组管理 - 优化限流配置组件,提供更直观的用户体验 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -836,7 +836,7 @@ class RedisClient {
|
||||
for (const key of keys) {
|
||||
const accountData = await this.client.hgetall(key)
|
||||
if (accountData && Object.keys(accountData).length > 0) {
|
||||
accounts.push({ id: key.replace('claude:account:', ''), ...accountData })
|
||||
accounts.push({ id: key.replace('openai:account:', ''), ...accountData })
|
||||
}
|
||||
}
|
||||
return accounts
|
||||
|
||||
@@ -4,6 +4,7 @@ const claudeAccountService = require('../services/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
|
||||
const bedrockAccountService = require('../services/bedrockAccountService')
|
||||
const geminiAccountService = require('../services/geminiAccountService')
|
||||
const openaiAccountService = require('../services/openaiAccountService')
|
||||
const accountGroupService = require('../services/accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const { authenticateAdmin } = require('../middleware/auth')
|
||||
@@ -388,6 +389,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
claudeAccountId,
|
||||
claudeConsoleAccountId,
|
||||
geminiAccountId,
|
||||
openaiAccountId,
|
||||
permissions,
|
||||
concurrencyLimit,
|
||||
rateLimitWindow,
|
||||
@@ -483,6 +485,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
claudeAccountId,
|
||||
claudeConsoleAccountId,
|
||||
geminiAccountId,
|
||||
openaiAccountId,
|
||||
permissions,
|
||||
concurrencyLimit,
|
||||
rateLimitWindow,
|
||||
@@ -515,6 +518,7 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||
claudeAccountId,
|
||||
claudeConsoleAccountId,
|
||||
geminiAccountId,
|
||||
openaiAccountId,
|
||||
permissions,
|
||||
concurrencyLimit,
|
||||
rateLimitWindow,
|
||||
@@ -557,6 +561,7 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||
claudeAccountId,
|
||||
claudeConsoleAccountId,
|
||||
geminiAccountId,
|
||||
openaiAccountId,
|
||||
permissions,
|
||||
concurrencyLimit,
|
||||
rateLimitWindow,
|
||||
@@ -626,6 +631,7 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||
claudeAccountId,
|
||||
claudeConsoleAccountId,
|
||||
geminiAccountId,
|
||||
openaiAccountId,
|
||||
permissions,
|
||||
enableModelRestriction,
|
||||
restrictedModels,
|
||||
@@ -684,12 +690,17 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||
updates.geminiAccountId = geminiAccountId || ''
|
||||
}
|
||||
|
||||
if (openaiAccountId !== undefined) {
|
||||
// 空字符串表示解绑,null或空字符串都设置为空字符串
|
||||
updates.openaiAccountId = openaiAccountId || ''
|
||||
}
|
||||
|
||||
if (permissions !== undefined) {
|
||||
// 验证权限值
|
||||
if (!['claude', 'gemini', 'all'].includes(permissions)) {
|
||||
if (!['claude', 'gemini', 'openai', 'all'].includes(permissions)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'Invalid permissions value. Must be claude, gemini, or all' })
|
||||
.json({ error: 'Invalid permissions value. Must be claude, gemini, openai, or all' })
|
||||
}
|
||||
updates.permissions = permissions
|
||||
}
|
||||
@@ -894,6 +905,11 @@ router.get('/account-groups/:groupId/members', authenticateAdmin, async (req, re
|
||||
account = await geminiAccountService.getAccount(memberId)
|
||||
}
|
||||
|
||||
// 如果还找不到,尝试OpenAI账户
|
||||
if (!account) {
|
||||
account = await openaiAccountService.getAccount(memberId)
|
||||
}
|
||||
|
||||
if (account) {
|
||||
members.push(account)
|
||||
}
|
||||
@@ -2396,6 +2412,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
claudeConsoleAccounts,
|
||||
geminiAccounts,
|
||||
bedrockAccountsResult,
|
||||
openaiAccounts,
|
||||
todayStats,
|
||||
systemAverages,
|
||||
realtimeMetrics
|
||||
@@ -2406,6 +2423,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
claudeConsoleAccountService.getAllAccounts(),
|
||||
geminiAccountService.getAllAccounts(),
|
||||
bedrockAccountService.getAllAccounts(),
|
||||
redis.getAllOpenAIAccounts(),
|
||||
redis.getTodayStats(),
|
||||
redis.getSystemAverages(),
|
||||
redis.getRealtimeSystemMetrics()
|
||||
@@ -2543,6 +2561,39 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||
).length
|
||||
|
||||
// OpenAI账户统计
|
||||
// 注意:OpenAI账户的isActive和schedulable是字符串类型,默认值为'true'
|
||||
const normalOpenAIAccounts = openaiAccounts.filter(
|
||||
(acc) =>
|
||||
(acc.isActive === 'true' ||
|
||||
acc.isActive === true ||
|
||||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== 'false' &&
|
||||
acc.schedulable !== false && // 包括'true'、true和undefined
|
||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
const abnormalOpenAIAccounts = openaiAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive === 'false' ||
|
||||
acc.isActive === false ||
|
||||
acc.status === 'blocked' ||
|
||||
acc.status === 'unauthorized'
|
||||
).length
|
||||
const pausedOpenAIAccounts = openaiAccounts.filter(
|
||||
(acc) =>
|
||||
(acc.schedulable === 'false' || acc.schedulable === false) &&
|
||||
(acc.isActive === 'true' ||
|
||||
acc.isActive === true ||
|
||||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)) &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized'
|
||||
).length
|
||||
const rateLimitedOpenAIAccounts = openaiAccounts.filter(
|
||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||
).length
|
||||
|
||||
const dashboard = {
|
||||
overview: {
|
||||
totalApiKeys: apiKeys.length,
|
||||
@@ -2552,27 +2603,32 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
claudeAccounts.length +
|
||||
claudeConsoleAccounts.length +
|
||||
geminiAccounts.length +
|
||||
bedrockAccounts.length,
|
||||
bedrockAccounts.length +
|
||||
openaiAccounts.length,
|
||||
normalAccounts:
|
||||
normalClaudeAccounts +
|
||||
normalClaudeConsoleAccounts +
|
||||
normalGeminiAccounts +
|
||||
normalBedrockAccounts,
|
||||
normalBedrockAccounts +
|
||||
normalOpenAIAccounts,
|
||||
abnormalAccounts:
|
||||
abnormalClaudeAccounts +
|
||||
abnormalClaudeConsoleAccounts +
|
||||
abnormalGeminiAccounts +
|
||||
abnormalBedrockAccounts,
|
||||
abnormalBedrockAccounts +
|
||||
abnormalOpenAIAccounts,
|
||||
pausedAccounts:
|
||||
pausedClaudeAccounts +
|
||||
pausedClaudeConsoleAccounts +
|
||||
pausedGeminiAccounts +
|
||||
pausedBedrockAccounts,
|
||||
pausedBedrockAccounts +
|
||||
pausedOpenAIAccounts,
|
||||
rateLimitedAccounts:
|
||||
rateLimitedClaudeAccounts +
|
||||
rateLimitedClaudeConsoleAccounts +
|
||||
rateLimitedGeminiAccounts +
|
||||
rateLimitedBedrockAccounts,
|
||||
rateLimitedBedrockAccounts +
|
||||
rateLimitedOpenAIAccounts,
|
||||
// 各平台详细统计
|
||||
accountsByPlatform: {
|
||||
claude: {
|
||||
@@ -2602,6 +2658,13 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
abnormal: abnormalBedrockAccounts,
|
||||
paused: pausedBedrockAccounts,
|
||||
rateLimited: rateLimitedBedrockAccounts
|
||||
},
|
||||
openai: {
|
||||
total: openaiAccounts.length,
|
||||
normal: normalOpenAIAccounts,
|
||||
abnormal: abnormalOpenAIAccounts,
|
||||
paused: pausedOpenAIAccounts,
|
||||
rateLimited: rateLimitedOpenAIAccounts
|
||||
}
|
||||
},
|
||||
// 保留旧字段以兼容
|
||||
@@ -2609,7 +2672,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
normalClaudeAccounts +
|
||||
normalClaudeConsoleAccounts +
|
||||
normalGeminiAccounts +
|
||||
normalBedrockAccounts,
|
||||
normalBedrockAccounts +
|
||||
normalOpenAIAccounts,
|
||||
totalClaudeAccounts: claudeAccounts.length + claudeConsoleAccounts.length,
|
||||
activeClaudeAccounts: normalClaudeAccounts + normalClaudeConsoleAccounts,
|
||||
rateLimitedClaudeAccounts: rateLimitedClaudeAccounts + rateLimitedClaudeConsoleAccounts,
|
||||
@@ -4513,7 +4577,7 @@ router.post('/openai-accounts/exchange-code', authenticateAdmin, async (req, res
|
||||
// 获取所有 OpenAI 账户
|
||||
router.get('/openai-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const accounts = await redis.getAllOpenAIAccounts()
|
||||
const accounts = await openaiAccountService.getAllAccounts()
|
||||
|
||||
logger.info(`获取 OpenAI 账户列表: ${accounts.length} 个账户`)
|
||||
|
||||
@@ -4553,60 +4617,41 @@ router.post('/openai-accounts', authenticateAdmin, async (req, res) => {
|
||||
message: '账户名称不能为空'
|
||||
})
|
||||
}
|
||||
const id = uuidv4()
|
||||
// 创建账户数据
|
||||
const accountData = {
|
||||
id,
|
||||
name,
|
||||
description: description || '',
|
||||
platform: 'openai',
|
||||
accountType: accountType || 'shared',
|
||||
groupId: groupId || null,
|
||||
dedicatedApiKeys: dedicatedApiKeys || [],
|
||||
priority: priority || 50,
|
||||
rateLimitDuration: rateLimitDuration || 60,
|
||||
enabled: true,
|
||||
idToken: claudeAccountService._encryptSensitiveData(openaiOauth.idToken),
|
||||
accessToken: claudeAccountService._encryptSensitiveData(openaiOauth.accessToken),
|
||||
refreshToken: claudeAccountService._encryptSensitiveData(openaiOauth.refreshToken),
|
||||
accountId: accountInfo?.accountId || '',
|
||||
expiresAt: (Math.floor(Date.now() / 1000) + openaiOauth.expires_in) * 1000,
|
||||
chatgptUserId: accountInfo?.chatgptUserId || '',
|
||||
organizationId: accountInfo?.organizationId || '',
|
||||
organizationRole: accountInfo?.organizationRole || '',
|
||||
organizationTitle: accountInfo?.organizationTitle || '',
|
||||
planType: accountInfo?.planType || '',
|
||||
email: claudeAccountService._encryptSensitiveData(accountInfo?.email || ''),
|
||||
emailVerified: accountInfo?.emailVerified || false,
|
||||
openaiOauth: openaiOauth || {},
|
||||
accountInfo: accountInfo || {},
|
||||
proxy: proxy?.enabled
|
||||
? {
|
||||
type: proxy.type,
|
||||
host: proxy.host,
|
||||
port: proxy.port,
|
||||
username: proxy.username || null,
|
||||
password: proxy.password || null
|
||||
}
|
||||
: null,
|
||||
isActive: true,
|
||||
status: 'active',
|
||||
lastRefresh: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
schedulable: true
|
||||
}
|
||||
|
||||
// 存储代理配置(如果提供)
|
||||
if (proxy?.enabled) {
|
||||
accountData.proxy = {
|
||||
type: proxy.type,
|
||||
host: proxy.host,
|
||||
port: proxy.port,
|
||||
username: proxy.username || null,
|
||||
password: proxy.password || null
|
||||
}
|
||||
// 创建账户
|
||||
const createdAccount = await openaiAccountService.createAccount(accountData)
|
||||
|
||||
// 如果是分组类型,添加到分组
|
||||
if (accountType === 'group' && groupId) {
|
||||
await accountGroupService.addAccountToGroup(createdAccount.id, groupId, 'openai')
|
||||
}
|
||||
|
||||
// 保存到 Redis
|
||||
const accountId = await redis.setOpenAiAccount(id, accountData)
|
||||
|
||||
logger.success(`✅ 创建 OpenAI 账户成功: ${name} (ID: ${accountId})`)
|
||||
logger.success(`✅ 创建 OpenAI 账户成功: ${name} (ID: ${createdAccount.id})`)
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: accountId,
|
||||
...accountData
|
||||
}
|
||||
data: createdAccount
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('创建 OpenAI 账户失败:', error)
|
||||
@@ -4619,19 +4664,100 @@ router.post('/openai-accounts', authenticateAdmin, async (req, res) => {
|
||||
})
|
||||
|
||||
// 更新 OpenAI 账户
|
||||
router.put('/openai-accounts/:id', authenticateAdmin, async (req, res) =>
|
||||
//TODO:
|
||||
res.json({
|
||||
success: true
|
||||
})
|
||||
)
|
||||
router.put('/openai-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const updates = req.body
|
||||
|
||||
// 验证accountType的有效性
|
||||
if (updates.accountType && !['shared', 'dedicated', 'group'].includes(updates.accountType)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
|
||||
}
|
||||
|
||||
// 如果更新为分组类型,验证groupId
|
||||
if (updates.accountType === 'group' && !updates.groupId) {
|
||||
return res.status(400).json({ error: 'Group ID is required for group type accounts' })
|
||||
}
|
||||
|
||||
// 获取账户当前信息以处理分组变更
|
||||
const currentAccount = await openaiAccountService.getAccount(id)
|
||||
if (!currentAccount) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
// 处理分组的变更
|
||||
if (updates.accountType !== undefined) {
|
||||
// 如果之前是分组类型,需要从原分组中移除
|
||||
if (currentAccount.accountType === 'group') {
|
||||
const oldGroup = await accountGroupService.getAccountGroup(id)
|
||||
if (oldGroup) {
|
||||
await accountGroupService.removeAccountFromGroup(id, oldGroup.id)
|
||||
}
|
||||
}
|
||||
// 如果新类型是分组,添加到新分组
|
||||
if (updates.accountType === 'group' && updates.groupId) {
|
||||
await accountGroupService.addAccountToGroup(id, updates.groupId, 'openai')
|
||||
}
|
||||
}
|
||||
|
||||
// 准备更新数据
|
||||
const updateData = { ...updates }
|
||||
|
||||
// 处理敏感数据加密
|
||||
if (updates.openaiOauth) {
|
||||
updateData.openaiOauth = updates.openaiOauth
|
||||
if (updates.openaiOauth.idToken) {
|
||||
updateData.idToken = updates.openaiOauth.idToken
|
||||
}
|
||||
if (updates.openaiOauth.accessToken) {
|
||||
updateData.accessToken = updates.openaiOauth.accessToken
|
||||
}
|
||||
if (updates.openaiOauth.refreshToken) {
|
||||
updateData.refreshToken = updates.openaiOauth.refreshToken
|
||||
}
|
||||
if (updates.openaiOauth.expires_in) {
|
||||
updateData.expiresAt = new Date(
|
||||
Date.now() + updates.openaiOauth.expires_in * 1000
|
||||
).toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// 更新账户信息
|
||||
if (updates.accountInfo) {
|
||||
updateData.accountId = updates.accountInfo.accountId || currentAccount.accountId
|
||||
updateData.chatgptUserId = updates.accountInfo.chatgptUserId || currentAccount.chatgptUserId
|
||||
updateData.organizationId =
|
||||
updates.accountInfo.organizationId || currentAccount.organizationId
|
||||
updateData.organizationRole =
|
||||
updates.accountInfo.organizationRole || currentAccount.organizationRole
|
||||
updateData.organizationTitle =
|
||||
updates.accountInfo.organizationTitle || currentAccount.organizationTitle
|
||||
updateData.planType = updates.accountInfo.planType || currentAccount.planType
|
||||
updateData.email = updates.accountInfo.email || currentAccount.email
|
||||
updateData.emailVerified =
|
||||
updates.accountInfo.emailVerified !== undefined
|
||||
? updates.accountInfo.emailVerified
|
||||
: currentAccount.emailVerified
|
||||
}
|
||||
|
||||
const updatedAccount = await openaiAccountService.updateAccount(id, updateData)
|
||||
|
||||
logger.success(`📝 Admin updated OpenAI account: ${id}`)
|
||||
return res.json({ success: true, data: updatedAccount })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to update OpenAI account:', error)
|
||||
return res.status(500).json({ error: 'Failed to update account', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 删除 OpenAI 账户
|
||||
router.delete('/openai-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const account = await redis.getOpenAiAccount(id)
|
||||
const account = await openaiAccountService.getAccount(id)
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
@@ -4639,7 +4765,15 @@ router.delete('/openai-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
await redis.deleteOpenAiAccount(id)
|
||||
// 如果账户在分组中,从分组中移除
|
||||
if (account.accountType === 'group') {
|
||||
const group = await accountGroupService.getAccountGroup(id)
|
||||
if (group) {
|
||||
await accountGroupService.removeAccountFromGroup(id, group.id)
|
||||
}
|
||||
}
|
||||
|
||||
await openaiAccountService.deleteAccount(id)
|
||||
|
||||
logger.success(`✅ 删除 OpenAI 账户成功: ${account.name} (ID: ${id})`)
|
||||
|
||||
@@ -4695,4 +4829,30 @@ router.put('/openai-accounts/:id/toggle', authenticateAdmin, async (req, res) =>
|
||||
}
|
||||
})
|
||||
|
||||
// 切换 OpenAI 账户调度状态
|
||||
router.put(
|
||||
'/openai-accounts/:accountId/toggle-schedulable',
|
||||
authenticateAdmin,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
|
||||
const result = await openaiAccountService.toggleSchedulable(accountId)
|
||||
|
||||
return res.json({
|
||||
success: result.success,
|
||||
schedulable: result.schedulable,
|
||||
message: result.schedulable ? '已启用调度' : '已禁用调度'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('切换 OpenAI 账户调度状态失败:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '切换调度状态失败',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -279,6 +279,9 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
let currentWindowRequests = 0
|
||||
let currentWindowTokens = 0
|
||||
let currentDailyCost = 0
|
||||
let windowStartTime = null
|
||||
let windowEndTime = null
|
||||
let windowRemainingSeconds = null
|
||||
|
||||
try {
|
||||
// 获取当前时间窗口的请求次数和Token使用量
|
||||
@@ -286,9 +289,32 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
const client = redis.getClientSafe()
|
||||
const requestCountKey = `rate_limit:requests:${keyId}`
|
||||
const tokenCountKey = `rate_limit:tokens:${keyId}`
|
||||
const windowStartKey = `rate_limit:window_start:${keyId}`
|
||||
|
||||
currentWindowRequests = parseInt((await client.get(requestCountKey)) || '0')
|
||||
currentWindowTokens = parseInt((await client.get(tokenCountKey)) || '0')
|
||||
|
||||
// 获取窗口开始时间和计算剩余时间
|
||||
const windowStart = await client.get(windowStartKey)
|
||||
if (windowStart) {
|
||||
const now = Date.now()
|
||||
windowStartTime = parseInt(windowStart)
|
||||
const windowDuration = fullKeyData.rateLimitWindow * 60 * 1000 // 转换为毫秒
|
||||
windowEndTime = windowStartTime + windowDuration
|
||||
|
||||
// 如果窗口还有效
|
||||
if (now < windowEndTime) {
|
||||
windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000))
|
||||
} else {
|
||||
// 窗口已过期,下次请求会重置
|
||||
windowStartTime = null
|
||||
windowEndTime = null
|
||||
windowRemainingSeconds = 0
|
||||
// 重置计数为0,因为窗口已过期
|
||||
currentWindowRequests = 0
|
||||
currentWindowTokens = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当日费用
|
||||
@@ -334,7 +360,11 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
// 当前使用量
|
||||
currentWindowRequests,
|
||||
currentWindowTokens,
|
||||
currentDailyCost
|
||||
currentDailyCost,
|
||||
// 时间窗口信息
|
||||
windowStartTime,
|
||||
windowEndTime,
|
||||
windowRemainingSeconds
|
||||
},
|
||||
|
||||
// 绑定的账户信息(只显示ID,不显示敏感信息)
|
||||
|
||||
@@ -3,33 +3,51 @@ const axios = require('axios')
|
||||
const router = express.Router()
|
||||
const logger = require('../utils/logger')
|
||||
const { authenticateApiKey } = require('../middleware/auth')
|
||||
const redis = require('../models/redis')
|
||||
const claudeAccountService = require('../services/claudeAccountService')
|
||||
const unifiedOpenAIScheduler = require('../services/unifiedOpenAIScheduler')
|
||||
const openaiAccountService = require('../services/openaiAccountService')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const crypto = require('crypto')
|
||||
|
||||
// 选择一个可用的 OpenAI 账户,并返回解密后的 accessToken
|
||||
async function getOpenAIAuthToken() {
|
||||
// 使用统一调度器选择 OpenAI 账户
|
||||
async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel = null) {
|
||||
try {
|
||||
const accounts = await redis.getAllOpenAIAccounts()
|
||||
if (!accounts || accounts.length === 0) {
|
||||
throw new Error('No OpenAI accounts found in Redis')
|
||||
// 生成会话哈希(如果有会话ID)
|
||||
const sessionHash = sessionId
|
||||
? crypto.createHash('sha256').update(sessionId).digest('hex')
|
||||
: null
|
||||
|
||||
// 使用统一调度器选择账户
|
||||
const result = await unifiedOpenAIScheduler.selectAccountForApiKey(
|
||||
apiKeyData,
|
||||
sessionHash,
|
||||
requestedModel
|
||||
)
|
||||
|
||||
if (!result || !result.accountId) {
|
||||
throw new Error('No available OpenAI account found')
|
||||
}
|
||||
|
||||
// 简单选择策略:选择第一个启用并活跃的账户
|
||||
const candidate =
|
||||
accounts.find((a) => String(a.enabled) === 'true' && String(a.isActive) === 'true') ||
|
||||
accounts[0]
|
||||
|
||||
if (!candidate || !candidate.accessToken) {
|
||||
throw new Error('No valid OpenAI account with accessToken')
|
||||
// 获取账户详情
|
||||
const account = await openaiAccountService.getAccount(result.accountId)
|
||||
if (!account || !account.accessToken) {
|
||||
throw new Error(`OpenAI account ${result.accountId} has no valid accessToken`)
|
||||
}
|
||||
|
||||
const accessToken = claudeAccountService._decryptSensitiveData(candidate.accessToken)
|
||||
// 解密 accessToken
|
||||
const accessToken = claudeAccountService._decryptSensitiveData(account.accessToken)
|
||||
if (!accessToken) {
|
||||
throw new Error('Failed to decrypt OpenAI accessToken')
|
||||
}
|
||||
return { accessToken, accountId: candidate.accountId || 'unknown' }
|
||||
|
||||
logger.info(`Selected OpenAI account: ${account.name} (${result.accountId})`)
|
||||
return {
|
||||
accessToken,
|
||||
accountId: result.accountId,
|
||||
accountName: account.name
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to get OpenAI auth token from Redis:', error)
|
||||
logger.error('Failed to get OpenAI auth token:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -37,7 +55,27 @@ async function getOpenAIAuthToken() {
|
||||
router.post('/responses', authenticateApiKey, async (req, res) => {
|
||||
let upstream = null
|
||||
try {
|
||||
const { accessToken, accountId } = await getOpenAIAuthToken()
|
||||
// 从中间件获取 API Key 数据
|
||||
const apiKeyData = req.apiKeyData || {}
|
||||
|
||||
// 从请求头或请求体中提取会话 ID
|
||||
const sessionId =
|
||||
req.headers['session_id'] ||
|
||||
req.headers['x-session-id'] ||
|
||||
req.body?.session_id ||
|
||||
req.body?.conversation_id ||
|
||||
null
|
||||
|
||||
// 从请求体中提取模型和流式标志
|
||||
const requestedModel = req.body?.model || null
|
||||
const isStream = req.body?.stream !== false // 默认为流式(兼容现有行为)
|
||||
|
||||
// 使用调度器选择账户
|
||||
const { accessToken, accountId } = await getOpenAIAuthToken(
|
||||
apiKeyData,
|
||||
sessionId,
|
||||
requestedModel
|
||||
)
|
||||
// 基于白名单构造上游所需的请求头,确保键为小写且值受控
|
||||
const incoming = req.headers || {}
|
||||
|
||||
@@ -54,21 +92,39 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
|
||||
headers['authorization'] = `Bearer ${accessToken}`
|
||||
headers['chatgpt-account-id'] = accountId
|
||||
headers['host'] = 'chatgpt.com'
|
||||
headers['accept'] = 'text/event-stream'
|
||||
headers['accept'] = isStream ? 'text/event-stream' : 'application/json'
|
||||
headers['content-type'] = 'application/json'
|
||||
req.body['store'] = false
|
||||
// 使用流式转发,保持与上游一致
|
||||
upstream = await axios.post('https://chatgpt.com/backend-api/codex/responses', req.body, {
|
||||
headers,
|
||||
responseType: 'stream',
|
||||
timeout: 60000,
|
||||
validateStatus: () => true
|
||||
})
|
||||
|
||||
// 根据 stream 参数决定请求类型
|
||||
if (isStream) {
|
||||
// 流式请求
|
||||
upstream = await axios.post('https://chatgpt.com/backend-api/codex/responses', req.body, {
|
||||
headers,
|
||||
responseType: 'stream',
|
||||
timeout: 60000,
|
||||
validateStatus: () => true
|
||||
})
|
||||
} else {
|
||||
// 非流式请求
|
||||
upstream = await axios.post('https://chatgpt.com/backend-api/codex/responses', req.body, {
|
||||
headers,
|
||||
timeout: 60000,
|
||||
validateStatus: () => true
|
||||
})
|
||||
}
|
||||
res.status(upstream.status)
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
res.setHeader('X-Accel-Buffering', 'no')
|
||||
|
||||
if (isStream) {
|
||||
// 流式响应头
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
res.setHeader('X-Accel-Buffering', 'no')
|
||||
} else {
|
||||
// 非流式响应头
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
// 透传关键诊断头,避免传递不安全或与传输相关的头
|
||||
const passThroughHeaderKeys = ['openai-version', 'x-request-id', 'openai-processing-ms']
|
||||
@@ -79,11 +135,170 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 立即刷新响应头,开始 SSE
|
||||
if (typeof res.flushHeaders === 'function') {
|
||||
res.flushHeaders()
|
||||
if (isStream) {
|
||||
// 立即刷新响应头,开始 SSE
|
||||
if (typeof res.flushHeaders === 'function') {
|
||||
res.flushHeaders()
|
||||
}
|
||||
}
|
||||
|
||||
// 处理响应并捕获 usage 数据和真实的 model
|
||||
let buffer = ''
|
||||
let usageData = null
|
||||
let actualModel = null
|
||||
let usageReported = false
|
||||
|
||||
if (!isStream) {
|
||||
// 非流式响应处理
|
||||
try {
|
||||
logger.info(`📄 Processing OpenAI non-stream response for model: ${requestedModel}`)
|
||||
|
||||
// 直接获取完整响应
|
||||
const responseData = upstream.data
|
||||
|
||||
// 从响应中获取实际的 model 和 usage
|
||||
actualModel = responseData.model || requestedModel || 'gpt-4'
|
||||
usageData = responseData.usage
|
||||
|
||||
logger.debug(`📊 Non-stream response - Model: ${actualModel}, Usage:`, usageData)
|
||||
|
||||
// 记录使用统计
|
||||
if (usageData) {
|
||||
const inputTokens = usageData.input_tokens || usageData.prompt_tokens || 0
|
||||
const outputTokens = usageData.output_tokens || usageData.completion_tokens || 0
|
||||
const cacheCreateTokens = usageData.input_tokens_details?.cache_creation_tokens || 0
|
||||
const cacheReadTokens = usageData.input_tokens_details?.cached_tokens || 0
|
||||
|
||||
await apiKeyService.recordUsage(
|
||||
apiKeyData.id,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
actualModel,
|
||||
accountId
|
||||
)
|
||||
|
||||
logger.info(
|
||||
`📊 Recorded OpenAI non-stream usage - Input: ${inputTokens}, Output: ${outputTokens}, Total: ${usageData.total_tokens || inputTokens + outputTokens}, Model: ${actualModel}`
|
||||
)
|
||||
}
|
||||
|
||||
// 返回响应
|
||||
res.json(responseData)
|
||||
return
|
||||
} catch (error) {
|
||||
logger.error('Failed to process non-stream response:', error)
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: { message: 'Failed to process response' } })
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 SSE 事件以捕获 usage 数据和 model
|
||||
const parseSSEForUsage = (data) => {
|
||||
const lines = data.split('\n')
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: response.completed')) {
|
||||
// 下一行应该是数据
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const jsonStr = line.slice(6) // 移除 'data: ' 前缀
|
||||
const eventData = JSON.parse(jsonStr)
|
||||
|
||||
// 检查是否是 response.completed 事件
|
||||
if (eventData.type === 'response.completed' && eventData.response) {
|
||||
// 从响应中获取真实的 model
|
||||
if (eventData.response.model) {
|
||||
actualModel = eventData.response.model
|
||||
logger.debug(`📊 Captured actual model: ${actualModel}`)
|
||||
}
|
||||
|
||||
// 获取 usage 数据
|
||||
if (eventData.response.usage) {
|
||||
usageData = eventData.response.usage
|
||||
logger.debug('📊 Captured OpenAI usage data:', usageData)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
upstream.data.on('data', (chunk) => {
|
||||
try {
|
||||
const chunkStr = chunk.toString()
|
||||
|
||||
// 转发数据给客户端
|
||||
if (!res.headersSent) {
|
||||
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 OpenAI stream chunk:', error)
|
||||
}
|
||||
})
|
||||
|
||||
upstream.data.on('end', async () => {
|
||||
// 处理剩余的 buffer
|
||||
if (buffer.trim()) {
|
||||
parseSSEForUsage(buffer)
|
||||
}
|
||||
|
||||
// 记录使用统计
|
||||
if (!usageReported && usageData) {
|
||||
try {
|
||||
const inputTokens = usageData.input_tokens || 0
|
||||
const outputTokens = usageData.output_tokens || 0
|
||||
const cacheCreateTokens = usageData.input_tokens_details?.cache_creation_tokens || 0
|
||||
const cacheReadTokens = usageData.input_tokens_details?.cached_tokens || 0
|
||||
|
||||
// 使用响应中的真实 model,如果没有则使用请求中的 model,最后回退到默认值
|
||||
const modelToRecord = actualModel || requestedModel || 'gpt-4'
|
||||
|
||||
await apiKeyService.recordUsage(
|
||||
apiKeyData.id,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
modelToRecord,
|
||||
accountId
|
||||
)
|
||||
|
||||
logger.info(
|
||||
`📊 Recorded OpenAI usage - Input: ${inputTokens}, Output: ${outputTokens}, Total: ${usageData.total_tokens || inputTokens + outputTokens}, Model: ${modelToRecord} (actual: ${actualModel}, requested: ${requestedModel})`
|
||||
)
|
||||
usageReported = true
|
||||
} catch (error) {
|
||||
logger.error('Failed to record OpenAI usage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
res.end()
|
||||
})
|
||||
|
||||
upstream.data.on('error', (err) => {
|
||||
logger.error('Upstream stream error:', err)
|
||||
if (!res.headersSent) {
|
||||
@@ -93,8 +308,6 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
upstream.data.pipe(res)
|
||||
|
||||
// 客户端断开时清理上游流
|
||||
const cleanup = () => {
|
||||
try {
|
||||
@@ -116,4 +329,65 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 使用情况统计端点
|
||||
router.get('/usage', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
const { usage } = req.apiKey
|
||||
|
||||
res.json({
|
||||
object: 'usage',
|
||||
total_tokens: usage.total.tokens,
|
||||
total_requests: usage.total.requests,
|
||||
daily_tokens: usage.daily.tokens,
|
||||
daily_requests: usage.daily.requests,
|
||||
monthly_tokens: usage.monthly.tokens,
|
||||
monthly_requests: usage.monthly.requests
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get usage stats:', error)
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to retrieve usage statistics',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// API Key 信息端点
|
||||
router.get('/key-info', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
const keyData = req.apiKey
|
||||
res.json({
|
||||
id: keyData.id,
|
||||
name: keyData.name,
|
||||
description: keyData.description,
|
||||
permissions: keyData.permissions || 'all',
|
||||
token_limit: keyData.tokenLimit,
|
||||
tokens_used: keyData.usage.total.tokens,
|
||||
tokens_remaining:
|
||||
keyData.tokenLimit > 0
|
||||
? Math.max(0, keyData.tokenLimit - keyData.usage.total.tokens)
|
||||
: null,
|
||||
rate_limit: {
|
||||
window: keyData.rateLimitWindow,
|
||||
requests: keyData.rateLimitRequests
|
||||
},
|
||||
usage: {
|
||||
total: keyData.usage.total,
|
||||
daily: keyData.usage.daily,
|
||||
monthly: keyData.usage.monthly
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get key info:', error)
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to retrieve API key information',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -27,8 +27,8 @@ class AccountGroupService {
|
||||
}
|
||||
|
||||
// 验证平台类型
|
||||
if (!['claude', 'gemini'].includes(platform)) {
|
||||
throw new Error('平台类型必须是 claude 或 gemini')
|
||||
if (!['claude', 'gemini', 'openai'].includes(platform)) {
|
||||
throw new Error('平台类型必须是 claude、gemini 或 openai')
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
@@ -309,7 +309,9 @@ class AccountGroupService {
|
||||
const keyData = await client.hgetall(`api_key:${keyId}`)
|
||||
if (
|
||||
keyData &&
|
||||
(keyData.claudeAccountId === groupKey || keyData.geminiAccountId === groupKey)
|
||||
(keyData.claudeAccountId === groupKey ||
|
||||
keyData.geminiAccountId === groupKey ||
|
||||
keyData.openaiAccountId === groupKey)
|
||||
) {
|
||||
boundApiKeys.push({
|
||||
id: keyId,
|
||||
|
||||
@@ -19,7 +19,8 @@ class ApiKeyService {
|
||||
claudeAccountId = null,
|
||||
claudeConsoleAccountId = null,
|
||||
geminiAccountId = null,
|
||||
permissions = 'all', // 'claude', 'gemini', 'all'
|
||||
openaiAccountId = null,
|
||||
permissions = 'all', // 'claude', 'gemini', 'openai', 'all'
|
||||
isActive = true,
|
||||
concurrencyLimit = 0,
|
||||
rateLimitWindow = null,
|
||||
@@ -50,6 +51,7 @@ class ApiKeyService {
|
||||
claudeAccountId: claudeAccountId || '',
|
||||
claudeConsoleAccountId: claudeConsoleAccountId || '',
|
||||
geminiAccountId: geminiAccountId || '',
|
||||
openaiAccountId: openaiAccountId || '',
|
||||
permissions: permissions || 'all',
|
||||
enableModelRestriction: String(enableModelRestriction),
|
||||
restrictedModels: JSON.stringify(restrictedModels || []),
|
||||
@@ -81,6 +83,7 @@ class ApiKeyService {
|
||||
claudeAccountId: keyData.claudeAccountId,
|
||||
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
|
||||
geminiAccountId: keyData.geminiAccountId,
|
||||
openaiAccountId: keyData.openaiAccountId,
|
||||
permissions: keyData.permissions,
|
||||
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
||||
restrictedModels: JSON.parse(keyData.restrictedModels),
|
||||
@@ -167,6 +170,7 @@ class ApiKeyService {
|
||||
claudeAccountId: keyData.claudeAccountId,
|
||||
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
|
||||
geminiAccountId: keyData.geminiAccountId,
|
||||
openaiAccountId: keyData.openaiAccountId,
|
||||
permissions: keyData.permissions || 'all',
|
||||
tokenLimit: parseInt(keyData.tokenLimit),
|
||||
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
||||
@@ -299,6 +303,7 @@ class ApiKeyService {
|
||||
'claudeAccountId',
|
||||
'claudeConsoleAccountId',
|
||||
'geminiAccountId',
|
||||
'openaiAccountId',
|
||||
'permissions',
|
||||
'expiresAt',
|
||||
'enableModelRestriction',
|
||||
|
||||
583
src/services/openaiAccountService.js
Normal file
583
src/services/openaiAccountService.js
Normal file
@@ -0,0 +1,583 @@
|
||||
const redisClient = require('../models/redis')
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
const config = require('../../config/config')
|
||||
const logger = require('../utils/logger')
|
||||
const { maskToken } = require('../utils/tokenMask')
|
||||
const {
|
||||
logRefreshStart,
|
||||
logRefreshSuccess,
|
||||
logRefreshError,
|
||||
logTokenUsage,
|
||||
logRefreshSkipped
|
||||
} = require('../utils/tokenRefreshLogger')
|
||||
const tokenRefreshService = require('./tokenRefreshService')
|
||||
|
||||
// 加密相关常量
|
||||
const ALGORITHM = 'aes-256-cbc'
|
||||
const ENCRYPTION_SALT = 'openai-account-salt'
|
||||
const IV_LENGTH = 16
|
||||
|
||||
// 生成加密密钥(使用与 claudeAccountService 相同的方法)
|
||||
function generateEncryptionKey() {
|
||||
return crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32)
|
||||
}
|
||||
|
||||
// OpenAI 账户键前缀
|
||||
const OPENAI_ACCOUNT_KEY_PREFIX = 'openai:account:'
|
||||
const SHARED_OPENAI_ACCOUNTS_KEY = 'shared_openai_accounts'
|
||||
const ACCOUNT_SESSION_MAPPING_PREFIX = 'openai_session_account_mapping:'
|
||||
|
||||
// 加密函数
|
||||
function encrypt(text) {
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
const key = generateEncryptionKey()
|
||||
const iv = crypto.randomBytes(IV_LENGTH)
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
||||
let encrypted = cipher.update(text)
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()])
|
||||
return `${iv.toString('hex')}:${encrypted.toString('hex')}`
|
||||
}
|
||||
|
||||
// 解密函数
|
||||
function decrypt(text) {
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
try {
|
||||
const key = generateEncryptionKey()
|
||||
// IV 是固定长度的 32 个十六进制字符(16 字节)
|
||||
const ivHex = text.substring(0, 32)
|
||||
const encryptedHex = text.substring(33) // 跳过冒号
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const encryptedText = Buffer.from(encryptedHex, 'hex')
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
||||
let decrypted = decipher.update(encryptedText)
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()])
|
||||
return decrypted.toString()
|
||||
} catch (error) {
|
||||
logger.error('Decryption error:', error)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新访问令牌
|
||||
async function refreshAccessToken(refreshToken) {
|
||||
try {
|
||||
// OpenAI OAuth token 刷新实现
|
||||
// TODO: 实现具体的 OpenAI OAuth token 刷新逻辑
|
||||
logger.warn('OpenAI token refresh not yet implemented')
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error('Error refreshing OpenAI access token:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 token 是否过期
|
||||
function isTokenExpired(account) {
|
||||
if (!account.expiresAt) {
|
||||
return false
|
||||
}
|
||||
return new Date(account.expiresAt) <= new Date()
|
||||
}
|
||||
|
||||
// 刷新账户的 access token
|
||||
async function refreshAccountToken(accountId) {
|
||||
const account = await getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
const accountName = account.name || accountId
|
||||
logRefreshStart(accountId, accountName, 'openai')
|
||||
|
||||
// 检查是否有 refresh token
|
||||
const refreshToken = account.refreshToken ? decrypt(account.refreshToken) : null
|
||||
if (!refreshToken) {
|
||||
logRefreshSkipped(accountId, accountName, 'openai', 'No refresh token available')
|
||||
throw new Error('No refresh token available')
|
||||
}
|
||||
|
||||
try {
|
||||
const newTokens = await refreshAccessToken(refreshToken)
|
||||
if (!newTokens) {
|
||||
throw new Error('Failed to refresh token')
|
||||
}
|
||||
|
||||
// 更新账户信息
|
||||
await updateAccount(accountId, {
|
||||
accessToken: encrypt(newTokens.access_token),
|
||||
expiresAt: new Date(newTokens.expiry_date).toISOString()
|
||||
})
|
||||
|
||||
logRefreshSuccess(accountId, accountName, 'openai', newTokens.expiry_date)
|
||||
return newTokens
|
||||
} catch (error) {
|
||||
logRefreshError(accountId, accountName, 'openai', error.message)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 创建账户
|
||||
async function createAccount(accountData) {
|
||||
const accountId = uuidv4()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// 处理OAuth数据
|
||||
let oauthData = {}
|
||||
if (accountData.openaiOauth) {
|
||||
oauthData =
|
||||
typeof accountData.openaiOauth === 'string'
|
||||
? JSON.parse(accountData.openaiOauth)
|
||||
: accountData.openaiOauth
|
||||
}
|
||||
|
||||
// 处理账户信息
|
||||
const accountInfo = accountData.accountInfo || {}
|
||||
|
||||
const account = {
|
||||
id: accountId,
|
||||
name: accountData.name,
|
||||
description: accountData.description || '',
|
||||
accountType: accountData.accountType || 'shared',
|
||||
groupId: accountData.groupId || null,
|
||||
priority: accountData.priority || 50,
|
||||
rateLimitDuration: accountData.rateLimitDuration || 60,
|
||||
// OAuth相关字段(加密存储)
|
||||
idToken: encrypt(oauthData.idToken || ''),
|
||||
accessToken: encrypt(oauthData.accessToken || ''),
|
||||
refreshToken: encrypt(oauthData.refreshToken || ''),
|
||||
openaiOauth: encrypt(JSON.stringify(oauthData)),
|
||||
// 账户信息字段
|
||||
accountId: accountInfo.accountId || '',
|
||||
chatgptUserId: accountInfo.chatgptUserId || '',
|
||||
organizationId: accountInfo.organizationId || '',
|
||||
organizationRole: accountInfo.organizationRole || '',
|
||||
organizationTitle: accountInfo.organizationTitle || '',
|
||||
planType: accountInfo.planType || '',
|
||||
email: encrypt(accountInfo.email || ''),
|
||||
emailVerified: accountInfo.emailVerified || false,
|
||||
// 过期时间
|
||||
expiresAt: oauthData.expires_in
|
||||
? new Date(Date.now() + oauthData.expires_in * 1000).toISOString()
|
||||
: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), // 默认1年
|
||||
// 状态字段
|
||||
isActive: accountData.isActive !== false ? 'true' : 'false',
|
||||
status: 'active',
|
||||
schedulable: accountData.schedulable !== false ? 'true' : 'false',
|
||||
lastRefresh: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
|
||||
// 代理配置
|
||||
if (accountData.proxy) {
|
||||
account.proxy =
|
||||
typeof accountData.proxy === 'string' ? accountData.proxy : JSON.stringify(accountData.proxy)
|
||||
}
|
||||
|
||||
const client = redisClient.getClientSafe()
|
||||
await client.hset(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account)
|
||||
|
||||
// 如果是共享账户,添加到共享账户集合
|
||||
if (account.accountType === 'shared') {
|
||||
await client.sadd(SHARED_OPENAI_ACCOUNTS_KEY, accountId)
|
||||
}
|
||||
|
||||
logger.info(`Created OpenAI account: ${accountId}`)
|
||||
return account
|
||||
}
|
||||
|
||||
// 获取账户
|
||||
async function getAccount(accountId) {
|
||||
const client = redisClient.getClientSafe()
|
||||
const accountData = await client.hgetall(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||
|
||||
if (!accountData || Object.keys(accountData).length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 解密敏感数据(仅用于内部处理,不返回给前端)
|
||||
if (accountData.idToken) {
|
||||
accountData.idToken = decrypt(accountData.idToken)
|
||||
}
|
||||
if (accountData.accessToken) {
|
||||
accountData.accessToken = decrypt(accountData.accessToken)
|
||||
}
|
||||
if (accountData.refreshToken) {
|
||||
accountData.refreshToken = decrypt(accountData.refreshToken)
|
||||
}
|
||||
if (accountData.email) {
|
||||
accountData.email = decrypt(accountData.email)
|
||||
}
|
||||
if (accountData.openaiOauth) {
|
||||
try {
|
||||
accountData.openaiOauth = JSON.parse(decrypt(accountData.openaiOauth))
|
||||
} catch (e) {
|
||||
accountData.openaiOauth = null
|
||||
}
|
||||
}
|
||||
|
||||
// 解析代理配置
|
||||
if (accountData.proxy && typeof accountData.proxy === 'string') {
|
||||
try {
|
||||
accountData.proxy = JSON.parse(accountData.proxy)
|
||||
} catch (e) {
|
||||
accountData.proxy = null
|
||||
}
|
||||
}
|
||||
|
||||
return accountData
|
||||
}
|
||||
|
||||
// 更新账户
|
||||
async function updateAccount(accountId, updates) {
|
||||
const existingAccount = await getAccount(accountId)
|
||||
if (!existingAccount) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
updates.updatedAt = new Date().toISOString()
|
||||
|
||||
// 加密敏感数据
|
||||
if (updates.openaiOauth) {
|
||||
const oauthData =
|
||||
typeof updates.openaiOauth === 'string'
|
||||
? updates.openaiOauth
|
||||
: JSON.stringify(updates.openaiOauth)
|
||||
updates.openaiOauth = encrypt(oauthData)
|
||||
}
|
||||
if (updates.idToken) {
|
||||
updates.idToken = encrypt(updates.idToken)
|
||||
}
|
||||
if (updates.accessToken) {
|
||||
updates.accessToken = encrypt(updates.accessToken)
|
||||
}
|
||||
if (updates.refreshToken) {
|
||||
updates.refreshToken = encrypt(updates.refreshToken)
|
||||
}
|
||||
if (updates.email) {
|
||||
updates.email = encrypt(updates.email)
|
||||
}
|
||||
|
||||
// 处理代理配置
|
||||
if (updates.proxy) {
|
||||
updates.proxy =
|
||||
typeof updates.proxy === 'string' ? updates.proxy : JSON.stringify(updates.proxy)
|
||||
}
|
||||
|
||||
// 更新账户类型时处理共享账户集合
|
||||
const client = redisClient.getClientSafe()
|
||||
if (updates.accountType && updates.accountType !== existingAccount.accountType) {
|
||||
if (updates.accountType === 'shared') {
|
||||
await client.sadd(SHARED_OPENAI_ACCOUNTS_KEY, accountId)
|
||||
} else {
|
||||
await client.srem(SHARED_OPENAI_ACCOUNTS_KEY, accountId)
|
||||
}
|
||||
}
|
||||
|
||||
await client.hset(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, updates)
|
||||
|
||||
logger.info(`Updated OpenAI account: ${accountId}`)
|
||||
|
||||
// 合并更新后的账户数据
|
||||
const updatedAccount = { ...existingAccount, ...updates }
|
||||
|
||||
// 返回时解析代理配置
|
||||
if (updatedAccount.proxy && typeof updatedAccount.proxy === 'string') {
|
||||
try {
|
||||
updatedAccount.proxy = JSON.parse(updatedAccount.proxy)
|
||||
} catch (e) {
|
||||
updatedAccount.proxy = null
|
||||
}
|
||||
}
|
||||
|
||||
return updatedAccount
|
||||
}
|
||||
|
||||
// 删除账户
|
||||
async function deleteAccount(accountId) {
|
||||
const account = await getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
// 从 Redis 删除
|
||||
const client = redisClient.getClientSafe()
|
||||
await client.del(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||
|
||||
// 从共享账户集合中移除
|
||||
if (account.accountType === 'shared') {
|
||||
await client.srem(SHARED_OPENAI_ACCOUNTS_KEY, accountId)
|
||||
}
|
||||
|
||||
// 清理会话映射
|
||||
const sessionMappings = await client.keys(`${ACCOUNT_SESSION_MAPPING_PREFIX}*`)
|
||||
for (const key of sessionMappings) {
|
||||
const mappedAccountId = await client.get(key)
|
||||
if (mappedAccountId === accountId) {
|
||||
await client.del(key)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Deleted OpenAI account: ${accountId}`)
|
||||
return true
|
||||
}
|
||||
|
||||
// 获取所有账户
|
||||
async function getAllAccounts() {
|
||||
const client = redisClient.getClientSafe()
|
||||
const keys = await client.keys(`${OPENAI_ACCOUNT_KEY_PREFIX}*`)
|
||||
const accounts = []
|
||||
|
||||
for (const key of keys) {
|
||||
const accountData = await client.hgetall(key)
|
||||
if (accountData && Object.keys(accountData).length > 0) {
|
||||
// 解密敏感数据(但不返回给前端)
|
||||
if (accountData.email) {
|
||||
accountData.email = decrypt(accountData.email)
|
||||
}
|
||||
|
||||
// 屏蔽敏感信息(token等不应该返回给前端)
|
||||
delete accountData.idToken
|
||||
delete accountData.accessToken
|
||||
delete accountData.refreshToken
|
||||
delete accountData.openaiOauth
|
||||
|
||||
// 获取限流状态信息
|
||||
const rateLimitInfo = await getAccountRateLimitInfo(accountData.id)
|
||||
|
||||
// 解析代理配置
|
||||
if (accountData.proxy) {
|
||||
try {
|
||||
accountData.proxy = JSON.parse(accountData.proxy)
|
||||
// 屏蔽代理密码
|
||||
if (accountData.proxy && accountData.proxy.password) {
|
||||
accountData.proxy.password = '******'
|
||||
}
|
||||
} catch (e) {
|
||||
// 如果解析失败,设置为null
|
||||
accountData.proxy = null
|
||||
}
|
||||
}
|
||||
|
||||
// 不解密敏感字段,只返回基本信息
|
||||
accounts.push({
|
||||
...accountData,
|
||||
openaiOauth: accountData.openaiOauth ? '[ENCRYPTED]' : '',
|
||||
accessToken: accountData.accessToken ? '[ENCRYPTED]' : '',
|
||||
refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '',
|
||||
// 添加限流状态信息(统一格式)
|
||||
rateLimitStatus: rateLimitInfo
|
||||
? {
|
||||
isRateLimited: rateLimitInfo.isRateLimited,
|
||||
rateLimitedAt: rateLimitInfo.rateLimitedAt,
|
||||
minutesRemaining: rateLimitInfo.minutesRemaining
|
||||
}
|
||||
: {
|
||||
isRateLimited: false,
|
||||
rateLimitedAt: null,
|
||||
minutesRemaining: 0
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return accounts
|
||||
}
|
||||
|
||||
// 选择可用账户(支持专属和共享账户)
|
||||
async function selectAvailableAccount(apiKeyId, sessionHash = null) {
|
||||
// 首先检查是否有粘性会话
|
||||
const client = redisClient.getClientSafe()
|
||||
if (sessionHash) {
|
||||
const mappedAccountId = await client.get(`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`)
|
||||
|
||||
if (mappedAccountId) {
|
||||
const account = await getAccount(mappedAccountId)
|
||||
if (account && account.isActive === 'true' && !isTokenExpired(account)) {
|
||||
logger.debug(`Using sticky session account: ${mappedAccountId}`)
|
||||
return account
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 API Key 信息
|
||||
const apiKeyData = await client.hgetall(`api_key:${apiKeyId}`)
|
||||
|
||||
// 检查是否绑定了 OpenAI 账户
|
||||
if (apiKeyData.openaiAccountId) {
|
||||
const account = await getAccount(apiKeyData.openaiAccountId)
|
||||
if (account && account.isActive === 'true') {
|
||||
// 检查 token 是否过期
|
||||
const isExpired = isTokenExpired(account)
|
||||
|
||||
// 记录token使用情况
|
||||
logTokenUsage(account.id, account.name, 'openai', account.expiresAt, isExpired)
|
||||
|
||||
if (isExpired) {
|
||||
await refreshAccountToken(account.id)
|
||||
return await getAccount(account.id)
|
||||
}
|
||||
|
||||
// 创建粘性会话映射
|
||||
if (sessionHash) {
|
||||
await client.setex(
|
||||
`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`,
|
||||
3600, // 1小时过期
|
||||
account.id
|
||||
)
|
||||
}
|
||||
|
||||
return account
|
||||
}
|
||||
}
|
||||
|
||||
// 从共享账户池选择
|
||||
const sharedAccountIds = await client.smembers(SHARED_OPENAI_ACCOUNTS_KEY)
|
||||
const availableAccounts = []
|
||||
|
||||
for (const accountId of sharedAccountIds) {
|
||||
const account = await getAccount(accountId)
|
||||
if (account && account.isActive === 'true' && !isRateLimited(account)) {
|
||||
availableAccounts.push(account)
|
||||
}
|
||||
}
|
||||
|
||||
if (availableAccounts.length === 0) {
|
||||
throw new Error('No available OpenAI accounts')
|
||||
}
|
||||
|
||||
// 选择使用最少的账户
|
||||
const selectedAccount = availableAccounts.reduce((prev, curr) => {
|
||||
const prevUsage = parseInt(prev.totalUsage || 0)
|
||||
const currUsage = parseInt(curr.totalUsage || 0)
|
||||
return prevUsage <= currUsage ? prev : curr
|
||||
})
|
||||
|
||||
// 检查 token 是否过期
|
||||
if (isTokenExpired(selectedAccount)) {
|
||||
await refreshAccountToken(selectedAccount.id)
|
||||
return await getAccount(selectedAccount.id)
|
||||
}
|
||||
|
||||
// 创建粘性会话映射
|
||||
if (sessionHash) {
|
||||
await client.setex(
|
||||
`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`,
|
||||
3600, // 1小时过期
|
||||
selectedAccount.id
|
||||
)
|
||||
}
|
||||
|
||||
return selectedAccount
|
||||
}
|
||||
|
||||
// 检查账户是否被限流
|
||||
function isRateLimited(account) {
|
||||
if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) {
|
||||
const limitedAt = new Date(account.rateLimitedAt).getTime()
|
||||
const now = Date.now()
|
||||
const limitDuration = 60 * 60 * 1000 // 1小时
|
||||
|
||||
return now < limitedAt + limitDuration
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 设置账户限流状态
|
||||
async function setAccountRateLimited(accountId, isLimited) {
|
||||
const updates = {
|
||||
rateLimitStatus: isLimited ? 'limited' : 'normal',
|
||||
rateLimitedAt: isLimited ? new Date().toISOString() : null
|
||||
}
|
||||
|
||||
await updateAccount(accountId, updates)
|
||||
logger.info(`Set rate limit status for OpenAI account ${accountId}: ${updates.rateLimitStatus}`)
|
||||
}
|
||||
|
||||
// 切换账户调度状态
|
||||
async function toggleSchedulable(accountId) {
|
||||
const account = await getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
// 切换调度状态
|
||||
const newSchedulable = account.schedulable === 'false' ? 'true' : 'false'
|
||||
|
||||
await updateAccount(accountId, {
|
||||
schedulable: newSchedulable
|
||||
})
|
||||
|
||||
logger.info(`Toggled schedulable status for OpenAI account ${accountId}: ${newSchedulable}`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
schedulable: newSchedulable === 'true'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取账户限流信息
|
||||
async function getAccountRateLimitInfo(accountId) {
|
||||
const account = await getAccount(accountId)
|
||||
if (!account) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) {
|
||||
const limitedAt = new Date(account.rateLimitedAt).getTime()
|
||||
const now = Date.now()
|
||||
const limitDuration = 60 * 60 * 1000 // 1小时
|
||||
const remainingTime = Math.max(0, limitedAt + limitDuration - now)
|
||||
|
||||
return {
|
||||
isRateLimited: remainingTime > 0,
|
||||
rateLimitedAt: account.rateLimitedAt,
|
||||
minutesRemaining: Math.ceil(remainingTime / (60 * 1000))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isRateLimited: false,
|
||||
rateLimitedAt: null,
|
||||
minutesRemaining: 0
|
||||
}
|
||||
}
|
||||
|
||||
// 更新账户使用统计
|
||||
async function updateAccountUsage(accountId, tokens) {
|
||||
const account = await getAccount(accountId)
|
||||
if (!account) {
|
||||
return
|
||||
}
|
||||
|
||||
const totalUsage = parseInt(account.totalUsage || 0) + tokens
|
||||
const lastUsedAt = new Date().toISOString()
|
||||
|
||||
await updateAccount(accountId, {
|
||||
totalUsage: totalUsage.toString(),
|
||||
lastUsedAt
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createAccount,
|
||||
getAccount,
|
||||
updateAccount,
|
||||
deleteAccount,
|
||||
getAllAccounts,
|
||||
selectAvailableAccount,
|
||||
refreshAccountToken,
|
||||
isTokenExpired,
|
||||
setAccountRateLimited,
|
||||
toggleSchedulable,
|
||||
getAccountRateLimitInfo,
|
||||
updateAccountUsage,
|
||||
encrypt,
|
||||
decrypt
|
||||
}
|
||||
492
src/services/unifiedOpenAIScheduler.js
Normal file
492
src/services/unifiedOpenAIScheduler.js
Normal file
@@ -0,0 +1,492 @@
|
||||
const openaiAccountService = require('./openaiAccountService')
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
class UnifiedOpenAIScheduler {
|
||||
constructor() {
|
||||
this.SESSION_MAPPING_PREFIX = 'unified_openai_session_mapping:'
|
||||
}
|
||||
|
||||
// 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值)
|
||||
_isSchedulable(schedulable) {
|
||||
// 如果是 undefined 或 null,默认为可调度
|
||||
if (schedulable === undefined || schedulable === null) {
|
||||
return true
|
||||
}
|
||||
// 明确设置为 false(布尔值)或 'false'(字符串)时不可调度
|
||||
return schedulable !== false && schedulable !== 'false'
|
||||
}
|
||||
|
||||
// 🎯 统一调度OpenAI账号
|
||||
async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
|
||||
try {
|
||||
// 如果API Key绑定了专属账户或分组,优先使用
|
||||
if (apiKeyData.openaiAccountId) {
|
||||
// 检查是否是分组
|
||||
if (apiKeyData.openaiAccountId.startsWith('group:')) {
|
||||
const groupId = apiKeyData.openaiAccountId.replace('group:', '')
|
||||
logger.info(
|
||||
`🎯 API key ${apiKeyData.name} is bound to group ${groupId}, selecting from group`
|
||||
)
|
||||
return await this.selectAccountFromGroup(groupId, sessionHash, requestedModel, apiKeyData)
|
||||
}
|
||||
|
||||
// 普通专属账户
|
||||
const boundAccount = await openaiAccountService.getAccount(apiKeyData.openaiAccountId)
|
||||
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
|
||||
logger.info(
|
||||
`🎯 Using bound dedicated OpenAI account: ${boundAccount.name} (${apiKeyData.openaiAccountId}) for API key ${apiKeyData.name}`
|
||||
)
|
||||
return {
|
||||
accountId: apiKeyData.openaiAccountId,
|
||||
accountType: 'openai'
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
`⚠️ Bound OpenAI account ${apiKeyData.openaiAccountId} is not available, falling back to pool`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有会话哈希,检查是否有已映射的账户
|
||||
if (sessionHash) {
|
||||
const mappedAccount = await this._getSessionMapping(sessionHash)
|
||||
if (mappedAccount) {
|
||||
// 验证映射的账户是否仍然可用
|
||||
const isAvailable = await this._isAccountAvailable(
|
||||
mappedAccount.accountId,
|
||||
mappedAccount.accountType
|
||||
)
|
||||
if (isAvailable) {
|
||||
logger.info(
|
||||
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
||||
)
|
||||
return mappedAccount
|
||||
} else {
|
||||
logger.warn(
|
||||
`⚠️ Mapped account ${mappedAccount.accountId} is no longer available, selecting new account`
|
||||
)
|
||||
await this._deleteSessionMapping(sessionHash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有可用账户
|
||||
const availableAccounts = await this._getAllAvailableAccounts(apiKeyData, requestedModel)
|
||||
|
||||
if (availableAccounts.length === 0) {
|
||||
// 提供更详细的错误信息
|
||||
if (requestedModel) {
|
||||
throw new Error(
|
||||
`No available OpenAI accounts support the requested model: ${requestedModel}`
|
||||
)
|
||||
} else {
|
||||
throw new Error('No available OpenAI accounts')
|
||||
}
|
||||
}
|
||||
|
||||
// 按优先级和最后使用时间排序
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
|
||||
|
||||
// 选择第一个账户
|
||||
const selectedAccount = sortedAccounts[0]
|
||||
|
||||
// 如果有会话哈希,建立新的映射
|
||||
if (sessionHash) {
|
||||
await this._setSessionMapping(
|
||||
sessionHash,
|
||||
selectedAccount.accountId,
|
||||
selectedAccount.accountType
|
||||
)
|
||||
logger.info(
|
||||
`🎯 Created new sticky session mapping: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}`
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority} for API key ${apiKeyData.name}`
|
||||
)
|
||||
|
||||
return {
|
||||
accountId: selectedAccount.accountId,
|
||||
accountType: selectedAccount.accountType
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to select account for API key:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 📋 获取所有可用账户
|
||||
async _getAllAvailableAccounts(apiKeyData, requestedModel = null) {
|
||||
const availableAccounts = []
|
||||
|
||||
// 如果API Key绑定了专属账户,优先返回
|
||||
if (apiKeyData.openaiAccountId) {
|
||||
const boundAccount = await openaiAccountService.getAccount(apiKeyData.openaiAccountId)
|
||||
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
|
||||
const isRateLimited = await this.isAccountRateLimited(boundAccount.id)
|
||||
if (!isRateLimited) {
|
||||
// 检查模型支持
|
||||
if (
|
||||
requestedModel &&
|
||||
boundAccount.supportedModels &&
|
||||
boundAccount.supportedModels.length > 0
|
||||
) {
|
||||
const modelSupported = boundAccount.supportedModels.includes(requestedModel)
|
||||
if (!modelSupported) {
|
||||
logger.warn(
|
||||
`⚠️ Bound OpenAI account ${boundAccount.name} does not support model ${requestedModel}`
|
||||
)
|
||||
return availableAccounts
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🎯 Using bound dedicated OpenAI account: ${boundAccount.name} (${apiKeyData.openaiAccountId})`
|
||||
)
|
||||
return [
|
||||
{
|
||||
...boundAccount,
|
||||
accountId: boundAccount.id,
|
||||
accountType: 'openai',
|
||||
priority: parseInt(boundAccount.priority) || 50,
|
||||
lastUsedAt: boundAccount.lastUsedAt || '0'
|
||||
}
|
||||
]
|
||||
}
|
||||
} else {
|
||||
logger.warn(`⚠️ Bound OpenAI account ${apiKeyData.openaiAccountId} is not available`)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有OpenAI账户(共享池)
|
||||
const openaiAccounts = await openaiAccountService.getAllAccounts()
|
||||
for (const account of openaiAccounts) {
|
||||
if (
|
||||
account.isActive === 'true' &&
|
||||
account.status !== 'error' &&
|
||||
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
|
||||
this._isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 检查是否可调度
|
||||
|
||||
// 检查token是否过期
|
||||
const isExpired = openaiAccountService.isTokenExpired(account)
|
||||
if (isExpired && !account.refreshToken) {
|
||||
logger.warn(
|
||||
`⚠️ OpenAI account ${account.name} token expired and no refresh token available`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查模型支持
|
||||
if (requestedModel && account.supportedModels && account.supportedModels.length > 0) {
|
||||
const modelSupported = account.supportedModels.includes(requestedModel)
|
||||
if (!modelSupported) {
|
||||
logger.debug(
|
||||
`⏭️ Skipping OpenAI account ${account.name} - doesn't support model ${requestedModel}`
|
||||
)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否被限流
|
||||
const isRateLimited = await this.isAccountRateLimited(account.id)
|
||||
if (isRateLimited) {
|
||||
logger.debug(`⏭️ Skipping OpenAI account ${account.name} - rate limited`)
|
||||
continue
|
||||
}
|
||||
|
||||
availableAccounts.push({
|
||||
...account,
|
||||
accountId: account.id,
|
||||
accountType: 'openai',
|
||||
priority: parseInt(account.priority) || 50,
|
||||
lastUsedAt: account.lastUsedAt || '0'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return availableAccounts
|
||||
}
|
||||
|
||||
// 🔢 按优先级和最后使用时间排序账户
|
||||
_sortAccountsByPriority(accounts) {
|
||||
return accounts.sort((a, b) => {
|
||||
// 首先按优先级排序(数字越小优先级越高)
|
||||
if (a.priority !== b.priority) {
|
||||
return a.priority - b.priority
|
||||
}
|
||||
|
||||
// 优先级相同时,按最后使用时间排序(最久未使用的优先)
|
||||
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
|
||||
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
|
||||
return aLastUsed - bLastUsed
|
||||
})
|
||||
}
|
||||
|
||||
// 🔍 检查账户是否可用
|
||||
async _isAccountAvailable(accountId, accountType) {
|
||||
try {
|
||||
if (accountType === 'openai') {
|
||||
const account = await openaiAccountService.getAccount(accountId)
|
||||
if (!account || account.isActive !== 'true' || account.status === 'error') {
|
||||
return false
|
||||
}
|
||||
// 检查是否可调度
|
||||
if (!this._isSchedulable(account.schedulable)) {
|
||||
logger.info(`🚫 OpenAI account ${accountId} is not schedulable`)
|
||||
return false
|
||||
}
|
||||
return !(await this.isAccountRateLimited(accountId))
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
logger.warn(`⚠️ Failed to check account availability: ${accountId}`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 🔗 获取会话映射
|
||||
async _getSessionMapping(sessionHash) {
|
||||
const client = redis.getClientSafe()
|
||||
const mappingData = await client.get(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`)
|
||||
|
||||
if (mappingData) {
|
||||
try {
|
||||
return JSON.parse(mappingData)
|
||||
} catch (error) {
|
||||
logger.warn('⚠️ Failed to parse session mapping:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// 💾 设置会话映射
|
||||
async _setSessionMapping(sessionHash, accountId, accountType) {
|
||||
const client = redis.getClientSafe()
|
||||
const mappingData = JSON.stringify({ accountId, accountType })
|
||||
|
||||
// 设置1小时过期
|
||||
await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, 3600, mappingData)
|
||||
}
|
||||
|
||||
// 🗑️ 删除会话映射
|
||||
async _deleteSessionMapping(sessionHash) {
|
||||
const client = redis.getClientSafe()
|
||||
await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`)
|
||||
}
|
||||
|
||||
// 🚫 标记账户为限流状态
|
||||
async markAccountRateLimited(accountId, accountType, sessionHash = null) {
|
||||
try {
|
||||
if (accountType === 'openai') {
|
||||
await openaiAccountService.setAccountRateLimited(accountId, true)
|
||||
}
|
||||
|
||||
// 删除会话映射
|
||||
if (sessionHash) {
|
||||
await this._deleteSessionMapping(sessionHash)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`❌ Failed to mark account as rate limited: ${accountId} (${accountType})`,
|
||||
error
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 移除账户的限流状态
|
||||
async removeAccountRateLimit(accountId, accountType) {
|
||||
try {
|
||||
if (accountType === 'openai') {
|
||||
await openaiAccountService.setAccountRateLimited(accountId, false)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`❌ Failed to remove rate limit for account: ${accountId} (${accountType})`,
|
||||
error
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 检查账户是否处于限流状态
|
||||
async isAccountRateLimited(accountId) {
|
||||
try {
|
||||
const account = await openaiAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) {
|
||||
const limitedAt = new Date(account.rateLimitedAt).getTime()
|
||||
const now = Date.now()
|
||||
const limitDuration = 60 * 60 * 1000 // 1小时
|
||||
|
||||
return now < limitedAt + limitDuration
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to check rate limit status: ${accountId}`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 👥 从分组中选择账户
|
||||
async selectAccountFromGroup(groupId, sessionHash = null, requestedModel = null) {
|
||||
try {
|
||||
// 获取分组信息
|
||||
const group = await accountGroupService.getGroup(groupId)
|
||||
if (!group) {
|
||||
throw new Error(`Group ${groupId} not found`)
|
||||
}
|
||||
|
||||
if (group.platform !== 'openai') {
|
||||
throw new Error(`Group ${group.name} is not an OpenAI group`)
|
||||
}
|
||||
|
||||
logger.info(`👥 Selecting account from OpenAI group: ${group.name}`)
|
||||
|
||||
// 如果有会话哈希,检查是否有已映射的账户
|
||||
if (sessionHash) {
|
||||
const mappedAccount = await this._getSessionMapping(sessionHash)
|
||||
if (mappedAccount) {
|
||||
// 验证映射的账户是否仍然可用并且在分组中
|
||||
const isInGroup = await this._isAccountInGroup(mappedAccount.accountId, groupId)
|
||||
if (isInGroup) {
|
||||
const isAvailable = await this._isAccountAvailable(
|
||||
mappedAccount.accountId,
|
||||
mappedAccount.accountType
|
||||
)
|
||||
if (isAvailable) {
|
||||
logger.info(
|
||||
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType})`
|
||||
)
|
||||
return mappedAccount
|
||||
}
|
||||
}
|
||||
// 如果账户不可用或不在分组中,删除映射
|
||||
await this._deleteSessionMapping(sessionHash)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取分组成员
|
||||
const memberIds = await accountGroupService.getGroupMembers(groupId)
|
||||
if (memberIds.length === 0) {
|
||||
throw new Error(`Group ${group.name} has no members`)
|
||||
}
|
||||
|
||||
// 获取可用的分组成员账户
|
||||
const availableAccounts = []
|
||||
for (const memberId of memberIds) {
|
||||
const account = await openaiAccountService.getAccount(memberId)
|
||||
if (
|
||||
account &&
|
||||
account.isActive === 'true' &&
|
||||
account.status !== 'error' &&
|
||||
this._isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 检查token是否过期
|
||||
const isExpired = openaiAccountService.isTokenExpired(account)
|
||||
if (isExpired && !account.refreshToken) {
|
||||
logger.warn(
|
||||
`⚠️ Group member OpenAI account ${account.name} token expired and no refresh token available`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查模型支持
|
||||
if (requestedModel && account.supportedModels && account.supportedModels.length > 0) {
|
||||
const modelSupported = account.supportedModels.includes(requestedModel)
|
||||
if (!modelSupported) {
|
||||
logger.debug(
|
||||
`⏭️ Skipping group member OpenAI account ${account.name} - doesn't support model ${requestedModel}`
|
||||
)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否被限流
|
||||
const isRateLimited = await this.isAccountRateLimited(account.id)
|
||||
if (isRateLimited) {
|
||||
logger.debug(`⏭️ Skipping group member OpenAI account ${account.name} - rate limited`)
|
||||
continue
|
||||
}
|
||||
|
||||
availableAccounts.push({
|
||||
...account,
|
||||
accountId: account.id,
|
||||
accountType: 'openai',
|
||||
priority: parseInt(account.priority) || 50,
|
||||
lastUsedAt: account.lastUsedAt || '0'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (availableAccounts.length === 0) {
|
||||
throw new Error(`No available accounts in group ${group.name}`)
|
||||
}
|
||||
|
||||
// 按优先级和最后使用时间排序
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
|
||||
|
||||
// 选择第一个账户
|
||||
const selectedAccount = sortedAccounts[0]
|
||||
|
||||
// 如果有会话哈希,建立新的映射
|
||||
if (sessionHash) {
|
||||
await this._setSessionMapping(
|
||||
sessionHash,
|
||||
selectedAccount.accountId,
|
||||
selectedAccount.accountType
|
||||
)
|
||||
logger.info(
|
||||
`🎯 Created new sticky session mapping from group: ${selectedAccount.name} (${selectedAccount.accountId})`
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🎯 Selected account from group: ${selectedAccount.name} (${selectedAccount.accountId}) with priority ${selectedAccount.priority}`
|
||||
)
|
||||
|
||||
return {
|
||||
accountId: selectedAccount.accountId,
|
||||
accountType: selectedAccount.accountType
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to select account from group ${groupId}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 检查账户是否在分组中
|
||||
async _isAccountInGroup(accountId, groupId) {
|
||||
const members = await accountGroupService.getGroupMembers(groupId)
|
||||
return members.includes(accountId)
|
||||
}
|
||||
|
||||
// 📊 更新账户最后使用时间
|
||||
async updateAccountLastUsed(accountId, accountType) {
|
||||
try {
|
||||
if (accountType === 'openai') {
|
||||
await openaiAccountService.updateAccount(accountId, {
|
||||
lastUsedAt: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`⚠️ Failed to update last used time for account ${accountId}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new UnifiedOpenAIScheduler()
|
||||
@@ -81,11 +81,29 @@ class CostCalculator {
|
||||
|
||||
if (pricingData) {
|
||||
// 转换动态价格格式为内部格式
|
||||
const inputPrice = (pricingData.input_cost_per_token || 0) * 1000000 // 转换为per 1M tokens
|
||||
const outputPrice = (pricingData.output_cost_per_token || 0) * 1000000
|
||||
const cacheReadPrice = (pricingData.cache_read_input_token_cost || 0) * 1000000
|
||||
|
||||
// OpenAI 模型的特殊处理:
|
||||
// - 如果没有 cache_creation_input_token_cost,缓存创建按普通 input 价格计费
|
||||
// - Claude 模型有专门的 cache_creation_input_token_cost
|
||||
let cacheWritePrice = (pricingData.cache_creation_input_token_cost || 0) * 1000000
|
||||
|
||||
// 检测是否为 OpenAI 模型(通过模型名或 litellm_provider)
|
||||
const isOpenAIModel =
|
||||
model.includes('gpt') || model.includes('o1') || pricingData.litellm_provider === 'openai'
|
||||
|
||||
if (isOpenAIModel && !pricingData.cache_creation_input_token_cost && cacheCreateTokens > 0) {
|
||||
// OpenAI 模型:缓存创建按普通 input 价格计费
|
||||
cacheWritePrice = inputPrice
|
||||
}
|
||||
|
||||
pricing = {
|
||||
input: (pricingData.input_cost_per_token || 0) * 1000000, // 转换为per 1M tokens
|
||||
output: (pricingData.output_cost_per_token || 0) * 1000000,
|
||||
cacheWrite: (pricingData.cache_creation_input_token_cost || 0) * 1000000,
|
||||
cacheRead: (pricingData.cache_read_input_token_cost || 0) * 1000000
|
||||
input: inputPrice,
|
||||
output: outputPrice,
|
||||
cacheWrite: cacheWritePrice,
|
||||
cacheRead: cacheReadPrice
|
||||
}
|
||||
usingDynamicPricing = true
|
||||
} else {
|
||||
@@ -126,6 +144,13 @@ class CostCalculator {
|
||||
cacheWrite: this.formatCost(cacheWriteCost),
|
||||
cacheRead: this.formatCost(cacheReadCost),
|
||||
total: this.formatCost(totalCost)
|
||||
},
|
||||
// 添加调试信息
|
||||
debug: {
|
||||
isOpenAIModel: model.includes('gpt') || model.includes('o1'),
|
||||
hasCacheCreatePrice: !!pricingData?.cache_creation_input_token_cost,
|
||||
cacheCreateTokens,
|
||||
cacheWritePriceUsed: pricing.cacheWrite
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user