mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Compare commits
69 Commits
revert-401
...
revert-424
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96f213e3e2 | ||
|
|
1d915d8327 | ||
|
|
af8350b850 | ||
|
|
a039d817db | ||
|
|
ebafbdcc55 | ||
|
|
67e72f1aaf | ||
|
|
99d72516ae | ||
|
|
5ea3623736 | ||
|
|
e36bacfd6b | ||
|
|
22e27738aa | ||
|
|
8522d20cad | ||
|
|
d5b9f809b0 | ||
|
|
022724336b | ||
|
|
482eb7c8f7 | ||
|
|
01eadea10b | ||
|
|
5f5826ce56 | ||
|
|
97b94eeff9 | ||
|
|
9836f88068 | ||
|
|
26d8c98c9d | ||
|
|
5706c32933 | ||
|
|
2de5191c05 | ||
|
|
2b40552eab | ||
|
|
30acf4a374 | ||
|
|
be7416386f | ||
|
|
1beed324d9 | ||
|
|
2e09896d0b | ||
|
|
e80c49c1ce | ||
|
|
27034997a6 | ||
|
|
24ad052d02 | ||
|
|
19ca374527 | ||
|
|
27c0804219 | ||
|
|
cd7959f3bf | ||
|
|
e88e97b485 | ||
|
|
4aae4aaec0 | ||
|
|
c7e1a3429d | ||
|
|
74d37486b8 | ||
|
|
1eadc94592 | ||
|
|
87591365bc | ||
|
|
f4b873315a | ||
|
|
cb1b7bc0e3 | ||
|
|
504b9e3ea7 | ||
|
|
19ad0cd5f8 | ||
|
|
009f7c84f6 | ||
|
|
7c4feec5aa | ||
|
|
b6b16d05f0 | ||
|
|
02989a7588 | ||
|
|
fef2c8c3c2 | ||
|
|
c0735b1bc5 | ||
|
|
0eb95b3b06 | ||
|
|
f667a95d88 | ||
|
|
03db930354 | ||
|
|
2fcfccb2fc | ||
|
|
78adf82f0d | ||
|
|
fe1f05fadd | ||
|
|
cd5573ecde | ||
|
|
fcc2f51f81 | ||
|
|
4fd4dbfa51 | ||
|
|
ce8706d1b6 | ||
|
|
d3fcd95b94 | ||
|
|
433f0c5f23 | ||
|
|
7712d5516c | ||
|
|
bdae9d6ceb | ||
|
|
08946c67ea | ||
|
|
8b0e9b8d8e | ||
|
|
1dd00e1463 | ||
|
|
5938180583 | ||
|
|
fb6d0e7f55 | ||
|
|
c0059c68eb | ||
|
|
7f9869ae20 |
@@ -26,7 +26,7 @@ REDIS_ENABLE_TLS=
|
||||
# 粘性会话TTL配置(小时),默认1小时
|
||||
STICKY_SESSION_TTL_HOURS=1
|
||||
# 续期阈值(分钟),默认0分钟(不续期)
|
||||
STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES=0
|
||||
STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES=15
|
||||
|
||||
# 🎯 Claude API 配置
|
||||
CLAUDE_API_URL=https://api.anthropic.com/v1/messages
|
||||
|
||||
@@ -19,6 +19,7 @@ const webRoutes = require('./routes/web')
|
||||
const apiStatsRoutes = require('./routes/apiStats')
|
||||
const geminiRoutes = require('./routes/geminiRoutes')
|
||||
const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes')
|
||||
const standardGeminiRoutes = require('./routes/standardGeminiRoutes')
|
||||
const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes')
|
||||
const openaiRoutes = require('./routes/openaiRoutes')
|
||||
const userRoutes = require('./routes/userRoutes')
|
||||
@@ -34,6 +35,7 @@ const {
|
||||
globalRateLimit,
|
||||
requestSizeLimit
|
||||
} = require('./middleware/auth')
|
||||
const { browserFallbackMiddleware } = require('./middleware/browserFallback')
|
||||
|
||||
class Application {
|
||||
constructor() {
|
||||
@@ -109,6 +111,9 @@ class Application {
|
||||
this.app.use(corsMiddleware)
|
||||
}
|
||||
|
||||
// 🆕 兜底中间件:处理Chrome插件兼容性(必须在认证之前)
|
||||
this.app.use(browserFallbackMiddleware)
|
||||
|
||||
// 📦 压缩 - 排除流式响应(SSE)
|
||||
this.app.use(
|
||||
compression({
|
||||
@@ -251,7 +256,9 @@ class Application {
|
||||
// 使用 web 路由(包含 auth 和页面重定向)
|
||||
this.app.use('/web', webRoutes)
|
||||
this.app.use('/apiStats', apiStatsRoutes)
|
||||
this.app.use('/gemini', geminiRoutes)
|
||||
// Gemini 路由:同时支持标准格式和原有格式
|
||||
this.app.use('/gemini', standardGeminiRoutes) // 标准 Gemini API 格式路由
|
||||
this.app.use('/gemini', geminiRoutes) // 保留原有路径以保持向后兼容
|
||||
this.app.use('/openai/gemini', openaiGeminiRoutes)
|
||||
this.app.use('/openai/claude', openaiClaudeRoutes)
|
||||
this.app.use('/openai', openaiRoutes)
|
||||
|
||||
@@ -757,7 +757,7 @@ const requireAdmin = (req, res, next) => {
|
||||
// 注意:使用统计现在直接在/api/v1/messages路由中处理,
|
||||
// 以便从Claude API响应中提取真实的usage数据
|
||||
|
||||
// 🚦 CORS中间件(优化版)
|
||||
// 🚦 CORS中间件(优化版,支持Chrome插件)
|
||||
const corsMiddleware = (req, res, next) => {
|
||||
const { origin } = req.headers
|
||||
|
||||
@@ -769,8 +769,11 @@ const corsMiddleware = (req, res, next) => {
|
||||
'https://127.0.0.1:3000'
|
||||
]
|
||||
|
||||
// 🆕 检查是否为Chrome插件请求
|
||||
const isChromeExtension = origin && origin.startsWith('chrome-extension://')
|
||||
|
||||
// 设置CORS头
|
||||
if (allowedOrigins.includes(origin) || !origin) {
|
||||
if (allowedOrigins.includes(origin) || !origin || isChromeExtension) {
|
||||
res.header('Access-Control-Allow-Origin', origin || '*')
|
||||
}
|
||||
|
||||
@@ -785,7 +788,9 @@ const corsMiddleware = (req, res, next) => {
|
||||
'Authorization',
|
||||
'x-api-key',
|
||||
'api-key',
|
||||
'x-admin-token'
|
||||
'x-admin-token',
|
||||
'anthropic-version',
|
||||
'anthropic-dangerous-direct-browser-access'
|
||||
].join(', ')
|
||||
)
|
||||
|
||||
|
||||
52
src/middleware/browserFallback.js
Normal file
52
src/middleware/browserFallback.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
/**
|
||||
* 浏览器/Chrome插件兜底中间件
|
||||
* 专门处理第三方插件的兼容性问题
|
||||
*/
|
||||
const browserFallbackMiddleware = (req, res, next) => {
|
||||
const userAgent = req.headers['user-agent'] || ''
|
||||
const origin = req.headers['origin'] || ''
|
||||
const authHeader = req.headers['authorization'] || req.headers['x-api-key'] || ''
|
||||
|
||||
// 检查是否为Chrome插件或浏览器请求
|
||||
const isChromeExtension = origin.startsWith('chrome-extension://')
|
||||
const isBrowserRequest = userAgent.includes('Mozilla/') && userAgent.includes('Chrome/')
|
||||
const hasApiKey = authHeader.startsWith('cr_') // 我们的API Key格式
|
||||
|
||||
if ((isChromeExtension || isBrowserRequest) && hasApiKey) {
|
||||
// 为Chrome插件请求添加特殊标记
|
||||
req.isBrowserFallback = true
|
||||
req.originalUserAgent = userAgent
|
||||
|
||||
// 🆕 关键修改:伪装成claude-cli请求以绕过客户端限制
|
||||
req.headers['user-agent'] = 'claude-cli/1.0.110 (external, cli, browser-fallback)'
|
||||
|
||||
// 确保设置正确的认证头
|
||||
if (!req.headers['authorization'] && req.headers['x-api-key']) {
|
||||
req.headers['authorization'] = `Bearer ${req.headers['x-api-key']}`
|
||||
}
|
||||
|
||||
// 添加必要的Anthropic头
|
||||
if (!req.headers['anthropic-version']) {
|
||||
req.headers['anthropic-version'] = '2023-06-01'
|
||||
}
|
||||
|
||||
if (!req.headers['anthropic-dangerous-direct-browser-access']) {
|
||||
req.headers['anthropic-dangerous-direct-browser-access'] = 'true'
|
||||
}
|
||||
|
||||
logger.api(
|
||||
`🔧 Browser fallback activated for ${isChromeExtension ? 'Chrome extension' : 'browser'} request`
|
||||
)
|
||||
logger.api(` Original User-Agent: "${req.originalUserAgent}"`)
|
||||
logger.api(` Origin: "${origin}"`)
|
||||
logger.api(` Modified User-Agent: "${req.headers['user-agent']}"`)
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
browserFallbackMiddleware
|
||||
}
|
||||
@@ -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)))
|
||||
@@ -1701,6 +1718,42 @@ class RedisClient {
|
||||
|
||||
const redisClient = new RedisClient()
|
||||
|
||||
// 分布式锁相关方法
|
||||
redisClient.setAccountLock = async function (lockKey, lockValue, ttlMs) {
|
||||
try {
|
||||
// 使用SET NX EX实现原子性的锁获取
|
||||
const result = await this.client.set(lockKey, lockValue, {
|
||||
NX: true, // 只在键不存在时设置
|
||||
PX: ttlMs // 毫秒级过期时间
|
||||
})
|
||||
return result === 'OK'
|
||||
} catch (error) {
|
||||
logger.error(`Failed to acquire lock ${lockKey}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
redisClient.releaseAccountLock = async function (lockKey, lockValue) {
|
||||
try {
|
||||
// 使用Lua脚本确保只有持有锁的进程才能释放锁
|
||||
const script = `
|
||||
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||
return redis.call("del", KEYS[1])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
`
|
||||
const result = await this.client.eval(script, {
|
||||
keys: [lockKey],
|
||||
arguments: [lockValue]
|
||||
})
|
||||
return result === 1
|
||||
} catch (error) {
|
||||
logger.error(`Failed to release lock ${lockKey}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 导出时区辅助函数
|
||||
redisClient.getDateInTimezone = getDateInTimezone
|
||||
redisClient.getDateStringInTimezone = getDateStringInTimezone
|
||||
|
||||
@@ -3,8 +3,10 @@ const apiKeyService = require('../services/apiKeyService')
|
||||
const claudeAccountService = require('../services/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
|
||||
const bedrockAccountService = require('../services/bedrockAccountService')
|
||||
const ccrAccountService = require('../services/ccrAccountService')
|
||||
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 +1948,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 +2383,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 {
|
||||
@@ -2497,9 +2499,9 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
||||
quotaResetTime: quotaResetTime || '00:00'
|
||||
})
|
||||
|
||||
// 如果是分组类型,将账户添加到分组
|
||||
// 如果是分组类型,将账户添加到分组(CCR 归属 Claude 平台分组)
|
||||
if (accountType === 'group' && groupId) {
|
||||
await accountGroupService.addAccountToGroup(newAccount.id, groupId)
|
||||
await accountGroupService.addAccountToGroup(newAccount.id, groupId, 'claude')
|
||||
}
|
||||
|
||||
logger.success(`🎮 Admin created Claude Console account: ${name}`)
|
||||
@@ -2740,6 +2742,382 @@ router.post('/claude-console-accounts/reset-all-usage', authenticateAdmin, async
|
||||
}
|
||||
})
|
||||
|
||||
// 🔧 CCR 账户管理
|
||||
|
||||
// 获取所有CCR账户
|
||||
router.get('/ccr-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { platform, groupId } = req.query
|
||||
let accounts = await ccrAccountService.getAllAccounts()
|
||||
|
||||
// 根据查询参数进行筛选
|
||||
if (platform && platform !== 'all' && platform !== 'ccr') {
|
||||
// 如果指定了其他平台,返回空数组
|
||||
accounts = []
|
||||
}
|
||||
|
||||
// 如果指定了分组筛选
|
||||
if (groupId && groupId !== 'all') {
|
||||
if (groupId === 'ungrouped') {
|
||||
// 筛选未分组账户
|
||||
const filteredAccounts = []
|
||||
for (const account of accounts) {
|
||||
const groups = await accountGroupService.getAccountGroups(account.id)
|
||||
if (!groups || groups.length === 0) {
|
||||
filteredAccounts.push(account)
|
||||
}
|
||||
}
|
||||
accounts = filteredAccounts
|
||||
} else {
|
||||
// 筛选特定分组的账户
|
||||
const groupMembers = await accountGroupService.getGroupMembers(groupId)
|
||||
accounts = accounts.filter((account) => groupMembers.includes(account.id))
|
||||
}
|
||||
}
|
||||
|
||||
// 为每个账户添加使用统计信息
|
||||
const accountsWithStats = await Promise.all(
|
||||
accounts.map(async (account) => {
|
||||
try {
|
||||
const usageStats = await redis.getAccountUsageStats(account.id)
|
||||
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||
|
||||
return {
|
||||
...account,
|
||||
// 转换schedulable为布尔值
|
||||
schedulable: account.schedulable === 'true' || account.schedulable === true,
|
||||
groupInfos,
|
||||
usage: {
|
||||
daily: usageStats.daily,
|
||||
total: usageStats.total,
|
||||
averages: usageStats.averages
|
||||
}
|
||||
}
|
||||
} catch (statsError) {
|
||||
logger.warn(
|
||||
`⚠️ Failed to get usage stats for CCR account ${account.id}:`,
|
||||
statsError.message
|
||||
)
|
||||
try {
|
||||
const groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||
return {
|
||||
...account,
|
||||
// 转换schedulable为布尔值
|
||||
schedulable: account.schedulable === 'true' || account.schedulable === true,
|
||||
groupInfos,
|
||||
usage: {
|
||||
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
averages: { rpm: 0, tpm: 0 }
|
||||
}
|
||||
}
|
||||
} catch (groupError) {
|
||||
logger.warn(
|
||||
`⚠️ Failed to get group info for CCR account ${account.id}:`,
|
||||
groupError.message
|
||||
)
|
||||
return {
|
||||
...account,
|
||||
groupInfos: [],
|
||||
usage: {
|
||||
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||
averages: { rpm: 0, tpm: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return res.json({ success: true, data: accountsWithStats })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get CCR accounts:', error)
|
||||
return res.status(500).json({ error: 'Failed to get CCR accounts', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 创建新的CCR账户
|
||||
router.post('/ccr-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
apiUrl,
|
||||
apiKey,
|
||||
priority,
|
||||
supportedModels,
|
||||
userAgent,
|
||||
rateLimitDuration,
|
||||
proxy,
|
||||
accountType,
|
||||
groupId,
|
||||
dailyQuota,
|
||||
quotaResetTime
|
||||
} = req.body
|
||||
|
||||
if (!name || !apiUrl || !apiKey) {
|
||||
return res.status(400).json({ error: 'Name, API URL and API Key are required' })
|
||||
}
|
||||
|
||||
// 验证priority的有效性(1-100)
|
||||
if (priority !== undefined && (priority < 1 || priority > 100)) {
|
||||
return res.status(400).json({ error: 'Priority must be between 1 and 100' })
|
||||
}
|
||||
|
||||
// 验证accountType的有效性
|
||||
if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' })
|
||||
}
|
||||
|
||||
// 如果是分组类型,验证groupId
|
||||
if (accountType === 'group' && !groupId) {
|
||||
return res.status(400).json({ error: 'Group ID is required for group type accounts' })
|
||||
}
|
||||
|
||||
const newAccount = await ccrAccountService.createAccount({
|
||||
name,
|
||||
description,
|
||||
apiUrl,
|
||||
apiKey,
|
||||
priority: priority || 50,
|
||||
supportedModels: supportedModels || [],
|
||||
userAgent,
|
||||
rateLimitDuration:
|
||||
rateLimitDuration !== undefined && rateLimitDuration !== null ? rateLimitDuration : 60,
|
||||
proxy,
|
||||
accountType: accountType || 'shared',
|
||||
dailyQuota: dailyQuota || 0,
|
||||
quotaResetTime: quotaResetTime || '00:00'
|
||||
})
|
||||
|
||||
// 如果是分组类型,将账户添加到分组
|
||||
if (accountType === 'group' && groupId) {
|
||||
await accountGroupService.addAccountToGroup(newAccount.id, groupId)
|
||||
}
|
||||
|
||||
logger.success(`🔧 Admin created CCR account: ${name}`)
|
||||
return res.json({ success: true, data: newAccount })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to create CCR account:', error)
|
||||
return res.status(500).json({ error: 'Failed to create CCR account', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 更新CCR账户
|
||||
router.put('/ccr-accounts/:accountId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const updates = req.body
|
||||
|
||||
// 验证priority的有效性(1-100)
|
||||
if (updates.priority !== undefined && (updates.priority < 1 || updates.priority > 100)) {
|
||||
return res.status(400).json({ error: 'Priority must be between 1 and 100' })
|
||||
}
|
||||
|
||||
// 验证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 ccrAccountService.getAccount(accountId)
|
||||
if (!currentAccount) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
// 处理分组的变更
|
||||
if (updates.accountType !== undefined) {
|
||||
// 如果之前是分组类型,需要从所有分组中移除
|
||||
if (currentAccount.accountType === 'group') {
|
||||
const oldGroups = await accountGroupService.getAccountGroups(accountId)
|
||||
for (const oldGroup of oldGroups) {
|
||||
await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id)
|
||||
}
|
||||
}
|
||||
// 如果新类型是分组,处理多分组支持
|
||||
if (updates.accountType === 'group') {
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'groupIds')) {
|
||||
// 如果明确提供了 groupIds 参数(包括空数组)
|
||||
if (updates.groupIds && updates.groupIds.length > 0) {
|
||||
// 设置新的多分组
|
||||
await accountGroupService.setAccountGroups(accountId, updates.groupIds, 'claude')
|
||||
} else {
|
||||
// groupIds 为空数组,从所有分组中移除
|
||||
await accountGroupService.removeAccountFromAllGroups(accountId)
|
||||
}
|
||||
} else if (updates.groupId) {
|
||||
// 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑
|
||||
await accountGroupService.addAccountToGroup(accountId, updates.groupId, 'claude')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await ccrAccountService.updateAccount(accountId, updates)
|
||||
|
||||
logger.success(`📝 Admin updated CCR account: ${accountId}`)
|
||||
return res.json({ success: true, message: 'CCR account updated successfully' })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to update CCR account:', error)
|
||||
return res.status(500).json({ error: 'Failed to update CCR account', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 删除CCR账户
|
||||
router.delete('/ccr-accounts/:accountId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
|
||||
// 获取账户信息以检查是否在分组中
|
||||
const account = await ccrAccountService.getAccount(accountId)
|
||||
if (account && account.accountType === 'group') {
|
||||
const groups = await accountGroupService.getAccountGroups(accountId)
|
||||
for (const group of groups) {
|
||||
await accountGroupService.removeAccountFromGroup(accountId, group.id)
|
||||
}
|
||||
}
|
||||
|
||||
await ccrAccountService.deleteAccount(accountId)
|
||||
|
||||
logger.success(`🗑️ Admin deleted CCR account: ${accountId}`)
|
||||
return res.json({ success: true, message: 'CCR account deleted successfully' })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to delete CCR account:', error)
|
||||
return res.status(500).json({ error: 'Failed to delete CCR account', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 切换CCR账户状态
|
||||
router.put('/ccr-accounts/:accountId/toggle', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
|
||||
const account = await ccrAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
const newStatus = !account.isActive
|
||||
await ccrAccountService.updateAccount(accountId, { isActive: newStatus })
|
||||
|
||||
logger.success(
|
||||
`🔄 Admin toggled CCR account status: ${accountId} -> ${newStatus ? 'active' : 'inactive'}`
|
||||
)
|
||||
return res.json({ success: true, isActive: newStatus })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to toggle CCR account status:', error)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to toggle account status', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 切换CCR账户调度状态
|
||||
router.put('/ccr-accounts/:accountId/toggle-schedulable', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
|
||||
const account = await ccrAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
const newSchedulable = !account.schedulable
|
||||
await ccrAccountService.updateAccount(accountId, { schedulable: newSchedulable })
|
||||
|
||||
// 如果账号被禁用,发送webhook通知
|
||||
if (!newSchedulable) {
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId: account.id,
|
||||
accountName: account.name || 'CCR Account',
|
||||
platform: 'ccr',
|
||||
status: 'disabled',
|
||||
errorCode: 'CCR_MANUALLY_DISABLED',
|
||||
reason: '账号已被管理员手动禁用调度',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`🔄 Admin toggled CCR account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}`
|
||||
)
|
||||
return res.json({ success: true, schedulable: newSchedulable })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to toggle CCR account schedulable status:', error)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to toggle schedulable status', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 获取CCR账户的使用统计
|
||||
router.get('/ccr-accounts/:accountId/usage', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const usageStats = await ccrAccountService.getAccountUsageStats(accountId)
|
||||
|
||||
if (!usageStats) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
|
||||
return res.json(usageStats)
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get CCR account usage stats:', error)
|
||||
return res.status(500).json({ error: 'Failed to get usage stats', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 手动重置CCR账户的每日使用量
|
||||
router.post('/ccr-accounts/:accountId/reset-usage', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
await ccrAccountService.resetDailyUsage(accountId)
|
||||
|
||||
logger.success(`✅ Admin manually reset daily usage for CCR account: ${accountId}`)
|
||||
return res.json({ success: true, message: 'Daily usage reset successfully' })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset CCR account daily usage:', error)
|
||||
return res.status(500).json({ error: 'Failed to reset daily usage', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 重置CCR账户状态(清除所有异常状态)
|
||||
router.post('/ccr-accounts/:accountId/reset-status', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const result = await ccrAccountService.resetAccountStatus(accountId)
|
||||
logger.success(`✅ Admin reset status for CCR account: ${accountId}`)
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset CCR account status:', error)
|
||||
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 手动重置所有CCR账户的每日使用量
|
||||
router.post('/ccr-accounts/reset-all-usage', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
await ccrAccountService.resetAllDailyUsage()
|
||||
|
||||
logger.success('✅ Admin manually reset daily usage for all CCR accounts')
|
||||
return res.json({ success: true, message: 'All daily usage reset successfully' })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset all CCR accounts daily usage:', error)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to reset all daily usage', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// ☁️ Bedrock 账户管理
|
||||
|
||||
// 获取所有Bedrock账户
|
||||
@@ -2784,7 +3162,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 +3612,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 {
|
||||
@@ -3565,6 +3943,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
geminiAccounts,
|
||||
bedrockAccountsResult,
|
||||
openaiAccounts,
|
||||
ccrAccounts,
|
||||
todayStats,
|
||||
systemAverages,
|
||||
realtimeMetrics
|
||||
@@ -3575,6 +3954,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
claudeConsoleAccountService.getAllAccounts(),
|
||||
geminiAccountService.getAllAccounts(),
|
||||
bedrockAccountService.getAllAccounts(),
|
||||
ccrAccountService.getAllAccounts(),
|
||||
redis.getAllOpenAIAccounts(),
|
||||
redis.getTodayStats(),
|
||||
redis.getSystemAverages(),
|
||||
@@ -3746,6 +4126,29 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||
).length
|
||||
|
||||
// CCR账户统计
|
||||
const normalCcrAccounts = ccrAccounts.filter(
|
||||
(acc) =>
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized' &&
|
||||
acc.schedulable !== false &&
|
||||
!(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
|
||||
).length
|
||||
const abnormalCcrAccounts = ccrAccounts.filter(
|
||||
(acc) => !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
).length
|
||||
const pausedCcrAccounts = ccrAccounts.filter(
|
||||
(acc) =>
|
||||
acc.schedulable === false &&
|
||||
acc.isActive &&
|
||||
acc.status !== 'blocked' &&
|
||||
acc.status !== 'unauthorized'
|
||||
).length
|
||||
const rateLimitedCcrAccounts = ccrAccounts.filter(
|
||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||
).length
|
||||
|
||||
const dashboard = {
|
||||
overview: {
|
||||
totalApiKeys: apiKeys.length,
|
||||
@@ -3756,31 +4159,36 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
claudeConsoleAccounts.length +
|
||||
geminiAccounts.length +
|
||||
bedrockAccounts.length +
|
||||
openaiAccounts.length,
|
||||
openaiAccounts.length +
|
||||
ccrAccounts.length,
|
||||
normalAccounts:
|
||||
normalClaudeAccounts +
|
||||
normalClaudeConsoleAccounts +
|
||||
normalGeminiAccounts +
|
||||
normalBedrockAccounts +
|
||||
normalOpenAIAccounts,
|
||||
normalOpenAIAccounts +
|
||||
normalCcrAccounts,
|
||||
abnormalAccounts:
|
||||
abnormalClaudeAccounts +
|
||||
abnormalClaudeConsoleAccounts +
|
||||
abnormalGeminiAccounts +
|
||||
abnormalBedrockAccounts +
|
||||
abnormalOpenAIAccounts,
|
||||
abnormalOpenAIAccounts +
|
||||
abnormalCcrAccounts,
|
||||
pausedAccounts:
|
||||
pausedClaudeAccounts +
|
||||
pausedClaudeConsoleAccounts +
|
||||
pausedGeminiAccounts +
|
||||
pausedBedrockAccounts +
|
||||
pausedOpenAIAccounts,
|
||||
pausedOpenAIAccounts +
|
||||
pausedCcrAccounts,
|
||||
rateLimitedAccounts:
|
||||
rateLimitedClaudeAccounts +
|
||||
rateLimitedClaudeConsoleAccounts +
|
||||
rateLimitedGeminiAccounts +
|
||||
rateLimitedBedrockAccounts +
|
||||
rateLimitedOpenAIAccounts,
|
||||
rateLimitedOpenAIAccounts +
|
||||
rateLimitedCcrAccounts,
|
||||
// 各平台详细统计
|
||||
accountsByPlatform: {
|
||||
claude: {
|
||||
@@ -3817,6 +4225,13 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
abnormal: abnormalOpenAIAccounts,
|
||||
paused: pausedOpenAIAccounts,
|
||||
rateLimited: rateLimitedOpenAIAccounts
|
||||
},
|
||||
ccr: {
|
||||
total: ccrAccounts.length,
|
||||
normal: normalCcrAccounts,
|
||||
abnormal: abnormalCcrAccounts,
|
||||
paused: pausedCcrAccounts,
|
||||
rateLimited: rateLimitedCcrAccounts
|
||||
}
|
||||
},
|
||||
// 保留旧字段以兼容
|
||||
@@ -3825,7 +4240,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
normalClaudeConsoleAccounts +
|
||||
normalGeminiAccounts +
|
||||
normalBedrockAccounts +
|
||||
normalOpenAIAccounts,
|
||||
normalOpenAIAccounts +
|
||||
normalCcrAccounts,
|
||||
totalClaudeAccounts: claudeAccounts.length + claudeConsoleAccounts.length,
|
||||
activeClaudeAccounts: normalClaudeAccounts + normalClaudeConsoleAccounts,
|
||||
rateLimitedClaudeAccounts: rateLimitedClaudeAccounts + rateLimitedClaudeConsoleAccounts,
|
||||
@@ -5762,7 +6178,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 +6725,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 +7125,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
|
||||
|
||||
@@ -2,6 +2,7 @@ const express = require('express')
|
||||
const claudeRelayService = require('../services/claudeRelayService')
|
||||
const claudeConsoleRelayService = require('../services/claudeConsoleRelayService')
|
||||
const bedrockRelayService = require('../services/bedrockRelayService')
|
||||
const ccrRelayService = require('../services/ccrRelayService')
|
||||
const bedrockAccountService = require('../services/bedrockAccountService')
|
||||
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
@@ -9,6 +10,7 @@ const pricingService = require('../services/pricingService')
|
||||
const { authenticateApiKey } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
const redis = require('../models/redis')
|
||||
const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper')
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
|
||||
const router = express.Router()
|
||||
@@ -40,6 +42,23 @@ async function handleMessagesRequest(req, res) {
|
||||
})
|
||||
}
|
||||
|
||||
// 模型限制(黑名单)校验:统一在此处处理(去除供应商前缀)
|
||||
if (
|
||||
req.apiKey.enableModelRestriction &&
|
||||
Array.isArray(req.apiKey.restrictedModels) &&
|
||||
req.apiKey.restrictedModels.length > 0
|
||||
) {
|
||||
const effectiveModel = getEffectiveModel(req.body.model || '')
|
||||
if (req.apiKey.restrictedModels.includes(effectiveModel)) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
type: 'forbidden',
|
||||
message: '暂无该模型访问权限'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否为流式请求
|
||||
const isStream = req.body.stream === true
|
||||
|
||||
@@ -354,6 +373,110 @@ async function handleMessagesRequest(req, res) {
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
} else if (accountType === 'ccr') {
|
||||
// CCR账号使用CCR转发服务(需要传递accountId)
|
||||
await ccrRelayService.relayStreamRequestWithUsageCapture(
|
||||
req.body,
|
||||
req.apiKey,
|
||||
res,
|
||||
req.headers,
|
||||
(usageData) => {
|
||||
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
||||
logger.info(
|
||||
'🎯 CCR usage callback triggered with complete data:',
|
||||
JSON.stringify(usageData, null, 2)
|
||||
)
|
||||
|
||||
if (
|
||||
usageData &&
|
||||
usageData.input_tokens !== undefined &&
|
||||
usageData.output_tokens !== undefined
|
||||
) {
|
||||
const inputTokens = usageData.input_tokens || 0
|
||||
const outputTokens = usageData.output_tokens || 0
|
||||
// 兼容处理:如果有详细的 cache_creation 对象,使用它;否则使用总的 cache_creation_input_tokens
|
||||
let cacheCreateTokens = usageData.cache_creation_input_tokens || 0
|
||||
let ephemeral5mTokens = 0
|
||||
let ephemeral1hTokens = 0
|
||||
|
||||
if (usageData.cache_creation && typeof usageData.cache_creation === 'object') {
|
||||
ephemeral5mTokens = usageData.cache_creation.ephemeral_5m_input_tokens || 0
|
||||
ephemeral1hTokens = usageData.cache_creation.ephemeral_1h_input_tokens || 0
|
||||
// 总的缓存创建 tokens 是两者之和
|
||||
cacheCreateTokens = ephemeral5mTokens + ephemeral1hTokens
|
||||
}
|
||||
|
||||
const cacheReadTokens = usageData.cache_read_input_tokens || 0
|
||||
const model = usageData.model || 'unknown'
|
||||
|
||||
// 记录真实的token使用量(包含模型信息和所有4种token以及账户ID)
|
||||
const usageAccountId = usageData.accountId
|
||||
|
||||
// 构建 usage 对象以传递给 recordUsage
|
||||
const usageObject = {
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
cache_creation_input_tokens: cacheCreateTokens,
|
||||
cache_read_input_tokens: cacheReadTokens
|
||||
}
|
||||
|
||||
// 如果有详细的缓存创建数据,添加到 usage 对象中
|
||||
if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) {
|
||||
usageObject.cache_creation = {
|
||||
ephemeral_5m_input_tokens: ephemeral5mTokens,
|
||||
ephemeral_1h_input_tokens: ephemeral1hTokens
|
||||
}
|
||||
}
|
||||
|
||||
apiKeyService
|
||||
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'ccr')
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record CCR stream usage:', error)
|
||||
})
|
||||
|
||||
// 更新时间窗口内的token计数和费用
|
||||
if (req.rateLimitInfo) {
|
||||
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
||||
|
||||
// 更新Token计数(向后兼容)
|
||||
redis
|
||||
.getClient()
|
||||
.incrby(req.rateLimitInfo.tokenCountKey, totalTokens)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to update rate limit token count:', error)
|
||||
})
|
||||
logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`)
|
||||
|
||||
// 计算并更新费用计数(新功能)
|
||||
if (req.rateLimitInfo.costCountKey) {
|
||||
const costInfo = pricingService.calculateCost(usageData, model)
|
||||
if (costInfo.totalCost > 0) {
|
||||
redis
|
||||
.getClient()
|
||||
.incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to update rate limit cost count:', error)
|
||||
})
|
||||
logger.api(
|
||||
`💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
usageDataCaptured = true
|
||||
logger.api(
|
||||
`📊 CCR stream usage recorded (real) - Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Cache Create: ${cacheCreateTokens}, Cache Read: ${cacheReadTokens}, Total: ${inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens} tokens`
|
||||
)
|
||||
} else {
|
||||
logger.warn(
|
||||
'⚠️ CCR usage callback triggered but data is incomplete:',
|
||||
JSON.stringify(usageData)
|
||||
)
|
||||
}
|
||||
},
|
||||
accountId
|
||||
)
|
||||
}
|
||||
|
||||
// 流式请求完成后 - 如果没有捕获到usage数据,记录警告但不进行估算
|
||||
@@ -447,6 +570,17 @@ async function handleMessagesRequest(req, res) {
|
||||
accountId
|
||||
}
|
||||
}
|
||||
} else if (accountType === 'ccr') {
|
||||
// CCR账号使用CCR转发服务
|
||||
logger.debug(`[DEBUG] Calling ccrRelayService.relayRequest with accountId: ${accountId}`)
|
||||
response = await ccrRelayService.relayRequest(
|
||||
req.body,
|
||||
req.apiKey,
|
||||
req,
|
||||
res,
|
||||
req.headers,
|
||||
accountId
|
||||
)
|
||||
}
|
||||
|
||||
logger.info('📡 Claude API response received', {
|
||||
@@ -483,7 +617,10 @@ async function handleMessagesRequest(req, res) {
|
||||
const outputTokens = jsonData.usage.output_tokens || 0
|
||||
const cacheCreateTokens = jsonData.usage.cache_creation_input_tokens || 0
|
||||
const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0
|
||||
const model = jsonData.model || req.body.model || 'unknown'
|
||||
// Parse the model to remove vendor prefix if present (e.g., "ccr,gemini-2.5-pro" -> "gemini-2.5-pro")
|
||||
const rawModel = jsonData.model || req.body.model || 'unknown'
|
||||
const { baseModel } = parseVendorPrefixedModel(rawModel)
|
||||
const model = baseModel || rawModel
|
||||
|
||||
// 记录真实的token使用量(包含模型信息和所有4种token以及账户ID)
|
||||
const { accountId: responseAccountId } = response
|
||||
@@ -801,6 +938,14 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
|
||||
customPath: '/v1/messages/count_tokens' // 指定count_tokens路径
|
||||
}
|
||||
)
|
||||
} else if (accountType === 'ccr') {
|
||||
// CCR不支持count_tokens
|
||||
return res.status(501).json({
|
||||
error: {
|
||||
type: 'not_supported',
|
||||
message: 'Token counting is not supported for CCR accounts'
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Bedrock不支持count_tokens
|
||||
return res.status(501).json({
|
||||
|
||||
@@ -336,15 +336,15 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
const responseData = {
|
||||
id: keyId,
|
||||
name: fullKeyData.name,
|
||||
description: keyData.description || '',
|
||||
description: fullKeyData.description || keyData.description || '',
|
||||
isActive: true, // 如果能通过validateApiKey验证,说明一定是激活的
|
||||
createdAt: keyData.createdAt,
|
||||
expiresAt: keyData.expiresAt,
|
||||
createdAt: fullKeyData.createdAt || keyData.createdAt,
|
||||
expiresAt: fullKeyData.expiresAt || keyData.expiresAt,
|
||||
// 添加激活相关字段
|
||||
expirationMode: keyData.expirationMode || 'fixed',
|
||||
isActivated: keyData.isActivated === 'true',
|
||||
activationDays: parseInt(keyData.activationDays || 0),
|
||||
activatedAt: keyData.activatedAt || null,
|
||||
expirationMode: fullKeyData.expirationMode || 'fixed',
|
||||
isActivated: fullKeyData.isActivated === true || fullKeyData.isActivated === 'true',
|
||||
activationDays: parseInt(fullKeyData.activationDays || 0),
|
||||
activatedAt: fullKeyData.activatedAt || null,
|
||||
permissions: fullKeyData.permissions,
|
||||
|
||||
// 使用统计(使用验证结果中的完整数据)
|
||||
|
||||
@@ -961,4 +961,10 @@ router.post(
|
||||
handleStreamGenerateContent
|
||||
)
|
||||
|
||||
// 导出处理函数供标准路由使用
|
||||
module.exports = router
|
||||
module.exports.handleLoadCodeAssist = handleLoadCodeAssist
|
||||
module.exports.handleOnboardUser = handleOnboardUser
|
||||
module.exports.handleCountTokens = handleCountTokens
|
||||
module.exports.handleGenerateContent = handleGenerateContent
|
||||
module.exports.handleStreamGenerateContent = handleStreamGenerateContent
|
||||
|
||||
@@ -6,6 +6,8 @@ const config = require('../../config/config')
|
||||
const { authenticateApiKey } = require('../middleware/auth')
|
||||
const unifiedOpenAIScheduler = require('../services/unifiedOpenAIScheduler')
|
||||
const openaiAccountService = require('../services/openaiAccountService')
|
||||
const openaiResponsesAccountService = require('../services/openaiResponsesAccountService')
|
||||
const openaiResponsesRelayService = require('../services/openaiResponsesRelayService')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const crypto = require('crypto')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
@@ -34,51 +36,81 @@ async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel =
|
||||
throw new Error('No available OpenAI account found')
|
||||
}
|
||||
|
||||
// 获取账户详情
|
||||
let account = await openaiAccountService.getAccount(result.accountId)
|
||||
if (!account || !account.accessToken) {
|
||||
throw new Error(`OpenAI account ${result.accountId} has no valid accessToken`)
|
||||
}
|
||||
// 根据账户类型获取账户详情
|
||||
let account,
|
||||
accessToken,
|
||||
proxy = null
|
||||
|
||||
// 检查 token 是否过期并自动刷新(双重保护)
|
||||
if (openaiAccountService.isTokenExpired(account)) {
|
||||
if (account.refreshToken) {
|
||||
logger.info(`🔄 Token expired, auto-refreshing for account ${account.name} (fallback)`)
|
||||
if (result.accountType === 'openai-responses') {
|
||||
// 处理 OpenAI-Responses 账户
|
||||
account = await openaiResponsesAccountService.getAccount(result.accountId)
|
||||
if (!account || !account.apiKey) {
|
||||
throw new Error(`OpenAI-Responses account ${result.accountId} has no valid apiKey`)
|
||||
}
|
||||
|
||||
// OpenAI-Responses 账户不需要 accessToken,直接返回账户信息
|
||||
accessToken = null // OpenAI-Responses 使用账户内的 apiKey
|
||||
|
||||
// 解析代理配置
|
||||
if (account.proxy) {
|
||||
try {
|
||||
await openaiAccountService.refreshAccountToken(result.accountId)
|
||||
// 重新获取更新后的账户
|
||||
account = await openaiAccountService.getAccount(result.accountId)
|
||||
logger.info(`✅ Token refreshed successfully in route handler`)
|
||||
} catch (refreshError) {
|
||||
logger.error(`Failed to refresh token for ${account.name}:`, refreshError)
|
||||
throw new Error(`Token expired and refresh failed: ${refreshError.message}`)
|
||||
proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse proxy configuration:', e)
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Token expired and no refresh token available for account ${account.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 解密 accessToken(account.accessToken 是加密的)
|
||||
const accessToken = openaiAccountService.decrypt(account.accessToken)
|
||||
if (!accessToken) {
|
||||
throw new Error('Failed to decrypt OpenAI accessToken')
|
||||
}
|
||||
|
||||
// 解析代理配置
|
||||
let proxy = null
|
||||
if (account.proxy) {
|
||||
try {
|
||||
proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse proxy configuration:', e)
|
||||
logger.info(`Selected OpenAI-Responses account: ${account.name} (${result.accountId})`)
|
||||
} else {
|
||||
// 处理普通 OpenAI 账户
|
||||
account = await openaiAccountService.getAccount(result.accountId)
|
||||
if (!account || !account.accessToken) {
|
||||
throw new Error(`OpenAI account ${result.accountId} has no valid accessToken`)
|
||||
}
|
||||
|
||||
// 检查 token 是否过期并自动刷新(双重保护)
|
||||
if (openaiAccountService.isTokenExpired(account)) {
|
||||
if (account.refreshToken) {
|
||||
logger.info(`🔄 Token expired, auto-refreshing for account ${account.name} (fallback)`)
|
||||
try {
|
||||
await openaiAccountService.refreshAccountToken(result.accountId)
|
||||
// 重新获取更新后的账户
|
||||
account = await openaiAccountService.getAccount(result.accountId)
|
||||
logger.info(`✅ Token refreshed successfully in route handler`)
|
||||
} catch (refreshError) {
|
||||
logger.error(`Failed to refresh token for ${account.name}:`, refreshError)
|
||||
throw new Error(`Token expired and refresh failed: ${refreshError.message}`)
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`Token expired and no refresh token available for account ${account.name}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 解密 accessToken(account.accessToken 是加密的)
|
||||
accessToken = openaiAccountService.decrypt(account.accessToken)
|
||||
if (!accessToken) {
|
||||
throw new Error('Failed to decrypt OpenAI accessToken')
|
||||
}
|
||||
|
||||
// 解析代理配置
|
||||
if (account.proxy) {
|
||||
try {
|
||||
proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse proxy configuration:', e)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Selected OpenAI account: ${account.name} (${result.accountId})`)
|
||||
}
|
||||
|
||||
logger.info(`Selected OpenAI account: ${account.name} (${result.accountId})`)
|
||||
return {
|
||||
accessToken,
|
||||
accountId: result.accountId,
|
||||
accountName: account.name,
|
||||
accountType: result.accountType,
|
||||
proxy,
|
||||
account
|
||||
}
|
||||
@@ -151,9 +183,16 @@ const handleResponses = async (req, res) => {
|
||||
accessToken,
|
||||
accountId,
|
||||
accountName: _accountName,
|
||||
accountType,
|
||||
proxy,
|
||||
account
|
||||
} = await getOpenAIAuthToken(apiKeyData, sessionId, requestedModel)
|
||||
|
||||
// 如果是 OpenAI-Responses 账户,使用专门的中继服务处理
|
||||
if (accountType === 'openai-responses') {
|
||||
logger.info(`🔀 Using OpenAI-Responses relay service for account: ${account.name}`)
|
||||
return await openaiResponsesRelayService.handleRequest(req, res, account, apiKeyData)
|
||||
}
|
||||
// 基于白名单构造上游所需的请求头,确保键为小写且值受控
|
||||
const incoming = req.headers || {}
|
||||
|
||||
|
||||
638
src/routes/standardGeminiRoutes.js
Normal file
638
src/routes/standardGeminiRoutes.js
Normal file
@@ -0,0 +1,638 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { authenticateApiKey } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
const geminiAccountService = require('../services/geminiAccountService')
|
||||
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
||||
const apiKeyService = require('../services/apiKeyService')
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
|
||||
// 导入 geminiRoutes 中导出的处理函数
|
||||
const { handleLoadCodeAssist, handleOnboardUser, handleCountTokens } = require('./geminiRoutes')
|
||||
|
||||
// 标准 Gemini API 路由处理器
|
||||
// 这些路由将挂载在 /gemini 路径下,处理标准 Gemini API 格式的请求
|
||||
// 标准格式: /gemini/v1beta/models/{model}:generateContent
|
||||
|
||||
// 专门处理标准 Gemini API 格式的 generateContent
|
||||
async function handleStandardGenerateContent(req, res) {
|
||||
try {
|
||||
// 从路径参数中获取模型名
|
||||
const model = req.params.modelName || 'gemini-2.0-flash-exp'
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
// 标准 Gemini API 请求体直接包含 contents 等字段
|
||||
const { contents, generationConfig, safetySettings, systemInstruction } = req.body
|
||||
|
||||
// 验证必需参数
|
||||
if (!contents || !Array.isArray(contents) || contents.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Contents array is required',
|
||||
type: 'invalid_request_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 构建内部 API 需要的请求格式
|
||||
const actualRequestData = {
|
||||
contents,
|
||||
generationConfig: generationConfig || {
|
||||
temperature: 0.7,
|
||||
maxOutputTokens: 4096,
|
||||
topP: 0.95,
|
||||
topK: 40
|
||||
}
|
||||
}
|
||||
|
||||
// 只有在 safetySettings 存在且非空时才添加
|
||||
if (safetySettings && safetySettings.length > 0) {
|
||||
actualRequestData.safetySettings = safetySettings
|
||||
}
|
||||
|
||||
// 如果有 system instruction,修正格式并添加到请求体
|
||||
// Gemini CLI 的内部 API 需要 role: "user" 字段
|
||||
if (systemInstruction) {
|
||||
// 确保 systemInstruction 格式正确
|
||||
if (typeof systemInstruction === 'string' && systemInstruction.trim()) {
|
||||
actualRequestData.systemInstruction = {
|
||||
role: 'user', // Gemini CLI 内部 API 需要这个字段
|
||||
parts: [{ text: systemInstruction }]
|
||||
}
|
||||
} else if (systemInstruction.parts && systemInstruction.parts.length > 0) {
|
||||
// 检查是否有实际内容
|
||||
const hasContent = systemInstruction.parts.some(
|
||||
(part) => part.text && part.text.trim() !== ''
|
||||
)
|
||||
if (hasContent) {
|
||||
// 添加 role 字段(Gemini CLI 格式)
|
||||
actualRequestData.systemInstruction = {
|
||||
role: 'user', // Gemini CLI 内部 API 需要这个字段
|
||||
parts: systemInstruction.parts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用统一调度选择账号
|
||||
const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||
req.apiKey,
|
||||
sessionHash,
|
||||
model
|
||||
)
|
||||
const account = await geminiAccountService.getAccount(accountId)
|
||||
const { accessToken, refreshToken } = account
|
||||
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1'
|
||||
logger.info(`Standard Gemini API generateContent request (${version})`, {
|
||||
model,
|
||||
projectId: account.projectId,
|
||||
apiKeyId: req.apiKey?.id || 'unknown'
|
||||
})
|
||||
|
||||
// 解析账户的代理配置
|
||||
let proxyConfig = null
|
||||
if (account.proxy) {
|
||||
try {
|
||||
proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse proxy configuration:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||
|
||||
// 使用账户的项目ID(如果有的话)
|
||||
const effectiveProjectId = account.projectId || null
|
||||
|
||||
logger.info('📋 Standard API 项目ID处理逻辑', {
|
||||
accountProjectId: account.projectId,
|
||||
effectiveProjectId,
|
||||
decision: account.projectId ? '使用账户配置' : '不使用项目ID'
|
||||
})
|
||||
|
||||
// 生成一个符合 Gemini CLI 格式的 user_prompt_id
|
||||
const userPromptId = `${require('crypto').randomUUID()}########0`
|
||||
|
||||
// 调用内部 API(cloudcode-pa)
|
||||
const response = await geminiAccountService.generateContent(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
userPromptId, // 使用生成的 user_prompt_id
|
||||
effectiveProjectId || 'oceanic-graph-cgcz4', // 如果没有项目ID,使用默认值
|
||||
req.apiKey?.id, // 使用 API Key ID 作为 session ID
|
||||
proxyConfig
|
||||
)
|
||||
|
||||
// 记录使用统计
|
||||
if (response?.response?.usageMetadata) {
|
||||
try {
|
||||
const usage = response.response.usageMetadata
|
||||
await apiKeyService.recordUsage(
|
||||
req.apiKey.id,
|
||||
usage.promptTokenCount || 0,
|
||||
usage.candidatesTokenCount || 0,
|
||||
0, // cacheCreateTokens
|
||||
0, // cacheReadTokens
|
||||
model,
|
||||
account.id
|
||||
)
|
||||
logger.info(
|
||||
`📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to record Gemini usage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 返回标准 Gemini API 格式的响应
|
||||
// 内部 API 返回的是 { response: {...} } 格式,需要提取并过滤
|
||||
if (response.response) {
|
||||
// 过滤掉 thought 部分(这是内部 API 特有的)
|
||||
const standardResponse = { ...response.response }
|
||||
if (standardResponse.candidates) {
|
||||
standardResponse.candidates = standardResponse.candidates.map((candidate) => {
|
||||
if (candidate.content && candidate.content.parts) {
|
||||
// 过滤掉 thought: true 的 parts
|
||||
const filteredParts = candidate.content.parts.filter((part) => !part.thought)
|
||||
return {
|
||||
...candidate,
|
||||
content: {
|
||||
...candidate.content,
|
||||
parts: filteredParts
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidate
|
||||
})
|
||||
}
|
||||
res.json(standardResponse)
|
||||
} else {
|
||||
res.json(response)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error in standard generateContent endpoint`, {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
responseData: error.response?.data,
|
||||
stack: error.stack
|
||||
})
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Internal server error',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 专门处理标准 Gemini API 格式的 streamGenerateContent
|
||||
async function handleStandardStreamGenerateContent(req, res) {
|
||||
let abortController = null
|
||||
|
||||
try {
|
||||
// 从路径参数中获取模型名
|
||||
const model = req.params.modelName || 'gemini-2.0-flash-exp'
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
// 标准 Gemini API 请求体直接包含 contents 等字段
|
||||
const { contents, generationConfig, safetySettings, systemInstruction } = req.body
|
||||
|
||||
// 验证必需参数
|
||||
if (!contents || !Array.isArray(contents) || contents.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Contents array is required',
|
||||
type: 'invalid_request_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 构建内部 API 需要的请求格式
|
||||
const actualRequestData = {
|
||||
contents,
|
||||
generationConfig: generationConfig || {
|
||||
temperature: 0.7,
|
||||
maxOutputTokens: 4096,
|
||||
topP: 0.95,
|
||||
topK: 40
|
||||
}
|
||||
}
|
||||
|
||||
// 只有在 safetySettings 存在且非空时才添加
|
||||
if (safetySettings && safetySettings.length > 0) {
|
||||
actualRequestData.safetySettings = safetySettings
|
||||
}
|
||||
|
||||
// 如果有 system instruction,修正格式并添加到请求体
|
||||
// Gemini CLI 的内部 API 需要 role: "user" 字段
|
||||
if (systemInstruction) {
|
||||
// 确保 systemInstruction 格式正确
|
||||
if (typeof systemInstruction === 'string' && systemInstruction.trim()) {
|
||||
actualRequestData.systemInstruction = {
|
||||
role: 'user', // Gemini CLI 内部 API 需要这个字段
|
||||
parts: [{ text: systemInstruction }]
|
||||
}
|
||||
} else if (systemInstruction.parts && systemInstruction.parts.length > 0) {
|
||||
// 检查是否有实际内容
|
||||
const hasContent = systemInstruction.parts.some(
|
||||
(part) => part.text && part.text.trim() !== ''
|
||||
)
|
||||
if (hasContent) {
|
||||
// 添加 role 字段(Gemini CLI 格式)
|
||||
actualRequestData.systemInstruction = {
|
||||
role: 'user', // Gemini CLI 内部 API 需要这个字段
|
||||
parts: systemInstruction.parts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用统一调度选择账号
|
||||
const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||
req.apiKey,
|
||||
sessionHash,
|
||||
model
|
||||
)
|
||||
const account = await geminiAccountService.getAccount(accountId)
|
||||
const { accessToken, refreshToken } = account
|
||||
|
||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1'
|
||||
logger.info(`Standard Gemini API streamGenerateContent request (${version})`, {
|
||||
model,
|
||||
projectId: account.projectId,
|
||||
apiKeyId: req.apiKey?.id || 'unknown'
|
||||
})
|
||||
|
||||
// 创建中止控制器
|
||||
abortController = new AbortController()
|
||||
|
||||
// 处理客户端断开连接
|
||||
req.on('close', () => {
|
||||
if (abortController && !abortController.signal.aborted) {
|
||||
logger.info('Client disconnected, aborting stream request')
|
||||
abortController.abort()
|
||||
}
|
||||
})
|
||||
|
||||
// 解析账户的代理配置
|
||||
let proxyConfig = null
|
||||
if (account.proxy) {
|
||||
try {
|
||||
proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse proxy configuration:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||
|
||||
// 使用账户的项目ID(如果有的话)
|
||||
const effectiveProjectId = account.projectId || null
|
||||
|
||||
logger.info('📋 Standard API 流式项目ID处理逻辑', {
|
||||
accountProjectId: account.projectId,
|
||||
effectiveProjectId,
|
||||
decision: account.projectId ? '使用账户配置' : '不使用项目ID'
|
||||
})
|
||||
|
||||
// 生成一个符合 Gemini CLI 格式的 user_prompt_id
|
||||
const userPromptId = `${require('crypto').randomUUID()}########0`
|
||||
|
||||
// 调用内部 API(cloudcode-pa)的流式接口
|
||||
const streamResponse = await geminiAccountService.generateContentStream(
|
||||
client,
|
||||
{ model, request: actualRequestData },
|
||||
userPromptId, // 使用生成的 user_prompt_id
|
||||
effectiveProjectId || 'oceanic-graph-cgcz4', // 如果没有项目ID,使用默认值
|
||||
req.apiKey?.id, // 使用 API Key ID 作为 session ID
|
||||
abortController.signal,
|
||||
proxyConfig
|
||||
)
|
||||
|
||||
// 设置 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')
|
||||
|
||||
// 处理流式响应并捕获usage数据
|
||||
let totalUsage = {
|
||||
promptTokenCount: 0,
|
||||
candidatesTokenCount: 0,
|
||||
totalTokenCount: 0
|
||||
}
|
||||
|
||||
streamResponse.on('data', (chunk) => {
|
||||
try {
|
||||
if (!res.destroyed) {
|
||||
const chunkStr = chunk.toString()
|
||||
|
||||
// 处理 SSE 格式的数据
|
||||
const lines = chunkStr.split('\n')
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const jsonStr = line.substring(6).trim()
|
||||
if (jsonStr && jsonStr !== '[DONE]') {
|
||||
try {
|
||||
const data = JSON.parse(jsonStr)
|
||||
|
||||
// 捕获 usage 数据
|
||||
if (data.response?.usageMetadata) {
|
||||
totalUsage = data.response.usageMetadata
|
||||
}
|
||||
|
||||
// 转换格式:移除 response 包装,直接返回标准 Gemini API 格式
|
||||
if (data.response) {
|
||||
// 过滤掉 thought 部分(这是内部 API 特有的)
|
||||
if (data.response.candidates) {
|
||||
const filteredCandidates = data.response.candidates
|
||||
.map((candidate) => {
|
||||
if (candidate.content && candidate.content.parts) {
|
||||
// 过滤掉 thought: true 的 parts
|
||||
const filteredParts = candidate.content.parts.filter(
|
||||
(part) => !part.thought
|
||||
)
|
||||
if (filteredParts.length > 0) {
|
||||
return {
|
||||
...candidate,
|
||||
content: {
|
||||
...candidate.content,
|
||||
parts: filteredParts
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return candidate
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
// 只有当有有效内容时才发送
|
||||
if (filteredCandidates.length > 0 || data.response.usageMetadata) {
|
||||
const standardResponse = {
|
||||
candidates: filteredCandidates,
|
||||
...(data.response.usageMetadata && {
|
||||
usageMetadata: data.response.usageMetadata
|
||||
}),
|
||||
...(data.response.modelVersion && {
|
||||
modelVersion: data.response.modelVersion
|
||||
}),
|
||||
...(data.response.createTime && { createTime: data.response.createTime }),
|
||||
...(data.response.responseId && { responseId: data.response.responseId })
|
||||
}
|
||||
res.write(`data: ${JSON.stringify(standardResponse)}\n\n`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 如果没有 response 包装,直接发送
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`)
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
} else if (jsonStr === '[DONE]') {
|
||||
// 保持 [DONE] 标记
|
||||
res.write(`${line}\n\n`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error processing stream chunk:', error)
|
||||
}
|
||||
})
|
||||
|
||||
streamResponse.on('end', async () => {
|
||||
logger.info('Stream completed successfully')
|
||||
|
||||
// 记录使用统计
|
||||
if (totalUsage.totalTokenCount > 0) {
|
||||
try {
|
||||
await apiKeyService.recordUsage(
|
||||
req.apiKey.id,
|
||||
totalUsage.promptTokenCount || 0,
|
||||
totalUsage.candidatesTokenCount || 0,
|
||||
0, // cacheCreateTokens
|
||||
0, // cacheReadTokens
|
||||
model,
|
||||
account.id
|
||||
)
|
||||
logger.info(
|
||||
`📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to record Gemini usage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
res.end()
|
||||
})
|
||||
|
||||
streamResponse.on('error', (error) => {
|
||||
logger.error('Stream error:', error)
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Stream error',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
} else {
|
||||
res.end()
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Error in standard streamGenerateContent endpoint`, {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
responseData: error.response?.data,
|
||||
stack: error.stack
|
||||
})
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message || 'Internal server error',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
// 清理资源
|
||||
if (abortController) {
|
||||
abortController = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// v1beta 版本的标准路由 - 支持动态模型名称
|
||||
router.post('/v1beta/models/:modelName\\:loadCodeAssist', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request: ${req.method} ${req.originalUrl}`)
|
||||
handleLoadCodeAssist(req, res, next)
|
||||
})
|
||||
|
||||
router.post('/v1beta/models/:modelName\\:onboardUser', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request: ${req.method} ${req.originalUrl}`)
|
||||
handleOnboardUser(req, res, next)
|
||||
})
|
||||
|
||||
router.post('/v1beta/models/:modelName\\:countTokens', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request: ${req.method} ${req.originalUrl}`)
|
||||
handleCountTokens(req, res, next)
|
||||
})
|
||||
|
||||
// 使用专门的处理函数处理标准 Gemini API 格式
|
||||
router.post(
|
||||
'/v1beta/models/:modelName\\:generateContent',
|
||||
authenticateApiKey,
|
||||
handleStandardGenerateContent
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/v1beta/models/:modelName\\:streamGenerateContent',
|
||||
authenticateApiKey,
|
||||
handleStandardStreamGenerateContent
|
||||
)
|
||||
|
||||
// v1 版本的标准路由(为了完整性,虽然 Gemini 主要使用 v1beta)
|
||||
router.post(
|
||||
'/v1/models/:modelName\\:generateContent',
|
||||
authenticateApiKey,
|
||||
handleStandardGenerateContent
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/v1/models/:modelName\\:streamGenerateContent',
|
||||
authenticateApiKey,
|
||||
handleStandardStreamGenerateContent
|
||||
)
|
||||
|
||||
router.post('/v1/models/:modelName\\:countTokens', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1): ${req.method} ${req.originalUrl}`)
|
||||
handleCountTokens(req, res, next)
|
||||
})
|
||||
|
||||
// v1internal 版本的标准路由(这些使用原有的处理函数,因为格式不同)
|
||||
router.post('/v1internal\\:loadCodeAssist', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
|
||||
handleLoadCodeAssist(req, res, next)
|
||||
})
|
||||
|
||||
router.post('/v1internal\\:onboardUser', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
|
||||
handleOnboardUser(req, res, next)
|
||||
})
|
||||
|
||||
router.post('/v1internal\\:countTokens', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
|
||||
handleCountTokens(req, res, next)
|
||||
})
|
||||
|
||||
// v1internal 使用不同的处理逻辑,因为它们不包含模型在 URL 中
|
||||
router.post('/v1internal\\:generateContent', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
|
||||
// v1internal 格式不同,使用原有的处理函数
|
||||
const { handleGenerateContent } = require('./geminiRoutes')
|
||||
handleGenerateContent(req, res, next)
|
||||
})
|
||||
|
||||
router.post('/v1internal\\:streamGenerateContent', authenticateApiKey, (req, res, next) => {
|
||||
logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`)
|
||||
// v1internal 格式不同,使用原有的处理函数
|
||||
const { handleStreamGenerateContent } = require('./geminiRoutes')
|
||||
handleStreamGenerateContent(req, res, next)
|
||||
})
|
||||
|
||||
// 添加标准 Gemini API 的模型列表端点
|
||||
router.get('/v1beta/models', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
logger.info('Standard Gemini API models request')
|
||||
// 直接调用 geminiRoutes 中的模型处理逻辑
|
||||
const geminiRoutes = require('./geminiRoutes')
|
||||
const modelHandler = geminiRoutes.stack.find(
|
||||
(layer) => layer.route && layer.route.path === '/models' && layer.route.methods.get
|
||||
)
|
||||
if (modelHandler && modelHandler.route.stack[1]) {
|
||||
// 调用处理函数(跳过第一个 authenticateApiKey 中间件)
|
||||
modelHandler.route.stack[1].handle(req, res)
|
||||
} else {
|
||||
res.status(500).json({ error: 'Models handler not found' })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error in standard models endpoint:', error)
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to retrieve models',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
logger.info('Standard Gemini API models request (v1)')
|
||||
// 直接调用 geminiRoutes 中的模型处理逻辑
|
||||
const geminiRoutes = require('./geminiRoutes')
|
||||
const modelHandler = geminiRoutes.stack.find(
|
||||
(layer) => layer.route && layer.route.path === '/models' && layer.route.methods.get
|
||||
)
|
||||
if (modelHandler && modelHandler.route.stack[1]) {
|
||||
modelHandler.route.stack[1].handle(req, res)
|
||||
} else {
|
||||
res.status(500).json({ error: 'Models handler not found' })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error in standard models endpoint (v1):', error)
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to retrieve models',
|
||||
type: 'api_error'
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 添加模型详情端点
|
||||
router.get('/v1beta/models/:modelName', authenticateApiKey, (req, res) => {
|
||||
const { modelName } = req.params
|
||||
logger.info(`Standard Gemini API model details request: ${modelName}`)
|
||||
|
||||
res.json({
|
||||
name: `models/${modelName}`,
|
||||
version: '001',
|
||||
displayName: modelName,
|
||||
description: `Gemini model: ${modelName}`,
|
||||
inputTokenLimit: 1048576,
|
||||
outputTokenLimit: 8192,
|
||||
supportedGenerationMethods: ['generateContent', 'streamGenerateContent', 'countTokens'],
|
||||
temperature: 1.0,
|
||||
topP: 0.95,
|
||||
topK: 40
|
||||
})
|
||||
})
|
||||
|
||||
router.get('/v1/models/:modelName', authenticateApiKey, (req, res) => {
|
||||
const { modelName } = req.params
|
||||
logger.info(`Standard Gemini API model details request (v1): ${modelName}`)
|
||||
|
||||
res.json({
|
||||
name: `models/${modelName}`,
|
||||
version: '001',
|
||||
displayName: modelName,
|
||||
description: `Gemini model: ${modelName}`,
|
||||
inputTokenLimit: 1048576,
|
||||
outputTokenLimit: 8192,
|
||||
supportedGenerationMethods: ['generateContent', 'streamGenerateContent', 'countTokens'],
|
||||
temperature: 1.0,
|
||||
topP: 0.95,
|
||||
topK: 40
|
||||
})
|
||||
})
|
||||
|
||||
logger.info('Standard Gemini API routes initialized')
|
||||
|
||||
module.exports = router
|
||||
@@ -483,6 +483,10 @@ class ApiKeyService {
|
||||
} catch (e) {
|
||||
key.tags = []
|
||||
}
|
||||
// 不暴露已弃用字段
|
||||
if (Object.prototype.hasOwnProperty.call(key, 'ccrAccountId')) {
|
||||
delete key.ccrAccountId
|
||||
}
|
||||
delete key.apiKey // 不返回哈希后的key
|
||||
}
|
||||
|
||||
@@ -846,8 +850,11 @@ class ApiKeyService {
|
||||
return // 不是 Opus 模型,直接返回
|
||||
}
|
||||
|
||||
// 判断是否为 claude 或 claude-console 账户
|
||||
if (!accountType || (accountType !== 'claude' && accountType !== 'claude-console')) {
|
||||
// 判断是否为 claude、claude-console 或 ccr 账户
|
||||
if (
|
||||
!accountType ||
|
||||
(accountType !== 'claude' && accountType !== 'claude-console' && accountType !== 'ccr')
|
||||
) {
|
||||
logger.debug(`⚠️ Skipping Opus cost recording for non-Claude account type: ${accountType}`)
|
||||
return // 不是 claude 账户,直接返回
|
||||
}
|
||||
|
||||
903
src/services/ccrAccountService.js
Normal file
903
src/services/ccrAccountService.js
Normal file
@@ -0,0 +1,903 @@
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
|
||||
class CcrAccountService {
|
||||
constructor() {
|
||||
// 加密相关常量
|
||||
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
|
||||
this.ENCRYPTION_SALT = 'ccr-account-salt'
|
||||
|
||||
// Redis键前缀
|
||||
this.ACCOUNT_KEY_PREFIX = 'ccr_account:'
|
||||
this.SHARED_ACCOUNTS_KEY = 'shared_ccr_accounts'
|
||||
|
||||
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
|
||||
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 密集型操作
|
||||
this._encryptionKeyCache = null
|
||||
|
||||
// 🔄 解密结果缓存,提高解密性能
|
||||
this._decryptCache = new LRUCache(500)
|
||||
|
||||
// 🧹 定期清理缓存(每10分钟)
|
||||
setInterval(
|
||||
() => {
|
||||
this._decryptCache.cleanup()
|
||||
logger.info('🧹 CCR account decrypt cache cleanup completed', this._decryptCache.getStats())
|
||||
},
|
||||
10 * 60 * 1000
|
||||
)
|
||||
}
|
||||
|
||||
// 🏢 创建CCR账户
|
||||
async createAccount(options = {}) {
|
||||
const {
|
||||
name = 'CCR Account',
|
||||
description = '',
|
||||
apiUrl = '',
|
||||
apiKey = '',
|
||||
priority = 50, // 默认优先级50(1-100)
|
||||
supportedModels = [], // 支持的模型列表或映射表,空数组/对象表示支持所有
|
||||
userAgent = 'claude-relay-service/1.0.0',
|
||||
rateLimitDuration = 60, // 限流时间(分钟)
|
||||
proxy = null,
|
||||
isActive = true,
|
||||
accountType = 'shared', // 'dedicated' or 'shared'
|
||||
schedulable = true, // 是否可被调度
|
||||
dailyQuota = 0, // 每日额度限制(美元),0表示不限制
|
||||
quotaResetTime = '00:00' // 额度重置时间(HH:mm格式)
|
||||
} = options
|
||||
|
||||
// 验证必填字段
|
||||
if (!apiUrl || !apiKey) {
|
||||
throw new Error('API URL and API Key are required for CCR account')
|
||||
}
|
||||
|
||||
const accountId = uuidv4()
|
||||
|
||||
// 处理 supportedModels,确保向后兼容
|
||||
const processedModels = this._processModelMapping(supportedModels)
|
||||
|
||||
const accountData = {
|
||||
id: accountId,
|
||||
platform: 'ccr',
|
||||
name,
|
||||
description,
|
||||
apiUrl,
|
||||
apiKey: this._encryptSensitiveData(apiKey),
|
||||
priority: priority.toString(),
|
||||
supportedModels: JSON.stringify(processedModels),
|
||||
userAgent,
|
||||
rateLimitDuration: rateLimitDuration.toString(),
|
||||
proxy: proxy ? JSON.stringify(proxy) : '',
|
||||
isActive: isActive.toString(),
|
||||
accountType,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: '',
|
||||
status: 'active',
|
||||
errorMessage: '',
|
||||
// 限流相关
|
||||
rateLimitedAt: '',
|
||||
rateLimitStatus: '',
|
||||
// 调度控制
|
||||
schedulable: schedulable.toString(),
|
||||
// 额度管理相关
|
||||
dailyQuota: dailyQuota.toString(), // 每日额度限制(美元)
|
||||
dailyUsage: '0', // 当日使用金额(美元)
|
||||
// 使用与统计一致的时区日期,避免边界问题
|
||||
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
|
||||
quotaResetTime, // 额度重置时间
|
||||
quotaStoppedAt: '' // 因额度停用的时间
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
logger.debug(
|
||||
`[DEBUG] Saving CCR account data to Redis with key: ${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
)
|
||||
logger.debug(`[DEBUG] CCR Account data to save: ${JSON.stringify(accountData, null, 2)}`)
|
||||
|
||||
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, accountData)
|
||||
|
||||
// 如果是共享账户,添加到共享账户集合
|
||||
if (accountType === 'shared') {
|
||||
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
}
|
||||
|
||||
logger.success(`🏢 Created CCR account: ${name} (${accountId})`)
|
||||
|
||||
return {
|
||||
id: accountId,
|
||||
name,
|
||||
description,
|
||||
apiUrl,
|
||||
priority,
|
||||
supportedModels,
|
||||
userAgent,
|
||||
rateLimitDuration,
|
||||
isActive,
|
||||
proxy,
|
||||
accountType,
|
||||
status: 'active',
|
||||
createdAt: accountData.createdAt,
|
||||
dailyQuota,
|
||||
dailyUsage: 0,
|
||||
lastResetDate: accountData.lastResetDate,
|
||||
quotaResetTime,
|
||||
quotaStoppedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
// 📋 获取所有CCR账户
|
||||
async getAllAccounts() {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
|
||||
const accounts = []
|
||||
|
||||
for (const key of keys) {
|
||||
const accountData = await client.hgetall(key)
|
||||
if (accountData && Object.keys(accountData).length > 0) {
|
||||
// 获取限流状态信息
|
||||
const rateLimitInfo = this._getRateLimitInfo(accountData)
|
||||
|
||||
accounts.push({
|
||||
id: accountData.id,
|
||||
platform: accountData.platform,
|
||||
name: accountData.name,
|
||||
description: accountData.description,
|
||||
apiUrl: accountData.apiUrl,
|
||||
priority: parseInt(accountData.priority) || 50,
|
||||
supportedModels: JSON.parse(accountData.supportedModels || '[]'),
|
||||
userAgent: accountData.userAgent,
|
||||
rateLimitDuration: Number.isNaN(parseInt(accountData.rateLimitDuration))
|
||||
? 60
|
||||
: parseInt(accountData.rateLimitDuration),
|
||||
isActive: accountData.isActive === 'true',
|
||||
proxy: accountData.proxy ? JSON.parse(accountData.proxy) : null,
|
||||
accountType: accountData.accountType || 'shared',
|
||||
createdAt: accountData.createdAt,
|
||||
lastUsedAt: accountData.lastUsedAt,
|
||||
status: accountData.status || 'active',
|
||||
errorMessage: accountData.errorMessage,
|
||||
rateLimitInfo,
|
||||
schedulable: accountData.schedulable !== 'false', // 默认为true,只有明确设置为false才不可调度
|
||||
// 额度管理相关
|
||||
dailyQuota: parseFloat(accountData.dailyQuota || '0'),
|
||||
dailyUsage: parseFloat(accountData.dailyUsage || '0'),
|
||||
lastResetDate: accountData.lastResetDate || '',
|
||||
quotaResetTime: accountData.quotaResetTime || '00:00',
|
||||
quotaStoppedAt: accountData.quotaStoppedAt || null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return accounts
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get CCR accounts:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 获取单个账户(内部使用,包含敏感信息)
|
||||
async getAccount(accountId) {
|
||||
const client = redis.getClientSafe()
|
||||
logger.debug(`[DEBUG] Getting CCR account data for ID: ${accountId}`)
|
||||
const accountData = await client.hgetall(`${this.ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||
|
||||
if (!accountData || Object.keys(accountData).length === 0) {
|
||||
logger.debug(`[DEBUG] No CCR account data found for ID: ${accountId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
logger.debug(`[DEBUG] Raw CCR account data keys: ${Object.keys(accountData).join(', ')}`)
|
||||
logger.debug(`[DEBUG] Raw supportedModels value: ${accountData.supportedModels}`)
|
||||
|
||||
// 解密敏感字段(只解密apiKey,apiUrl不加密)
|
||||
const decryptedKey = this._decryptSensitiveData(accountData.apiKey)
|
||||
logger.debug(
|
||||
`[DEBUG] URL exists: ${!!accountData.apiUrl}, Decrypted key exists: ${!!decryptedKey}`
|
||||
)
|
||||
|
||||
accountData.apiKey = decryptedKey
|
||||
|
||||
// 解析JSON字段
|
||||
const parsedModels = JSON.parse(accountData.supportedModels || '[]')
|
||||
logger.debug(`[DEBUG] Parsed supportedModels: ${JSON.stringify(parsedModels)}`)
|
||||
|
||||
accountData.supportedModels = parsedModels
|
||||
accountData.priority = parseInt(accountData.priority) || 50
|
||||
{
|
||||
const _parsedDuration = parseInt(accountData.rateLimitDuration)
|
||||
accountData.rateLimitDuration = Number.isNaN(_parsedDuration) ? 60 : _parsedDuration
|
||||
}
|
||||
accountData.isActive = accountData.isActive === 'true'
|
||||
accountData.schedulable = accountData.schedulable !== 'false' // 默认为true
|
||||
|
||||
if (accountData.proxy) {
|
||||
accountData.proxy = JSON.parse(accountData.proxy)
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[DEBUG] Final CCR account data - name: ${accountData.name}, hasApiUrl: ${!!accountData.apiUrl}, hasApiKey: ${!!accountData.apiKey}, supportedModels: ${JSON.stringify(accountData.supportedModels)}`
|
||||
)
|
||||
|
||||
return accountData
|
||||
}
|
||||
|
||||
// 📝 更新账户
|
||||
async updateAccount(accountId, updates) {
|
||||
try {
|
||||
const existingAccount = await this.getAccount(accountId)
|
||||
if (!existingAccount) {
|
||||
throw new Error('CCR Account not found')
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
const updatedData = {}
|
||||
|
||||
// 处理各个字段的更新
|
||||
logger.debug(
|
||||
`[DEBUG] CCR update request received with fields: ${Object.keys(updates).join(', ')}`
|
||||
)
|
||||
logger.debug(`[DEBUG] CCR Updates content: ${JSON.stringify(updates, null, 2)}`)
|
||||
|
||||
if (updates.name !== undefined) {
|
||||
updatedData.name = updates.name
|
||||
}
|
||||
if (updates.description !== undefined) {
|
||||
updatedData.description = updates.description
|
||||
}
|
||||
if (updates.apiUrl !== undefined) {
|
||||
updatedData.apiUrl = updates.apiUrl
|
||||
}
|
||||
if (updates.apiKey !== undefined) {
|
||||
updatedData.apiKey = this._encryptSensitiveData(updates.apiKey)
|
||||
}
|
||||
if (updates.priority !== undefined) {
|
||||
updatedData.priority = updates.priority.toString()
|
||||
}
|
||||
if (updates.supportedModels !== undefined) {
|
||||
logger.debug(`[DEBUG] Updating supportedModels: ${JSON.stringify(updates.supportedModels)}`)
|
||||
// 处理 supportedModels,确保向后兼容
|
||||
const processedModels = this._processModelMapping(updates.supportedModels)
|
||||
updatedData.supportedModels = JSON.stringify(processedModels)
|
||||
}
|
||||
if (updates.userAgent !== undefined) {
|
||||
updatedData.userAgent = updates.userAgent
|
||||
}
|
||||
if (updates.rateLimitDuration !== undefined) {
|
||||
updatedData.rateLimitDuration = updates.rateLimitDuration.toString()
|
||||
}
|
||||
if (updates.proxy !== undefined) {
|
||||
updatedData.proxy = updates.proxy ? JSON.stringify(updates.proxy) : ''
|
||||
}
|
||||
if (updates.isActive !== undefined) {
|
||||
updatedData.isActive = updates.isActive.toString()
|
||||
}
|
||||
if (updates.schedulable !== undefined) {
|
||||
updatedData.schedulable = updates.schedulable.toString()
|
||||
}
|
||||
if (updates.dailyQuota !== undefined) {
|
||||
updatedData.dailyQuota = updates.dailyQuota.toString()
|
||||
}
|
||||
if (updates.quotaResetTime !== undefined) {
|
||||
updatedData.quotaResetTime = updates.quotaResetTime
|
||||
}
|
||||
|
||||
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updatedData)
|
||||
|
||||
// 处理共享账户集合变更
|
||||
if (updates.accountType !== undefined) {
|
||||
updatedData.accountType = updates.accountType
|
||||
if (updates.accountType === 'shared') {
|
||||
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
} else {
|
||||
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`📝 Updated CCR account: ${accountId}`)
|
||||
return await this.getAccount(accountId)
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to update CCR account ${accountId}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🗑️ 删除账户
|
||||
async deleteAccount(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
// 从共享账户集合中移除
|
||||
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
|
||||
// 删除账户数据
|
||||
const result = await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||
|
||||
if (result === 0) {
|
||||
throw new Error('CCR Account not found or already deleted')
|
||||
}
|
||||
|
||||
logger.success(`🗑️ Deleted CCR account: ${accountId}`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to delete CCR account ${accountId}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 标记账户为限流状态
|
||||
async markAccountRateLimited(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('CCR Account not found')
|
||||
}
|
||||
|
||||
// 如果限流时间设置为 0,表示不启用限流机制,直接返回
|
||||
if (account.rateLimitDuration === 0) {
|
||||
logger.info(
|
||||
`ℹ️ CCR account ${account.name} (${accountId}) has rate limiting disabled, skipping rate limit`
|
||||
)
|
||||
return { success: true, skipped: true }
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
|
||||
status: 'rate_limited',
|
||||
rateLimitedAt: now,
|
||||
rateLimitStatus: 'active',
|
||||
errorMessage: 'Rate limited by upstream service'
|
||||
})
|
||||
|
||||
logger.warn(`⏱️ Marked CCR account as rate limited: ${account.name} (${accountId})`)
|
||||
return { success: true, rateLimitedAt: now }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to mark CCR account as rate limited: ${accountId}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 移除账户限流状态
|
||||
async removeAccountRateLimit(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
// 获取账户当前状态和额度信息
|
||||
const [, quotaStoppedAt] = await client.hmget(accountKey, 'status', 'quotaStoppedAt')
|
||||
|
||||
// 删除限流相关字段
|
||||
await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus')
|
||||
|
||||
// 根据不同情况决定是否恢复账户
|
||||
let newStatus = 'active'
|
||||
let errorMessage = ''
|
||||
|
||||
// 如果因额度问题停用,不要自动激活
|
||||
if (quotaStoppedAt) {
|
||||
newStatus = 'quota_exceeded'
|
||||
errorMessage = 'Account stopped due to quota exceeded'
|
||||
logger.info(
|
||||
`ℹ️ CCR account ${accountId} rate limit removed but remains stopped due to quota exceeded`
|
||||
)
|
||||
} else {
|
||||
logger.success(`✅ Removed rate limit for CCR account: ${accountId}`)
|
||||
}
|
||||
|
||||
await client.hmset(accountKey, {
|
||||
status: newStatus,
|
||||
errorMessage
|
||||
})
|
||||
|
||||
return { success: true, newStatus }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to remove rate limit for CCR account: ${accountId}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 检查账户是否被限流
|
||||
async isAccountRateLimited(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
const [rateLimitedAt, rateLimitDuration] = await client.hmget(
|
||||
accountKey,
|
||||
'rateLimitedAt',
|
||||
'rateLimitDuration'
|
||||
)
|
||||
|
||||
if (rateLimitedAt) {
|
||||
const limitTime = new Date(rateLimitedAt)
|
||||
const duration = parseInt(rateLimitDuration) || 60
|
||||
const now = new Date()
|
||||
const expireTime = new Date(limitTime.getTime() + duration * 60 * 1000)
|
||||
|
||||
if (now < expireTime) {
|
||||
return true
|
||||
} else {
|
||||
// 限流时间已过,自动移除限流状态
|
||||
await this.removeAccountRateLimit(accountId)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to check rate limit status for CCR account: ${accountId}`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 标记账户为过载状态
|
||||
async markAccountOverloaded(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('CCR Account not found')
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
|
||||
status: 'overloaded',
|
||||
overloadedAt: now,
|
||||
errorMessage: 'Account overloaded'
|
||||
})
|
||||
|
||||
logger.warn(`🔥 Marked CCR account as overloaded: ${account.name} (${accountId})`)
|
||||
return { success: true, overloadedAt: now }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to mark CCR account as overloaded: ${accountId}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 移除账户过载状态
|
||||
async removeAccountOverload(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
// 删除过载相关字段
|
||||
await client.hdel(accountKey, 'overloadedAt')
|
||||
|
||||
await client.hmset(accountKey, {
|
||||
status: 'active',
|
||||
errorMessage: ''
|
||||
})
|
||||
|
||||
logger.success(`✅ Removed overload status for CCR account: ${accountId}`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to remove overload status for CCR account: ${accountId}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 检查账户是否过载
|
||||
async isAccountOverloaded(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
const status = await client.hget(accountKey, 'status')
|
||||
return status === 'overloaded'
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to check overload status for CCR account: ${accountId}`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 标记账户为未授权状态
|
||||
async markAccountUnauthorized(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('CCR Account not found')
|
||||
}
|
||||
|
||||
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
|
||||
status: 'unauthorized',
|
||||
errorMessage: 'API key invalid or unauthorized'
|
||||
})
|
||||
|
||||
logger.warn(`🚫 Marked CCR account as unauthorized: ${account.name} (${accountId})`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to mark CCR account as unauthorized: ${accountId}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 处理模型映射
|
||||
_processModelMapping(supportedModels) {
|
||||
// 如果是空值,返回空对象(支持所有模型)
|
||||
if (!supportedModels || (Array.isArray(supportedModels) && supportedModels.length === 0)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
// 如果已经是对象格式(新的映射表格式),直接返回
|
||||
if (typeof supportedModels === 'object' && !Array.isArray(supportedModels)) {
|
||||
return supportedModels
|
||||
}
|
||||
|
||||
// 如果是数组格式(旧格式),转换为映射表
|
||||
if (Array.isArray(supportedModels)) {
|
||||
const mapping = {}
|
||||
supportedModels.forEach((model) => {
|
||||
if (model && typeof model === 'string') {
|
||||
mapping[model] = model // 默认映射:原模型名 -> 原模型名
|
||||
}
|
||||
})
|
||||
return mapping
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
// 🔍 检查模型是否被支持
|
||||
isModelSupported(modelMapping, requestedModel) {
|
||||
// 如果映射表为空,支持所有模型
|
||||
if (!modelMapping || Object.keys(modelMapping).length === 0) {
|
||||
return true
|
||||
}
|
||||
// 检查请求的模型是否在映射表的键中
|
||||
return Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)
|
||||
}
|
||||
|
||||
// 🔄 获取映射后的模型名称
|
||||
getMappedModel(modelMapping, requestedModel) {
|
||||
// 如果映射表为空,返回原模型
|
||||
if (!modelMapping || Object.keys(modelMapping).length === 0) {
|
||||
return requestedModel
|
||||
}
|
||||
|
||||
// 返回映射后的模型名,如果不存在映射则返回原模型名
|
||||
return modelMapping[requestedModel] || requestedModel
|
||||
}
|
||||
|
||||
// 🔐 加密敏感数据
|
||||
_encryptSensitiveData(data) {
|
||||
if (!data) {
|
||||
return ''
|
||||
}
|
||||
try {
|
||||
const key = this._generateEncryptionKey()
|
||||
const iv = crypto.randomBytes(16)
|
||||
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
||||
let encrypted = cipher.update(data, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
return `${iv.toString('hex')}:${encrypted}`
|
||||
} catch (error) {
|
||||
logger.error('❌ CCR encryption error:', error)
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
// 🔓 解密敏感数据
|
||||
_decryptSensitiveData(encryptedData) {
|
||||
if (!encryptedData) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 🎯 检查缓存
|
||||
const cacheKey = crypto.createHash('sha256').update(encryptedData).digest('hex')
|
||||
const cached = this._decryptCache.get(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const parts = encryptedData.split(':')
|
||||
if (parts.length === 2) {
|
||||
const key = this._generateEncryptionKey()
|
||||
const iv = Buffer.from(parts[0], 'hex')
|
||||
const encrypted = parts[1]
|
||||
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
|
||||
// 💾 存入缓存(5分钟过期)
|
||||
this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000)
|
||||
|
||||
return decrypted
|
||||
} else {
|
||||
logger.error('❌ Invalid CCR encrypted data format')
|
||||
return encryptedData
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ CCR decryption error:', error)
|
||||
return encryptedData
|
||||
}
|
||||
}
|
||||
|
||||
// 🔑 生成加密密钥
|
||||
_generateEncryptionKey() {
|
||||
// 性能优化:缓存密钥派生结果,避免重复的 CPU 密集计算
|
||||
if (!this._encryptionKeyCache) {
|
||||
this._encryptionKeyCache = crypto.scryptSync(
|
||||
config.security.encryptionKey,
|
||||
this.ENCRYPTION_SALT,
|
||||
32
|
||||
)
|
||||
}
|
||||
return this._encryptionKeyCache
|
||||
}
|
||||
|
||||
// 🔍 获取限流状态信息
|
||||
_getRateLimitInfo(accountData) {
|
||||
const { rateLimitedAt } = accountData
|
||||
const rateLimitDuration = parseInt(accountData.rateLimitDuration) || 60
|
||||
|
||||
if (rateLimitedAt) {
|
||||
const limitTime = new Date(rateLimitedAt)
|
||||
const now = new Date()
|
||||
const expireTime = new Date(limitTime.getTime() + rateLimitDuration * 60 * 1000)
|
||||
const remainingMs = expireTime.getTime() - now.getTime()
|
||||
|
||||
return {
|
||||
isRateLimited: remainingMs > 0,
|
||||
rateLimitedAt,
|
||||
rateLimitExpireAt: expireTime.toISOString(),
|
||||
remainingTimeMs: Math.max(0, remainingMs),
|
||||
remainingTimeMinutes: Math.max(0, Math.ceil(remainingMs / (60 * 1000)))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isRateLimited: false,
|
||||
rateLimitedAt: null,
|
||||
rateLimitExpireAt: null,
|
||||
remainingTimeMs: 0,
|
||||
remainingTimeMinutes: 0
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 创建代理客户端
|
||||
_createProxyAgent(proxy) {
|
||||
return ProxyHelper.createProxyAgent(proxy)
|
||||
}
|
||||
|
||||
// 💰 检查配额使用情况(可选实现)
|
||||
async checkQuotaUsage(accountId) {
|
||||
try {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
return false
|
||||
}
|
||||
|
||||
const dailyQuota = parseFloat(account.dailyQuota || '0')
|
||||
// 如果未设置额度限制,则不限制
|
||||
if (dailyQuota <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否需要重置每日使用量
|
||||
const today = redis.getDateStringInTimezone()
|
||||
if (account.lastResetDate !== today) {
|
||||
await this.resetDailyUsage(accountId)
|
||||
return false // 刚重置,不会超额
|
||||
}
|
||||
|
||||
// 获取当日使用统计
|
||||
const usageStats = await this.getAccountUsageStats(accountId)
|
||||
if (!usageStats) {
|
||||
return false
|
||||
}
|
||||
|
||||
const dailyUsage = usageStats.dailyUsage || 0
|
||||
const isExceeded = dailyUsage >= dailyQuota
|
||||
|
||||
if (isExceeded) {
|
||||
// 标记账户因额度停用
|
||||
const client = redis.getClientSafe()
|
||||
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
|
||||
status: 'quota_exceeded',
|
||||
errorMessage: `Daily quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`,
|
||||
quotaStoppedAt: new Date().toISOString()
|
||||
})
|
||||
logger.warn(
|
||||
`💰 CCR account ${account.name} (${accountId}) quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`
|
||||
)
|
||||
|
||||
// 发送 Webhook 通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || accountId,
|
||||
platform: 'ccr',
|
||||
status: 'quota_exceeded',
|
||||
errorCode: 'QUOTA_EXCEEDED',
|
||||
reason: `Daily quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.warn('Failed to send webhook notification for CCR quota exceeded:', webhookError)
|
||||
}
|
||||
}
|
||||
|
||||
return isExceeded
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to check quota usage for CCR account ${accountId}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 重置每日使用量(可选实现)
|
||||
async resetDailyUsage(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
|
||||
dailyUsage: '0',
|
||||
lastResetDate: redis.getDateStringInTimezone(),
|
||||
quotaStoppedAt: ''
|
||||
})
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to reset daily usage for CCR account: ${accountId}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 检查账户是否超额
|
||||
async isAccountQuotaExceeded(accountId) {
|
||||
try {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
return false
|
||||
}
|
||||
|
||||
const dailyQuota = parseFloat(account.dailyQuota || '0')
|
||||
// 如果未设置额度限制,则不限制
|
||||
if (dailyQuota <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 获取当日使用统计
|
||||
const usageStats = await this.getAccountUsageStats(accountId)
|
||||
if (!usageStats) {
|
||||
return false
|
||||
}
|
||||
|
||||
const dailyUsage = usageStats.dailyUsage || 0
|
||||
const isExceeded = dailyUsage >= dailyQuota
|
||||
|
||||
if (isExceeded && !account.quotaStoppedAt) {
|
||||
// 标记账户因额度停用
|
||||
const client = redis.getClientSafe()
|
||||
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
|
||||
status: 'quota_exceeded',
|
||||
errorMessage: `Daily quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`,
|
||||
quotaStoppedAt: new Date().toISOString()
|
||||
})
|
||||
logger.warn(`💰 CCR account ${account.name} (${accountId}) quota exceeded`)
|
||||
}
|
||||
|
||||
return isExceeded
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to check quota for CCR account ${accountId}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 重置所有CCR账户的每日使用量
|
||||
async resetAllDailyUsage() {
|
||||
try {
|
||||
const accounts = await this.getAllAccounts()
|
||||
const today = redis.getDateStringInTimezone()
|
||||
let resetCount = 0
|
||||
|
||||
for (const account of accounts) {
|
||||
if (account.lastResetDate !== today) {
|
||||
await this.resetDailyUsage(account.id)
|
||||
resetCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`✅ Reset daily usage for ${resetCount} CCR accounts`)
|
||||
return { success: true, resetCount }
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset all CCR daily usage:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 获取CCR账户使用统计(含每日费用)
|
||||
async getAccountUsageStats(accountId) {
|
||||
try {
|
||||
// 使用统一的 Redis 统计
|
||||
const usageStats = await redis.getAccountUsageStats(accountId)
|
||||
|
||||
// 叠加账户自身的额度配置
|
||||
const accountData = await this.getAccount(accountId)
|
||||
if (!accountData) {
|
||||
return null
|
||||
}
|
||||
|
||||
const dailyQuota = parseFloat(accountData.dailyQuota || '0')
|
||||
const currentDailyCost = usageStats?.daily?.cost || 0
|
||||
|
||||
return {
|
||||
dailyQuota,
|
||||
dailyUsage: currentDailyCost,
|
||||
remainingQuota: dailyQuota > 0 ? Math.max(0, dailyQuota - currentDailyCost) : null,
|
||||
usagePercentage: dailyQuota > 0 ? (currentDailyCost / dailyQuota) * 100 : 0,
|
||||
lastResetDate: accountData.lastResetDate,
|
||||
quotaResetTime: accountData.quotaResetTime,
|
||||
quotaStoppedAt: accountData.quotaStoppedAt,
|
||||
isQuotaExceeded: dailyQuota > 0 && currentDailyCost >= dailyQuota,
|
||||
fullUsageStats: usageStats
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get CCR account usage stats:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 重置CCR账户所有异常状态
|
||||
async resetAccountStatus(accountId) {
|
||||
try {
|
||||
const accountData = await this.getAccount(accountId)
|
||||
if (!accountData) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
const updates = {
|
||||
status: 'active',
|
||||
errorMessage: '',
|
||||
schedulable: 'true',
|
||||
isActive: 'true'
|
||||
}
|
||||
|
||||
const fieldsToDelete = [
|
||||
'rateLimitedAt',
|
||||
'rateLimitStatus',
|
||||
'unauthorizedAt',
|
||||
'unauthorizedCount',
|
||||
'overloadedAt',
|
||||
'overloadStatus',
|
||||
'blockedAt',
|
||||
'quotaStoppedAt'
|
||||
]
|
||||
|
||||
await client.hset(accountKey, updates)
|
||||
await client.hdel(accountKey, ...fieldsToDelete)
|
||||
|
||||
logger.success(`✅ Reset all error status for CCR account ${accountId}`)
|
||||
|
||||
// 异步发送 Webhook 通知(忽略错误)
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: accountData.name || accountId,
|
||||
platform: 'ccr',
|
||||
status: 'recovered',
|
||||
errorCode: 'STATUS_RESET',
|
||||
reason: 'Account status manually reset',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.warn('Failed to send webhook notification for CCR status reset:', webhookError)
|
||||
}
|
||||
|
||||
return { success: true, accountId }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to reset CCR account status: ${accountId}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CcrAccountService()
|
||||
641
src/services/ccrRelayService.js
Normal file
641
src/services/ccrRelayService.js
Normal file
@@ -0,0 +1,641 @@
|
||||
const axios = require('axios')
|
||||
const ccrAccountService = require('./ccrAccountService')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const { parseVendorPrefixedModel } = require('../utils/modelHelper')
|
||||
|
||||
class CcrRelayService {
|
||||
constructor() {
|
||||
this.defaultUserAgent = 'claude-relay-service/1.0.0'
|
||||
}
|
||||
|
||||
// 🚀 转发请求到CCR API
|
||||
async relayRequest(
|
||||
requestBody,
|
||||
apiKeyData,
|
||||
clientRequest,
|
||||
clientResponse,
|
||||
clientHeaders,
|
||||
accountId,
|
||||
options = {}
|
||||
) {
|
||||
let abortController = null
|
||||
let account = null
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
account = await ccrAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('CCR account not found')
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`📤 Processing CCR API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId})`
|
||||
)
|
||||
logger.debug(`🌐 Account API URL: ${account.apiUrl}`)
|
||||
logger.debug(`🔍 Account supportedModels: ${JSON.stringify(account.supportedModels)}`)
|
||||
logger.debug(`🔑 Account has apiKey: ${!!account.apiKey}`)
|
||||
logger.debug(`📝 Request model: ${requestBody.model}`)
|
||||
|
||||
// 处理模型前缀解析和映射
|
||||
const { baseModel } = parseVendorPrefixedModel(requestBody.model)
|
||||
logger.debug(`🔄 Parsed base model: ${baseModel} from original: ${requestBody.model}`)
|
||||
|
||||
let mappedModel = baseModel
|
||||
if (
|
||||
account.supportedModels &&
|
||||
typeof account.supportedModels === 'object' &&
|
||||
!Array.isArray(account.supportedModels)
|
||||
) {
|
||||
const newModel = ccrAccountService.getMappedModel(account.supportedModels, baseModel)
|
||||
if (newModel !== baseModel) {
|
||||
logger.info(`🔄 Mapping model from ${baseModel} to ${newModel}`)
|
||||
mappedModel = newModel
|
||||
}
|
||||
}
|
||||
|
||||
// 创建修改后的请求体,使用去前缀后的模型名
|
||||
const modifiedRequestBody = {
|
||||
...requestBody,
|
||||
model: mappedModel
|
||||
}
|
||||
|
||||
// 创建代理agent
|
||||
const proxyAgent = ccrAccountService._createProxyAgent(account.proxy)
|
||||
|
||||
// 创建AbortController用于取消请求
|
||||
abortController = new AbortController()
|
||||
|
||||
// 设置客户端断开监听器
|
||||
const handleClientDisconnect = () => {
|
||||
logger.info('🔌 Client disconnected, aborting CCR request')
|
||||
if (abortController && !abortController.signal.aborted) {
|
||||
abortController.abort()
|
||||
}
|
||||
}
|
||||
|
||||
// 监听客户端断开事件
|
||||
if (clientRequest) {
|
||||
clientRequest.once('close', handleClientDisconnect)
|
||||
}
|
||||
if (clientResponse) {
|
||||
clientResponse.once('close', handleClientDisconnect)
|
||||
}
|
||||
|
||||
// 构建完整的API URL
|
||||
const cleanUrl = account.apiUrl.replace(/\/$/, '') // 移除末尾斜杠
|
||||
let apiEndpoint
|
||||
|
||||
if (options.customPath) {
|
||||
// 如果指定了自定义路径(如 count_tokens),使用它
|
||||
const baseUrl = cleanUrl.replace(/\/v1\/messages$/, '') // 移除已有的 /v1/messages
|
||||
apiEndpoint = `${baseUrl}${options.customPath}`
|
||||
} else {
|
||||
// 默认使用 messages 端点
|
||||
apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages`
|
||||
}
|
||||
|
||||
logger.debug(`🎯 Final API endpoint: ${apiEndpoint}`)
|
||||
logger.debug(`[DEBUG] Options passed to relayRequest: ${JSON.stringify(options)}`)
|
||||
logger.debug(`[DEBUG] Client headers received: ${JSON.stringify(clientHeaders)}`)
|
||||
|
||||
// 过滤客户端请求头
|
||||
const filteredHeaders = this._filterClientHeaders(clientHeaders)
|
||||
logger.debug(`[DEBUG] Filtered client headers: ${JSON.stringify(filteredHeaders)}`)
|
||||
|
||||
// 决定使用的 User-Agent:优先使用账户自定义的,否则透传客户端的,最后才使用默认值
|
||||
const userAgent =
|
||||
account.userAgent ||
|
||||
clientHeaders?.['user-agent'] ||
|
||||
clientHeaders?.['User-Agent'] ||
|
||||
this.defaultUserAgent
|
||||
|
||||
// 准备请求配置
|
||||
const requestConfig = {
|
||||
method: 'POST',
|
||||
url: apiEndpoint,
|
||||
data: modifiedRequestBody,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
'User-Agent': userAgent,
|
||||
...filteredHeaders
|
||||
},
|
||||
httpsAgent: proxyAgent,
|
||||
timeout: config.requestTimeout || 600000,
|
||||
signal: abortController.signal,
|
||||
validateStatus: () => true // 接受所有状态码
|
||||
}
|
||||
|
||||
// 根据 API Key 格式选择认证方式
|
||||
if (account.apiKey && account.apiKey.startsWith('sk-ant-')) {
|
||||
// Anthropic 官方 API Key 使用 x-api-key
|
||||
requestConfig.headers['x-api-key'] = account.apiKey
|
||||
logger.debug('[DEBUG] Using x-api-key authentication for sk-ant-* API key')
|
||||
} else {
|
||||
// 其他 API Key (包括CCR API Key) 使用 Authorization Bearer
|
||||
requestConfig.headers['Authorization'] = `Bearer ${account.apiKey}`
|
||||
logger.debug('[DEBUG] Using Authorization Bearer authentication')
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[DEBUG] Initial headers before beta: ${JSON.stringify(requestConfig.headers, null, 2)}`
|
||||
)
|
||||
|
||||
// 添加beta header如果需要
|
||||
if (options.betaHeader) {
|
||||
logger.debug(`[DEBUG] Adding beta header: ${options.betaHeader}`)
|
||||
requestConfig.headers['anthropic-beta'] = options.betaHeader
|
||||
} else {
|
||||
logger.debug('[DEBUG] No beta header to add')
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
logger.debug(
|
||||
'📤 Sending request to CCR API with headers:',
|
||||
JSON.stringify(requestConfig.headers, null, 2)
|
||||
)
|
||||
const response = await axios(requestConfig)
|
||||
|
||||
// 移除监听器(请求成功完成)
|
||||
if (clientRequest) {
|
||||
clientRequest.removeListener('close', handleClientDisconnect)
|
||||
}
|
||||
if (clientResponse) {
|
||||
clientResponse.removeListener('close', handleClientDisconnect)
|
||||
}
|
||||
|
||||
logger.debug(`🔗 CCR API response: ${response.status}`)
|
||||
logger.debug(`[DEBUG] Response headers: ${JSON.stringify(response.headers)}`)
|
||||
logger.debug(`[DEBUG] Response data type: ${typeof response.data}`)
|
||||
logger.debug(
|
||||
`[DEBUG] Response data length: ${response.data ? (typeof response.data === 'string' ? response.data.length : JSON.stringify(response.data).length) : 0}`
|
||||
)
|
||||
logger.debug(
|
||||
`[DEBUG] Response data preview: ${typeof response.data === 'string' ? response.data.substring(0, 200) : JSON.stringify(response.data).substring(0, 200)}`
|
||||
)
|
||||
|
||||
// 检查错误状态并相应处理
|
||||
if (response.status === 401) {
|
||||
logger.warn(`🚫 Unauthorized error detected for CCR account ${accountId}`)
|
||||
await ccrAccountService.markAccountUnauthorized(accountId)
|
||||
} else if (response.status === 429) {
|
||||
logger.warn(`🚫 Rate limit detected for CCR account ${accountId}`)
|
||||
// 收到429先检查是否因为超过了手动配置的每日额度
|
||||
await ccrAccountService.checkQuotaUsage(accountId).catch((err) => {
|
||||
logger.error('❌ Failed to check quota after 429 error:', err)
|
||||
})
|
||||
|
||||
await ccrAccountService.markAccountRateLimited(accountId)
|
||||
} else if (response.status === 529) {
|
||||
logger.warn(`🚫 Overload error detected for CCR account ${accountId}`)
|
||||
await ccrAccountService.markAccountOverloaded(accountId)
|
||||
} else if (response.status === 200 || response.status === 201) {
|
||||
// 如果请求成功,检查并移除错误状态
|
||||
const isRateLimited = await ccrAccountService.isAccountRateLimited(accountId)
|
||||
if (isRateLimited) {
|
||||
await ccrAccountService.removeAccountRateLimit(accountId)
|
||||
}
|
||||
const isOverloaded = await ccrAccountService.isAccountOverloaded(accountId)
|
||||
if (isOverloaded) {
|
||||
await ccrAccountService.removeAccountOverload(accountId)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新最后使用时间
|
||||
await this._updateLastUsedTime(accountId)
|
||||
|
||||
const responseBody =
|
||||
typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
|
||||
logger.debug(`[DEBUG] Final response body to return: ${responseBody}`)
|
||||
|
||||
return {
|
||||
statusCode: response.status,
|
||||
headers: response.headers,
|
||||
body: responseBody,
|
||||
accountId
|
||||
}
|
||||
} catch (error) {
|
||||
// 处理特定错误
|
||||
if (error.name === 'AbortError' || error.code === 'ECONNABORTED') {
|
||||
logger.info('Request aborted due to client disconnect')
|
||||
throw new Error('Client disconnected')
|
||||
}
|
||||
|
||||
logger.error(
|
||||
`❌ CCR relay request failed (Account: ${account?.name || accountId}):`,
|
||||
error.message
|
||||
)
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🌊 处理流式响应
|
||||
async relayStreamRequestWithUsageCapture(
|
||||
requestBody,
|
||||
apiKeyData,
|
||||
responseStream,
|
||||
clientHeaders,
|
||||
usageCallback,
|
||||
accountId,
|
||||
streamTransformer = null,
|
||||
options = {}
|
||||
) {
|
||||
let account = null
|
||||
try {
|
||||
// 获取账户信息
|
||||
account = await ccrAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('CCR account not found')
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`📡 Processing streaming CCR API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId})`
|
||||
)
|
||||
logger.debug(`🌐 Account API URL: ${account.apiUrl}`)
|
||||
|
||||
// 处理模型前缀解析和映射
|
||||
const { baseModel } = parseVendorPrefixedModel(requestBody.model)
|
||||
logger.debug(`🔄 Parsed base model: ${baseModel} from original: ${requestBody.model}`)
|
||||
|
||||
let mappedModel = baseModel
|
||||
if (
|
||||
account.supportedModels &&
|
||||
typeof account.supportedModels === 'object' &&
|
||||
!Array.isArray(account.supportedModels)
|
||||
) {
|
||||
const newModel = ccrAccountService.getMappedModel(account.supportedModels, baseModel)
|
||||
if (newModel !== baseModel) {
|
||||
logger.info(`🔄 [Stream] Mapping model from ${baseModel} to ${newModel}`)
|
||||
mappedModel = newModel
|
||||
}
|
||||
}
|
||||
|
||||
// 创建修改后的请求体,使用去前缀后的模型名
|
||||
const modifiedRequestBody = {
|
||||
...requestBody,
|
||||
model: mappedModel
|
||||
}
|
||||
|
||||
// 创建代理agent
|
||||
const proxyAgent = ccrAccountService._createProxyAgent(account.proxy)
|
||||
|
||||
// 发送流式请求
|
||||
await this._makeCcrStreamRequest(
|
||||
modifiedRequestBody,
|
||||
account,
|
||||
proxyAgent,
|
||||
clientHeaders,
|
||||
responseStream,
|
||||
accountId,
|
||||
usageCallback,
|
||||
streamTransformer,
|
||||
options
|
||||
)
|
||||
|
||||
// 更新最后使用时间
|
||||
await this._updateLastUsedTime(accountId)
|
||||
} catch (error) {
|
||||
logger.error(`❌ CCR stream relay failed (Account: ${account?.name || accountId}):`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🌊 发送流式请求到CCR API
|
||||
async _makeCcrStreamRequest(
|
||||
body,
|
||||
account,
|
||||
proxyAgent,
|
||||
clientHeaders,
|
||||
responseStream,
|
||||
accountId,
|
||||
usageCallback,
|
||||
streamTransformer = null,
|
||||
requestOptions = {}
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let aborted = false
|
||||
|
||||
// 构建完整的API URL
|
||||
const cleanUrl = account.apiUrl.replace(/\/$/, '') // 移除末尾斜杠
|
||||
const apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages`
|
||||
|
||||
logger.debug(`🎯 Final API endpoint for stream: ${apiEndpoint}`)
|
||||
|
||||
// 过滤客户端请求头
|
||||
const filteredHeaders = this._filterClientHeaders(clientHeaders)
|
||||
logger.debug(`[DEBUG] Filtered client headers: ${JSON.stringify(filteredHeaders)}`)
|
||||
|
||||
// 决定使用的 User-Agent:优先使用账户自定义的,否则透传客户端的,最后才使用默认值
|
||||
const userAgent =
|
||||
account.userAgent ||
|
||||
clientHeaders?.['user-agent'] ||
|
||||
clientHeaders?.['User-Agent'] ||
|
||||
this.defaultUserAgent
|
||||
|
||||
// 准备请求配置
|
||||
const requestConfig = {
|
||||
method: 'POST',
|
||||
url: apiEndpoint,
|
||||
data: body,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
'User-Agent': userAgent,
|
||||
...filteredHeaders
|
||||
},
|
||||
httpsAgent: proxyAgent,
|
||||
timeout: config.requestTimeout || 600000,
|
||||
responseType: 'stream',
|
||||
validateStatus: () => true // 接受所有状态码
|
||||
}
|
||||
|
||||
// 根据 API Key 格式选择认证方式
|
||||
if (account.apiKey && account.apiKey.startsWith('sk-ant-')) {
|
||||
// Anthropic 官方 API Key 使用 x-api-key
|
||||
requestConfig.headers['x-api-key'] = account.apiKey
|
||||
logger.debug('[DEBUG] Using x-api-key authentication for sk-ant-* API key')
|
||||
} else {
|
||||
// 其他 API Key (包括CCR API Key) 使用 Authorization Bearer
|
||||
requestConfig.headers['Authorization'] = `Bearer ${account.apiKey}`
|
||||
logger.debug('[DEBUG] Using Authorization Bearer authentication')
|
||||
}
|
||||
|
||||
// 添加beta header如果需要
|
||||
if (requestOptions.betaHeader) {
|
||||
requestConfig.headers['anthropic-beta'] = requestOptions.betaHeader
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
const request = axios(requestConfig)
|
||||
|
||||
request
|
||||
.then((response) => {
|
||||
logger.debug(`🌊 CCR stream response status: ${response.status}`)
|
||||
|
||||
// 错误响应处理
|
||||
if (response.status !== 200) {
|
||||
logger.error(
|
||||
`❌ CCR API returned error status: ${response.status} | Account: ${account?.name || accountId}`
|
||||
)
|
||||
|
||||
if (response.status === 401) {
|
||||
ccrAccountService.markAccountUnauthorized(accountId)
|
||||
} else if (response.status === 429) {
|
||||
ccrAccountService.markAccountRateLimited(accountId)
|
||||
// 检查是否因为超过每日额度
|
||||
ccrAccountService.checkQuotaUsage(accountId).catch((err) => {
|
||||
logger.error('❌ Failed to check quota after 429 error:', err)
|
||||
})
|
||||
} else if (response.status === 529) {
|
||||
ccrAccountService.markAccountOverloaded(accountId)
|
||||
}
|
||||
|
||||
// 设置错误响应的状态码和响应头
|
||||
if (!responseStream.headersSent) {
|
||||
const errorHeaders = {
|
||||
'Content-Type': response.headers['content-type'] || 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive'
|
||||
}
|
||||
// 避免 Transfer-Encoding 冲突,让 Express 自动处理
|
||||
delete errorHeaders['Transfer-Encoding']
|
||||
delete errorHeaders['Content-Length']
|
||||
responseStream.writeHead(response.status, errorHeaders)
|
||||
}
|
||||
|
||||
// 直接透传错误数据,不进行包装
|
||||
response.data.on('data', (chunk) => {
|
||||
if (!responseStream.destroyed) {
|
||||
responseStream.write(chunk)
|
||||
}
|
||||
})
|
||||
|
||||
response.data.on('end', () => {
|
||||
if (!responseStream.destroyed) {
|
||||
responseStream.end()
|
||||
}
|
||||
resolve() // 不抛出异常,正常完成流处理
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 成功响应,检查并移除错误状态
|
||||
ccrAccountService.isAccountRateLimited(accountId).then((isRateLimited) => {
|
||||
if (isRateLimited) {
|
||||
ccrAccountService.removeAccountRateLimit(accountId)
|
||||
}
|
||||
})
|
||||
ccrAccountService.isAccountOverloaded(accountId).then((isOverloaded) => {
|
||||
if (isOverloaded) {
|
||||
ccrAccountService.removeAccountOverload(accountId)
|
||||
}
|
||||
})
|
||||
|
||||
// 设置响应头
|
||||
if (!responseStream.headersSent) {
|
||||
const headers = {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Cache-Control'
|
||||
}
|
||||
responseStream.writeHead(200, headers)
|
||||
}
|
||||
|
||||
// 处理流数据和使用统计收集
|
||||
let rawBuffer = ''
|
||||
const collectedUsage = {}
|
||||
|
||||
response.data.on('data', (chunk) => {
|
||||
if (aborted || responseStream.destroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const chunkStr = chunk.toString('utf8')
|
||||
rawBuffer += chunkStr
|
||||
|
||||
// 按行分割处理 SSE 数据
|
||||
const lines = rawBuffer.split('\n')
|
||||
rawBuffer = lines.pop() // 保留最后一个可能不完整的行
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
// 解析 SSE 数据并收集使用统计
|
||||
const usageData = this._parseSSELineForUsage(line)
|
||||
if (usageData) {
|
||||
Object.assign(collectedUsage, usageData)
|
||||
}
|
||||
|
||||
// 应用流转换器(如果提供)
|
||||
let outputLine = line
|
||||
if (streamTransformer && typeof streamTransformer === 'function') {
|
||||
outputLine = streamTransformer(line)
|
||||
}
|
||||
|
||||
// 写入到响应流
|
||||
if (outputLine && !responseStream.destroyed) {
|
||||
responseStream.write(`${outputLine}\n`)
|
||||
}
|
||||
} else {
|
||||
// 空行也需要传递
|
||||
if (!responseStream.destroyed) {
|
||||
responseStream.write('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('❌ Error processing SSE chunk:', err)
|
||||
}
|
||||
})
|
||||
|
||||
response.data.on('end', () => {
|
||||
if (!responseStream.destroyed) {
|
||||
responseStream.end()
|
||||
}
|
||||
|
||||
// 如果收集到使用统计数据,调用回调
|
||||
if (usageCallback && Object.keys(collectedUsage).length > 0) {
|
||||
try {
|
||||
logger.debug(`📊 Collected usage data: ${JSON.stringify(collectedUsage)}`)
|
||||
// 在 usage 回调中包含模型信息
|
||||
usageCallback({ ...collectedUsage, accountId, model: body.model })
|
||||
} catch (err) {
|
||||
logger.error('❌ Error in usage callback:', err)
|
||||
}
|
||||
}
|
||||
|
||||
resolve()
|
||||
})
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
logger.error('❌ Stream data error:', err)
|
||||
if (!responseStream.destroyed) {
|
||||
responseStream.end()
|
||||
}
|
||||
reject(err)
|
||||
})
|
||||
|
||||
// 客户端断开处理
|
||||
responseStream.on('close', () => {
|
||||
logger.info('🔌 Client disconnected from CCR stream')
|
||||
aborted = true
|
||||
if (response.data && typeof response.data.destroy === 'function') {
|
||||
response.data.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
responseStream.on('error', (err) => {
|
||||
logger.error('❌ Response stream error:', err)
|
||||
aborted = true
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!responseStream.headersSent) {
|
||||
responseStream.writeHead(500, { 'Content-Type': 'application/json' })
|
||||
}
|
||||
|
||||
const errorResponse = {
|
||||
error: {
|
||||
type: 'internal_error',
|
||||
message: 'CCR API request failed'
|
||||
}
|
||||
}
|
||||
|
||||
if (!responseStream.destroyed) {
|
||||
responseStream.write(`data: ${JSON.stringify(errorResponse)}\n\n`)
|
||||
responseStream.end()
|
||||
}
|
||||
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 📊 解析SSE行以提取使用统计信息
|
||||
_parseSSELineForUsage(line) {
|
||||
try {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.substring(6).trim()
|
||||
if (data === '[DONE]') {
|
||||
return null
|
||||
}
|
||||
|
||||
const jsonData = JSON.parse(data)
|
||||
|
||||
// 检查是否包含使用统计信息
|
||||
if (jsonData.usage) {
|
||||
return {
|
||||
input_tokens: jsonData.usage.input_tokens || 0,
|
||||
output_tokens: jsonData.usage.output_tokens || 0,
|
||||
cache_creation_input_tokens: jsonData.usage.cache_creation_input_tokens || 0,
|
||||
cache_read_input_tokens: jsonData.usage.cache_read_input_tokens || 0,
|
||||
// 支持 ephemeral cache 字段
|
||||
cache_creation_input_tokens_ephemeral_5m:
|
||||
jsonData.usage.cache_creation_input_tokens_ephemeral_5m || 0,
|
||||
cache_creation_input_tokens_ephemeral_1h:
|
||||
jsonData.usage.cache_creation_input_tokens_ephemeral_1h || 0
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 message_delta 事件中的使用统计
|
||||
if (jsonData.type === 'message_delta' && jsonData.delta && jsonData.delta.usage) {
|
||||
return {
|
||||
input_tokens: jsonData.delta.usage.input_tokens || 0,
|
||||
output_tokens: jsonData.delta.usage.output_tokens || 0,
|
||||
cache_creation_input_tokens: jsonData.delta.usage.cache_creation_input_tokens || 0,
|
||||
cache_read_input_tokens: jsonData.delta.usage.cache_read_input_tokens || 0,
|
||||
cache_creation_input_tokens_ephemeral_5m:
|
||||
jsonData.delta.usage.cache_creation_input_tokens_ephemeral_5m || 0,
|
||||
cache_creation_input_tokens_ephemeral_1h:
|
||||
jsonData.delta.usage.cache_creation_input_tokens_ephemeral_1h || 0
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// 忽略解析错误,不是所有行都包含 JSON
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// 🔍 过滤客户端请求头
|
||||
_filterClientHeaders(clientHeaders) {
|
||||
if (!clientHeaders) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const filteredHeaders = {}
|
||||
const allowedHeaders = [
|
||||
'accept-language',
|
||||
'anthropic-beta',
|
||||
'anthropic-dangerous-direct-browser-access'
|
||||
]
|
||||
|
||||
// 只保留允许的头部信息
|
||||
for (const [key, value] of Object.entries(clientHeaders)) {
|
||||
const lowerKey = key.toLowerCase()
|
||||
if (allowedHeaders.includes(lowerKey)) {
|
||||
filteredHeaders[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return filteredHeaders
|
||||
}
|
||||
|
||||
// ⏰ 更新账户最后使用时间
|
||||
async _updateLastUsedTime(accountId) {
|
||||
try {
|
||||
const redis = require('../models/redis')
|
||||
const client = redis.getClientSafe()
|
||||
await client.hset(`ccr_account:${accountId}`, 'lastUsedAt', new Date().toISOString())
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to update last used time for CCR account ${accountId}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CcrRelayService()
|
||||
@@ -603,6 +603,25 @@ class ClaudeAccountService {
|
||||
|
||||
updatedData.updatedAt = new Date().toISOString()
|
||||
|
||||
// 如果是手动修改调度状态,清除所有自动停止相关的字段
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'schedulable')) {
|
||||
// 清除所有自动停止的标记,防止自动恢复
|
||||
delete updatedData.rateLimitAutoStopped
|
||||
delete updatedData.fiveHourAutoStopped
|
||||
delete updatedData.fiveHourStoppedAt
|
||||
delete updatedData.tempErrorAutoStopped
|
||||
// 兼容旧的标记(逐步迁移)
|
||||
delete updatedData.autoStoppedAt
|
||||
delete updatedData.stoppedReason
|
||||
|
||||
// 如果是手动启用调度,记录日志
|
||||
if (updates.schedulable === true || updates.schedulable === 'true') {
|
||||
logger.info(`✅ Manually enabled scheduling for account ${accountId}`)
|
||||
} else {
|
||||
logger.info(`⛔ Manually disabled scheduling for account ${accountId}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否手动禁用了账号,如果是则发送webhook通知
|
||||
if (updates.isActive === 'false' && accountData.isActive === 'true') {
|
||||
try {
|
||||
@@ -1088,7 +1107,9 @@ class ClaudeAccountService {
|
||||
updatedAccountData.rateLimitedAt = new Date().toISOString()
|
||||
updatedAccountData.rateLimitStatus = 'limited'
|
||||
// 限流时停止调度,与 OpenAI 账号保持一致
|
||||
updatedAccountData.schedulable = false
|
||||
updatedAccountData.schedulable = 'false'
|
||||
// 使用独立的限流自动停止标记,避免与其他自动停止冲突
|
||||
updatedAccountData.rateLimitAutoStopped = 'true'
|
||||
|
||||
// 如果提供了准确的限流重置时间戳(来自API响应头)
|
||||
if (rateLimitResetTimestamp) {
|
||||
@@ -1173,13 +1194,16 @@ class ClaudeAccountService {
|
||||
delete accountData.rateLimitedAt
|
||||
delete accountData.rateLimitStatus
|
||||
delete accountData.rateLimitEndAt // 清除限流结束时间
|
||||
// 恢复可调度状态,与 OpenAI 账号保持一致
|
||||
accountData.schedulable = true
|
||||
|
||||
// 只恢复因限流而自动停止的账户
|
||||
if (accountData.rateLimitAutoStopped === 'true' && accountData.schedulable === 'false') {
|
||||
accountData.schedulable = 'true'
|
||||
delete accountData.rateLimitAutoStopped
|
||||
logger.info(`✅ Auto-resuming scheduling for account ${accountId} after rate limit cleared`)
|
||||
}
|
||||
await redis.setClaudeAccount(accountId, accountData)
|
||||
|
||||
logger.success(
|
||||
`✅ Rate limit removed for account: ${accountData.name} (${accountId}), schedulable restored`
|
||||
)
|
||||
logger.success(`✅ Rate limit removed for account: ${accountData.name} (${accountId})`)
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
@@ -1331,17 +1355,13 @@ class ClaudeAccountService {
|
||||
}
|
||||
|
||||
// 如果账户因为5小时限制被自动停止,现在恢复调度
|
||||
if (
|
||||
accountData.autoStoppedAt &&
|
||||
accountData.schedulable === 'false' &&
|
||||
accountData.stoppedReason === '5小时使用量接近限制,自动停止调度'
|
||||
) {
|
||||
if (accountData.fiveHourAutoStopped === 'true' && accountData.schedulable === 'false') {
|
||||
logger.info(
|
||||
`✅ Auto-resuming scheduling for account ${accountData.name} (${accountId}) - new session window started`
|
||||
)
|
||||
accountData.schedulable = 'true'
|
||||
delete accountData.stoppedReason
|
||||
delete accountData.autoStoppedAt
|
||||
delete accountData.fiveHourAutoStopped
|
||||
delete accountData.fiveHourStoppedAt
|
||||
|
||||
// 发送Webhook通知
|
||||
try {
|
||||
@@ -1823,8 +1843,16 @@ class ClaudeAccountService {
|
||||
updatedAccountData.status = 'created'
|
||||
}
|
||||
|
||||
// 恢复可调度状态
|
||||
// 恢复可调度状态(管理员手动重置时恢复调度是合理的)
|
||||
updatedAccountData.schedulable = 'true'
|
||||
// 清除所有自动停止相关的标记
|
||||
delete updatedAccountData.rateLimitAutoStopped
|
||||
delete updatedAccountData.fiveHourAutoStopped
|
||||
delete updatedAccountData.fiveHourStoppedAt
|
||||
delete updatedAccountData.tempErrorAutoStopped
|
||||
// 兼容旧的标记
|
||||
delete updatedAccountData.autoStoppedAt
|
||||
delete updatedAccountData.stoppedReason
|
||||
|
||||
// 清除错误相关字段
|
||||
delete updatedAccountData.errorMessage
|
||||
@@ -1850,7 +1878,15 @@ class ClaudeAccountService {
|
||||
'rateLimitEndAt',
|
||||
'tempErrorAt',
|
||||
'sessionWindowStart',
|
||||
'sessionWindowEnd'
|
||||
'sessionWindowEnd',
|
||||
// 新的独立标记
|
||||
'rateLimitAutoStopped',
|
||||
'fiveHourAutoStopped',
|
||||
'fiveHourStoppedAt',
|
||||
'tempErrorAutoStopped',
|
||||
// 兼容旧的标记
|
||||
'autoStoppedAt',
|
||||
'stoppedReason'
|
||||
]
|
||||
await redis.client.hdel(`claude:account:${accountId}`, ...fieldsToDelete)
|
||||
|
||||
@@ -1901,13 +1937,22 @@ class ClaudeAccountService {
|
||||
// 如果临时错误状态超过指定时间,尝试重新激活
|
||||
if (minutesSinceTempError > TEMP_ERROR_RECOVERY_MINUTES) {
|
||||
account.status = 'active' // 恢复为 active 状态
|
||||
account.schedulable = 'true' // 恢复为可调度
|
||||
// 只恢复因临时错误而自动停止的账户
|
||||
if (account.tempErrorAutoStopped === 'true') {
|
||||
account.schedulable = 'true' // 恢复为可调度
|
||||
delete account.tempErrorAutoStopped
|
||||
}
|
||||
delete account.errorMessage
|
||||
delete account.tempErrorAt
|
||||
await redis.setClaudeAccount(account.id, account)
|
||||
|
||||
// 显式从 Redis 中删除这些字段(因为 HSET 不会删除现有字段)
|
||||
await redis.client.hdel(`claude:account:${account.id}`, 'errorMessage', 'tempErrorAt')
|
||||
await redis.client.hdel(
|
||||
`claude:account:${account.id}`,
|
||||
'errorMessage',
|
||||
'tempErrorAt',
|
||||
'tempErrorAutoStopped'
|
||||
)
|
||||
|
||||
// 同时清除500错误计数
|
||||
await this.clearInternalErrors(account.id)
|
||||
@@ -1992,6 +2037,8 @@ class ClaudeAccountService {
|
||||
updatedAccountData.schedulable = 'false' // 设置为不可调度
|
||||
updatedAccountData.errorMessage = 'Account temporarily disabled due to consecutive 500 errors'
|
||||
updatedAccountData.tempErrorAt = new Date().toISOString()
|
||||
// 使用独立的临时错误自动停止标记
|
||||
updatedAccountData.tempErrorAutoStopped = 'true'
|
||||
|
||||
// 保存更新后的账户数据
|
||||
await redis.setClaudeAccount(accountId, updatedAccountData)
|
||||
@@ -2010,7 +2057,11 @@ class ClaudeAccountService {
|
||||
if (minutesSince >= 5) {
|
||||
// 恢复账户
|
||||
account.status = 'active'
|
||||
account.schedulable = 'true'
|
||||
// 只恢复因临时错误而自动停止的账户
|
||||
if (account.tempErrorAutoStopped === 'true') {
|
||||
account.schedulable = 'true'
|
||||
delete account.tempErrorAutoStopped
|
||||
}
|
||||
delete account.errorMessage
|
||||
delete account.tempErrorAt
|
||||
|
||||
@@ -2020,7 +2071,8 @@ class ClaudeAccountService {
|
||||
await redis.client.hdel(
|
||||
`claude:account:${accountId}`,
|
||||
'errorMessage',
|
||||
'tempErrorAt'
|
||||
'tempErrorAt',
|
||||
'tempErrorAutoStopped'
|
||||
)
|
||||
|
||||
// 清除 500 错误计数
|
||||
@@ -2108,8 +2160,9 @@ class ClaudeAccountService {
|
||||
`⚠️ Account ${accountData.name} (${accountId}) approaching 5h limit, auto-stopping scheduling`
|
||||
)
|
||||
accountData.schedulable = 'false'
|
||||
accountData.stoppedReason = '5小时使用量接近限制,自动停止调度'
|
||||
accountData.autoStoppedAt = new Date().toISOString()
|
||||
// 使用独立的5小时限制自动停止标记
|
||||
accountData.fiveHourAutoStopped = 'true'
|
||||
accountData.fiveHourStoppedAt = new Date().toISOString()
|
||||
|
||||
// 发送Webhook通知
|
||||
try {
|
||||
@@ -2239,6 +2292,178 @@ class ClaudeAccountService {
|
||||
// 不抛出错误,移除过载状态失败不应该影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并恢复因5小时限制被自动停止的账号
|
||||
* 用于定时任务自动恢复
|
||||
* @returns {Promise<{checked: number, recovered: number, accounts: Array}>}
|
||||
*/
|
||||
async checkAndRecoverFiveHourStoppedAccounts() {
|
||||
const result = {
|
||||
checked: 0,
|
||||
recovered: 0,
|
||||
accounts: []
|
||||
}
|
||||
|
||||
try {
|
||||
const accounts = await this.getAllAccounts()
|
||||
const now = new Date()
|
||||
|
||||
for (const account of accounts) {
|
||||
// 只检查因5小时限制被自动停止的账号
|
||||
// 重要:不恢复手动停止的账号(没有fiveHourAutoStopped标记的)
|
||||
if (account.fiveHourAutoStopped === 'true' && account.schedulable === 'false') {
|
||||
result.checked++
|
||||
|
||||
// 使用分布式锁防止并发修改
|
||||
const lockKey = `lock:account:${account.id}:recovery`
|
||||
const lockValue = `${Date.now()}_${Math.random()}`
|
||||
const lockTTL = 5000 // 5秒锁超时
|
||||
|
||||
try {
|
||||
// 尝试获取锁
|
||||
const lockAcquired = await redis.setAccountLock(lockKey, lockValue, lockTTL)
|
||||
if (!lockAcquired) {
|
||||
logger.debug(
|
||||
`⏭️ Account ${account.name} (${account.id}) is being processed by another instance`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// 重新获取账号数据,确保是最新的
|
||||
const latestAccount = await redis.getClaudeAccount(account.id)
|
||||
if (
|
||||
!latestAccount ||
|
||||
latestAccount.fiveHourAutoStopped !== 'true' ||
|
||||
latestAccount.schedulable !== 'false'
|
||||
) {
|
||||
// 账号状态已变化,跳过
|
||||
await redis.releaseAccountLock(lockKey, lockValue)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查当前时间是否已经进入新的5小时窗口
|
||||
let shouldRecover = false
|
||||
let newWindowStart = null
|
||||
let newWindowEnd = null
|
||||
|
||||
if (latestAccount.sessionWindowEnd) {
|
||||
const windowEnd = new Date(latestAccount.sessionWindowEnd)
|
||||
|
||||
// 使用严格的时间比较,添加1分钟缓冲避免边界问题
|
||||
if (now.getTime() > windowEnd.getTime() + 60000) {
|
||||
shouldRecover = true
|
||||
|
||||
// 计算新的窗口时间(基于窗口结束时间,而不是当前时间)
|
||||
// 这样可以保证窗口时间的连续性
|
||||
newWindowStart = new Date(windowEnd)
|
||||
newWindowStart.setMilliseconds(newWindowStart.getMilliseconds() + 1)
|
||||
newWindowEnd = new Date(newWindowStart)
|
||||
newWindowEnd.setHours(newWindowEnd.getHours() + 5)
|
||||
|
||||
logger.info(
|
||||
`🔄 Account ${latestAccount.name} (${latestAccount.id}) has entered new session window. ` +
|
||||
`Old window: ${latestAccount.sessionWindowStart} - ${latestAccount.sessionWindowEnd}, ` +
|
||||
`New window: ${newWindowStart.toISOString()} - ${newWindowEnd.toISOString()}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// 如果没有窗口结束时间,但有停止时间,检查是否已经过了5小时
|
||||
if (latestAccount.fiveHourStoppedAt) {
|
||||
const stoppedAt = new Date(latestAccount.fiveHourStoppedAt)
|
||||
const hoursSinceStopped = (now.getTime() - stoppedAt.getTime()) / (1000 * 60 * 60)
|
||||
|
||||
// 使用严格的5小时判断,加上1分钟缓冲
|
||||
if (hoursSinceStopped > 5.017) {
|
||||
// 5小时1分钟
|
||||
shouldRecover = true
|
||||
newWindowStart = this._calculateSessionWindowStart(now)
|
||||
newWindowEnd = this._calculateSessionWindowEnd(newWindowStart)
|
||||
|
||||
logger.info(
|
||||
`🔄 Account ${latestAccount.name} (${latestAccount.id}) stopped ${hoursSinceStopped.toFixed(2)} hours ago, recovering`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRecover) {
|
||||
// 恢复账号调度
|
||||
const updatedAccountData = { ...latestAccount }
|
||||
|
||||
// 恢复调度状态
|
||||
updatedAccountData.schedulable = 'true'
|
||||
delete updatedAccountData.fiveHourAutoStopped
|
||||
delete updatedAccountData.fiveHourStoppedAt
|
||||
|
||||
// 更新会话窗口(如果有新窗口)
|
||||
if (newWindowStart && newWindowEnd) {
|
||||
updatedAccountData.sessionWindowStart = newWindowStart.toISOString()
|
||||
updatedAccountData.sessionWindowEnd = newWindowEnd.toISOString()
|
||||
|
||||
// 清除会话窗口状态
|
||||
delete updatedAccountData.sessionWindowStatus
|
||||
delete updatedAccountData.sessionWindowStatusUpdatedAt
|
||||
}
|
||||
|
||||
// 保存更新
|
||||
await redis.setClaudeAccount(account.id, updatedAccountData)
|
||||
|
||||
result.recovered++
|
||||
result.accounts.push({
|
||||
id: latestAccount.id,
|
||||
name: latestAccount.name,
|
||||
oldWindow: latestAccount.sessionWindowEnd
|
||||
? {
|
||||
start: latestAccount.sessionWindowStart,
|
||||
end: latestAccount.sessionWindowEnd
|
||||
}
|
||||
: null,
|
||||
newWindow:
|
||||
newWindowStart && newWindowEnd
|
||||
? {
|
||||
start: newWindowStart.toISOString(),
|
||||
end: newWindowEnd.toISOString()
|
||||
}
|
||||
: null
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`✅ Auto-resumed scheduling for account ${latestAccount.name} (${latestAccount.id}) - 5-hour limit expired`
|
||||
)
|
||||
}
|
||||
|
||||
// 释放锁
|
||||
await redis.releaseAccountLock(lockKey, lockValue)
|
||||
} catch (error) {
|
||||
// 确保释放锁
|
||||
if (lockKey && lockValue) {
|
||||
try {
|
||||
await redis.releaseAccountLock(lockKey, lockValue)
|
||||
} catch (unlockError) {
|
||||
logger.error(`Failed to release lock for account ${account.id}:`, unlockError)
|
||||
}
|
||||
}
|
||||
logger.error(
|
||||
`❌ Failed to check/recover 5-hour stopped account ${account.name} (${account.id}):`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.recovered > 0) {
|
||||
logger.info(
|
||||
`🔄 5-hour limit recovery completed: ${result.recovered}/${result.checked} accounts recovered`
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to check and recover 5-hour stopped accounts:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ClaudeAccountService()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -79,34 +79,6 @@ class ClaudeRelayService {
|
||||
requestedModel: requestBody.model
|
||||
})
|
||||
|
||||
// 检查模型限制
|
||||
if (
|
||||
apiKeyData.enableModelRestriction &&
|
||||
apiKeyData.restrictedModels &&
|
||||
apiKeyData.restrictedModels.length > 0
|
||||
) {
|
||||
const requestedModel = requestBody.model
|
||||
logger.info(
|
||||
`🔒 Model restriction check - Requested model: ${requestedModel}, Restricted models: ${JSON.stringify(apiKeyData.restrictedModels)}`
|
||||
)
|
||||
|
||||
if (requestedModel && apiKeyData.restrictedModels.includes(requestedModel)) {
|
||||
logger.warn(
|
||||
`🚫 Model restriction violation for key ${apiKeyData.name}: Attempted to use restricted model ${requestedModel}`
|
||||
)
|
||||
return {
|
||||
statusCode: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
type: 'forbidden',
|
||||
message: '暂无该模型访问权限'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生成会话哈希用于sticky会话
|
||||
const sessionHash = sessionHelper.generateSessionHash(requestBody)
|
||||
|
||||
@@ -629,8 +601,30 @@ class ClaudeRelayService {
|
||||
'transfer-encoding'
|
||||
]
|
||||
|
||||
// 🆕 需要移除的浏览器相关 headers(避免CORS问题)
|
||||
const browserHeaders = [
|
||||
'origin',
|
||||
'referer',
|
||||
'sec-fetch-mode',
|
||||
'sec-fetch-site',
|
||||
'sec-fetch-dest',
|
||||
'sec-ch-ua',
|
||||
'sec-ch-ua-mobile',
|
||||
'sec-ch-ua-platform',
|
||||
'accept-language',
|
||||
'accept-encoding',
|
||||
'accept',
|
||||
'cache-control',
|
||||
'pragma',
|
||||
'anthropic-dangerous-direct-browser-access' // 这个头可能触发CORS检查
|
||||
]
|
||||
|
||||
// 应该保留的 headers(用于会话一致性和追踪)
|
||||
const allowedHeaders = ['x-request-id']
|
||||
const allowedHeaders = [
|
||||
'x-request-id',
|
||||
'anthropic-version', // 保留API版本
|
||||
'anthropic-beta' // 保留beta功能
|
||||
]
|
||||
|
||||
const filteredHeaders = {}
|
||||
|
||||
@@ -641,8 +635,8 @@ class ClaudeRelayService {
|
||||
if (allowedHeaders.includes(lowerKey)) {
|
||||
filteredHeaders[key] = clientHeaders[key]
|
||||
}
|
||||
// 如果不在敏感列表中,也保留
|
||||
else if (!sensitiveHeaders.includes(lowerKey)) {
|
||||
// 如果不在敏感列表和浏览器列表中,也保留
|
||||
else if (!sensitiveHeaders.includes(lowerKey) && !browserHeaders.includes(lowerKey)) {
|
||||
filteredHeaders[key] = clientHeaders[key]
|
||||
}
|
||||
})
|
||||
@@ -844,36 +838,6 @@ class ClaudeRelayService {
|
||||
requestedModel: requestBody.model
|
||||
})
|
||||
|
||||
// 检查模型限制
|
||||
if (
|
||||
apiKeyData.enableModelRestriction &&
|
||||
apiKeyData.restrictedModels &&
|
||||
apiKeyData.restrictedModels.length > 0
|
||||
) {
|
||||
const requestedModel = requestBody.model
|
||||
logger.info(
|
||||
`🔒 [Stream] Model restriction check - Requested model: ${requestedModel}, Restricted models: ${JSON.stringify(apiKeyData.restrictedModels)}`
|
||||
)
|
||||
|
||||
if (requestedModel && apiKeyData.restrictedModels.includes(requestedModel)) {
|
||||
logger.warn(
|
||||
`🚫 Model restriction violation for key ${apiKeyData.name}: Attempted to use restricted model ${requestedModel}`
|
||||
)
|
||||
|
||||
// 对于流式响应,需要写入错误并结束流
|
||||
const errorResponse = JSON.stringify({
|
||||
error: {
|
||||
type: 'forbidden',
|
||||
message: '暂无该模型访问权限'
|
||||
}
|
||||
})
|
||||
|
||||
responseStream.writeHead(403, { 'Content-Type': 'application/json' })
|
||||
responseStream.end(errorResponse)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 生成会话哈希用于sticky会话
|
||||
const sessionHash = sessionHelper.generateSessionHash(requestBody)
|
||||
|
||||
|
||||
@@ -1290,13 +1290,17 @@ async function generateContent(
|
||||
// 按照 gemini-cli 的转换格式构造请求
|
||||
const request = {
|
||||
model: requestData.model,
|
||||
user_prompt_id: userPromptId,
|
||||
request: {
|
||||
...requestData.request,
|
||||
session_id: sessionId
|
||||
}
|
||||
}
|
||||
|
||||
// 只有当 userPromptId 存在时才添加
|
||||
if (userPromptId) {
|
||||
request.user_prompt_id = userPromptId
|
||||
}
|
||||
|
||||
// 只有当projectId存在时才添加project字段
|
||||
if (projectId) {
|
||||
request.project = projectId
|
||||
@@ -1309,6 +1313,12 @@ async function generateContent(
|
||||
sessionId
|
||||
})
|
||||
|
||||
// 添加详细的请求日志
|
||||
logger.info('📦 generateContent 请求详情', {
|
||||
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:generateContent`,
|
||||
requestBody: JSON.stringify(request, null, 2)
|
||||
})
|
||||
|
||||
const axiosConfig = {
|
||||
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:generateContent`,
|
||||
method: 'POST',
|
||||
@@ -1356,13 +1366,17 @@ async function generateContentStream(
|
||||
// 按照 gemini-cli 的转换格式构造请求
|
||||
const request = {
|
||||
model: requestData.model,
|
||||
user_prompt_id: userPromptId,
|
||||
request: {
|
||||
...requestData.request,
|
||||
session_id: sessionId
|
||||
}
|
||||
}
|
||||
|
||||
// 只有当 userPromptId 存在时才添加
|
||||
if (userPromptId) {
|
||||
request.user_prompt_id = userPromptId
|
||||
}
|
||||
|
||||
// 只有当projectId存在时才添加project字段
|
||||
if (projectId) {
|
||||
request.project = projectId
|
||||
|
||||
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()
|
||||
@@ -215,6 +215,39 @@ class RateLimitCleanupService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查并恢复因5小时限制被自动停止的账号
|
||||
try {
|
||||
const fiveHourResult = await claudeAccountService.checkAndRecoverFiveHourStoppedAccounts()
|
||||
|
||||
if (fiveHourResult.recovered > 0) {
|
||||
// 将5小时限制恢复的账号也加入到已清理账户列表中,用于发送通知
|
||||
for (const account of fiveHourResult.accounts) {
|
||||
this.clearedAccounts.push({
|
||||
platform: 'Claude',
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
previousStatus: '5hour_limited',
|
||||
currentStatus: 'active',
|
||||
windowInfo: account.newWindow
|
||||
})
|
||||
}
|
||||
|
||||
// 更新统计数据
|
||||
result.checked += fiveHourResult.checked
|
||||
result.cleared += fiveHourResult.recovered
|
||||
|
||||
logger.info(
|
||||
`🕐 Claude 5-hour limit recovery: ${fiveHourResult.recovered}/${fiveHourResult.checked} accounts recovered`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to check and recover 5-hour stopped Claude accounts:', error)
|
||||
result.errors.push({
|
||||
type: '5hour_recovery',
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup Claude accounts:', error)
|
||||
result.errors.push({ error: error.message })
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
const claudeAccountService = require('./claudeAccountService')
|
||||
const claudeConsoleAccountService = require('./claudeConsoleAccountService')
|
||||
const bedrockAccountService = require('./bedrockAccountService')
|
||||
const ccrAccountService = require('./ccrAccountService')
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const { parseVendorPrefixedModel } = require('../utils/modelHelper')
|
||||
|
||||
class UnifiedClaudeScheduler {
|
||||
constructor() {
|
||||
@@ -88,12 +90,53 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
// CCR 账户的模型支持检查
|
||||
if (accountType === 'ccr' && account.supportedModels) {
|
||||
// 兼容旧格式(数组)和新格式(对象)
|
||||
if (Array.isArray(account.supportedModels)) {
|
||||
// 旧格式:数组
|
||||
if (
|
||||
account.supportedModels.length > 0 &&
|
||||
!account.supportedModels.includes(requestedModel)
|
||||
) {
|
||||
logger.info(
|
||||
`🚫 CCR account ${account.name} does not support model ${requestedModel}${context ? ` ${context}` : ''}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
} else if (typeof account.supportedModels === 'object') {
|
||||
// 新格式:映射表
|
||||
if (
|
||||
Object.keys(account.supportedModels).length > 0 &&
|
||||
!ccrAccountService.isModelSupported(account.supportedModels, requestedModel)
|
||||
) {
|
||||
logger.info(
|
||||
`🚫 CCR account ${account.name} does not support model ${requestedModel}${context ? ` ${context}` : ''}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 🎯 统一调度Claude账号(官方和Console)
|
||||
async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
|
||||
try {
|
||||
// 解析供应商前缀
|
||||
const { vendor, baseModel } = parseVendorPrefixedModel(requestedModel)
|
||||
const effectiveModel = vendor === 'ccr' ? baseModel : requestedModel
|
||||
|
||||
logger.debug(
|
||||
`🔍 Model parsing - Original: ${requestedModel}, Vendor: ${vendor}, Effective: ${effectiveModel}`
|
||||
)
|
||||
|
||||
// 如果是 CCR 前缀,只在 CCR 账户池中选择
|
||||
if (vendor === 'ccr') {
|
||||
logger.info(`🎯 CCR vendor prefix detected, routing to CCR accounts only`)
|
||||
return await this._selectCcrAccount(apiKeyData, sessionHash, effectiveModel)
|
||||
}
|
||||
// 如果API Key绑定了专属账户或分组,优先使用
|
||||
if (apiKeyData.claudeAccountId) {
|
||||
// 检查是否是分组
|
||||
@@ -102,7 +145,12 @@ class UnifiedClaudeScheduler {
|
||||
logger.info(
|
||||
`🎯 API key ${apiKeyData.name} is bound to group ${groupId}, selecting from group`
|
||||
)
|
||||
return await this.selectAccountFromGroup(groupId, sessionHash, requestedModel)
|
||||
return await this.selectAccountFromGroup(
|
||||
groupId,
|
||||
sessionHash,
|
||||
effectiveModel,
|
||||
vendor === 'ccr'
|
||||
)
|
||||
}
|
||||
|
||||
// 普通专属账户
|
||||
@@ -176,40 +224,54 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
// CCR 账户不支持绑定(仅通过 ccr, 前缀进行 CCR 路由)
|
||||
|
||||
// 如果有会话哈希,检查是否有已映射的账户
|
||||
if (sessionHash) {
|
||||
const mappedAccount = await this._getSessionMapping(sessionHash)
|
||||
if (mappedAccount) {
|
||||
// 验证映射的账户是否仍然可用
|
||||
const isAvailable = await this._isAccountAvailable(
|
||||
mappedAccount.accountId,
|
||||
mappedAccount.accountType,
|
||||
requestedModel
|
||||
)
|
||||
if (isAvailable) {
|
||||
// 🚀 智能会话续期:剩余时间少于14天时自动续期到15天
|
||||
await redis.extendSessionAccountMappingTTL(sessionHash)
|
||||
// 当本次请求不是 CCR 前缀时,不允许使用指向 CCR 的粘性会话映射
|
||||
if (vendor !== 'ccr' && mappedAccount.accountType === 'ccr') {
|
||||
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`
|
||||
`ℹ️ Skipping CCR sticky session mapping for non-CCR request; removing mapping for session ${sessionHash}`
|
||||
)
|
||||
await this._deleteSessionMapping(sessionHash)
|
||||
} else {
|
||||
// 验证映射的账户是否仍然可用
|
||||
const isAvailable = await this._isAccountAvailable(
|
||||
mappedAccount.accountId,
|
||||
mappedAccount.accountType,
|
||||
effectiveModel
|
||||
)
|
||||
if (isAvailable) {
|
||||
// 🚀 智能会话续期:剩余时间少于14天时自动续期到15天(续期正确的 unified 映射键)
|
||||
await this._extendSessionMappingTTL(sessionHash)
|
||||
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)
|
||||
const availableAccounts = await this._getAllAvailableAccounts(
|
||||
apiKeyData,
|
||||
effectiveModel,
|
||||
false // 仅前缀才走 CCR:默认池不包含 CCR 账户
|
||||
)
|
||||
|
||||
if (availableAccounts.length === 0) {
|
||||
// 提供更详细的错误信息
|
||||
if (requestedModel) {
|
||||
if (effectiveModel) {
|
||||
throw new Error(
|
||||
`No available Claude accounts support the requested model: ${requestedModel}`
|
||||
`No available Claude accounts support the requested model: ${effectiveModel}`
|
||||
)
|
||||
} else {
|
||||
throw new Error('No available Claude accounts (neither official nor console)')
|
||||
@@ -249,7 +311,7 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
|
||||
// 📋 获取所有可用账户(合并官方和Console)
|
||||
async _getAllAvailableAccounts(apiKeyData, requestedModel = null) {
|
||||
async _getAllAvailableAccounts(apiKeyData, requestedModel = null, includeCcr = false) {
|
||||
const availableAccounts = []
|
||||
|
||||
// 如果API Key绑定了专属账户,优先返回
|
||||
@@ -496,8 +558,60 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取CCR账户(共享池)- 仅当明确要求包含时
|
||||
if (includeCcr) {
|
||||
const ccrAccounts = await ccrAccountService.getAllAccounts()
|
||||
logger.info(`📋 Found ${ccrAccounts.length} total CCR accounts`)
|
||||
|
||||
for (const account of ccrAccounts) {
|
||||
logger.info(
|
||||
`🔍 Checking CCR account: ${account.name} - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}`
|
||||
)
|
||||
|
||||
if (
|
||||
account.isActive === true &&
|
||||
account.status === 'active' &&
|
||||
account.accountType === 'shared' &&
|
||||
this._isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 检查模型支持
|
||||
if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否被限流
|
||||
const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id)
|
||||
const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id)
|
||||
|
||||
if (!isRateLimited && !isQuotaExceeded) {
|
||||
availableAccounts.push({
|
||||
...account,
|
||||
accountId: account.id,
|
||||
accountType: 'ccr',
|
||||
priority: parseInt(account.priority) || 50,
|
||||
lastUsedAt: account.lastUsedAt || '0'
|
||||
})
|
||||
logger.info(
|
||||
`✅ Added CCR account to available pool: ${account.name} (priority: ${account.priority})`
|
||||
)
|
||||
} else {
|
||||
if (isRateLimited) {
|
||||
logger.warn(`⚠️ CCR account ${account.name} is rate limited`)
|
||||
}
|
||||
if (isQuotaExceeded) {
|
||||
logger.warn(`💰 CCR account ${account.name} quota exceeded`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
`❌ CCR account ${account.name} not eligible - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`📊 Total available accounts: ${availableAccounts.length} (Claude: ${availableAccounts.filter((a) => a.accountType === 'claude-official').length}, Console: ${availableAccounts.filter((a) => a.accountType === 'claude-console').length}, Bedrock: ${availableAccounts.filter((a) => a.accountType === 'bedrock').length})`
|
||||
`📊 Total available accounts: ${availableAccounts.length} (Claude: ${availableAccounts.filter((a) => a.accountType === 'claude-official').length}, Console: ${availableAccounts.filter((a) => a.accountType === 'claude-console').length}, Bedrock: ${availableAccounts.filter((a) => a.accountType === 'bedrock').length}, CCR: ${availableAccounts.filter((a) => a.accountType === 'ccr').length})`
|
||||
)
|
||||
return availableAccounts
|
||||
}
|
||||
@@ -617,6 +731,52 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
// Bedrock账户暂不需要限流检查,因为AWS管理限流
|
||||
return true
|
||||
} else if (accountType === 'ccr') {
|
||||
const account = await ccrAccountService.getAccount(accountId)
|
||||
if (!account || !account.isActive) {
|
||||
return false
|
||||
}
|
||||
// 检查账户状态
|
||||
if (
|
||||
account.status !== 'active' &&
|
||||
account.status !== 'unauthorized' &&
|
||||
account.status !== 'overloaded'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// 检查是否可调度
|
||||
if (!this._isSchedulable(account.schedulable)) {
|
||||
logger.info(`🚫 CCR account ${accountId} is not schedulable`)
|
||||
return false
|
||||
}
|
||||
// 检查模型支持
|
||||
if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel, 'in session check')) {
|
||||
return false
|
||||
}
|
||||
// 检查是否超额
|
||||
try {
|
||||
await ccrAccountService.checkQuotaUsage(accountId)
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to check quota for CCR account ${accountId}: ${e.message}`)
|
||||
// 继续处理
|
||||
}
|
||||
|
||||
// 检查是否被限流
|
||||
if (await ccrAccountService.isAccountRateLimited(accountId)) {
|
||||
return false
|
||||
}
|
||||
if (await ccrAccountService.isAccountQuotaExceeded(accountId)) {
|
||||
return false
|
||||
}
|
||||
// 检查是否未授权(401错误)
|
||||
if (account.status === 'unauthorized') {
|
||||
return false
|
||||
}
|
||||
// 检查是否过载(529错误)
|
||||
if (await ccrAccountService.isAccountOverloaded(accountId)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
@@ -646,9 +806,11 @@ class UnifiedClaudeScheduler {
|
||||
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)
|
||||
// 依据配置设置TTL(小时)
|
||||
const appConfig = require('../../config/config')
|
||||
const ttlHours = appConfig.session?.stickyTtlHours || 1
|
||||
const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60))
|
||||
await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, ttlSeconds, mappingData)
|
||||
}
|
||||
|
||||
// 🗑️ 删除会话映射
|
||||
@@ -657,6 +819,50 @@ class UnifiedClaudeScheduler {
|
||||
await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`)
|
||||
}
|
||||
|
||||
// 🔁 续期统一调度会话映射TTL(针对 unified_claude_session_mapping:* 键),遵循会话配置
|
||||
async _extendSessionMappingTTL(sessionHash) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const key = `${this.SESSION_MAPPING_PREFIX}${sessionHash}`
|
||||
const remainingTTL = await client.ttl(key)
|
||||
|
||||
// -2: key 不存在;-1: 无过期时间
|
||||
if (remainingTTL === -2) {
|
||||
return false
|
||||
}
|
||||
if (remainingTTL === -1) {
|
||||
return true
|
||||
}
|
||||
|
||||
const appConfig = require('../../config/config')
|
||||
const ttlHours = appConfig.session?.stickyTtlHours || 1
|
||||
const renewalThresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0
|
||||
|
||||
// 阈值为0则不续期
|
||||
if (!renewalThresholdMinutes) {
|
||||
return true
|
||||
}
|
||||
|
||||
const fullTTL = Math.max(1, Math.floor(ttlHours * 60 * 60))
|
||||
const threshold = Math.max(0, Math.floor(renewalThresholdMinutes * 60))
|
||||
|
||||
if (remainingTTL < threshold) {
|
||||
await client.expire(key, fullTTL)
|
||||
logger.debug(
|
||||
`🔄 Renewed unified session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 60)}m, renewed to ${ttlHours}h)`
|
||||
)
|
||||
} else {
|
||||
logger.debug(
|
||||
`✅ Unified session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 60)}m)`
|
||||
)
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to extend unified session TTL:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 标记账户为限流状态
|
||||
async markAccountRateLimited(
|
||||
accountId,
|
||||
@@ -673,6 +879,8 @@ class UnifiedClaudeScheduler {
|
||||
)
|
||||
} else if (accountType === 'claude-console') {
|
||||
await claudeConsoleAccountService.markAccountRateLimited(accountId)
|
||||
} else if (accountType === 'ccr') {
|
||||
await ccrAccountService.markAccountRateLimited(accountId)
|
||||
}
|
||||
|
||||
// 删除会话映射
|
||||
@@ -697,6 +905,8 @@ class UnifiedClaudeScheduler {
|
||||
await claudeAccountService.removeAccountRateLimit(accountId)
|
||||
} else if (accountType === 'claude-console') {
|
||||
await claudeConsoleAccountService.removeAccountRateLimit(accountId)
|
||||
} else if (accountType === 'ccr') {
|
||||
await ccrAccountService.removeAccountRateLimit(accountId)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
@@ -716,6 +926,8 @@ class UnifiedClaudeScheduler {
|
||||
return await claudeAccountService.isAccountRateLimited(accountId)
|
||||
} else if (accountType === 'claude-console') {
|
||||
return await claudeConsoleAccountService.isAccountRateLimited(accountId)
|
||||
} else if (accountType === 'ccr') {
|
||||
return await ccrAccountService.isAccountRateLimited(accountId)
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
@@ -791,7 +1003,12 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
|
||||
// 👥 从分组中选择账户
|
||||
async selectAccountFromGroup(groupId, sessionHash = null, requestedModel = null) {
|
||||
async selectAccountFromGroup(
|
||||
groupId,
|
||||
sessionHash = null,
|
||||
requestedModel = null,
|
||||
allowCcr = false
|
||||
) {
|
||||
try {
|
||||
// 获取分组信息
|
||||
const group = await accountGroupService.getGroup(groupId)
|
||||
@@ -808,18 +1025,23 @@ class UnifiedClaudeScheduler {
|
||||
// 验证映射的账户是否属于这个分组
|
||||
const memberIds = await accountGroupService.getGroupMembers(groupId)
|
||||
if (memberIds.includes(mappedAccount.accountId)) {
|
||||
const isAvailable = await this._isAccountAvailable(
|
||||
mappedAccount.accountId,
|
||||
mappedAccount.accountType,
|
||||
requestedModel
|
||||
)
|
||||
if (isAvailable) {
|
||||
// 🚀 智能会话续期:剩余时间少于14天时自动续期到15天
|
||||
await redis.extendSessionAccountMappingTTL(sessionHash)
|
||||
logger.info(
|
||||
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
||||
// 非 CCR 请求时不允许 CCR 粘性映射
|
||||
if (!allowCcr && mappedAccount.accountType === 'ccr') {
|
||||
await this._deleteSessionMapping(sessionHash)
|
||||
} else {
|
||||
const isAvailable = await this._isAccountAvailable(
|
||||
mappedAccount.accountId,
|
||||
mappedAccount.accountType,
|
||||
requestedModel
|
||||
)
|
||||
return mappedAccount
|
||||
if (isAvailable) {
|
||||
// 🚀 智能会话续期:续期 unified 映射键
|
||||
await this._extendSessionMappingTTL(sessionHash)
|
||||
logger.info(
|
||||
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
||||
)
|
||||
return mappedAccount
|
||||
}
|
||||
}
|
||||
}
|
||||
// 如果映射的账户不可用或不在分组中,删除映射
|
||||
@@ -851,6 +1073,14 @@ class UnifiedClaudeScheduler {
|
||||
account = await claudeConsoleAccountService.getAccount(memberId)
|
||||
if (account) {
|
||||
accountType = 'claude-console'
|
||||
} else {
|
||||
// 尝试CCR账户(仅允许在 allowCcr 为 true 时)
|
||||
if (allowCcr) {
|
||||
account = await ccrAccountService.getAccount(memberId)
|
||||
if (account) {
|
||||
accountType = 'ccr'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (group.platform === 'gemini') {
|
||||
@@ -873,7 +1103,9 @@ class UnifiedClaudeScheduler {
|
||||
const status =
|
||||
accountType === 'claude-official'
|
||||
? account.status !== 'error' && account.status !== 'blocked'
|
||||
: account.status === 'active'
|
||||
: accountType === 'ccr'
|
||||
? account.status === 'active'
|
||||
: account.status === 'active'
|
||||
|
||||
if (isActive && status && this._isSchedulable(account.schedulable)) {
|
||||
// 检查模型支持
|
||||
@@ -930,6 +1162,133 @@ class UnifiedClaudeScheduler {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🎯 专门选择CCR账户(仅限CCR前缀路由使用)
|
||||
async _selectCcrAccount(apiKeyData, sessionHash = null, effectiveModel = null) {
|
||||
try {
|
||||
// 1. 检查会话粘性
|
||||
if (sessionHash) {
|
||||
const mappedAccount = await this._getSessionMapping(sessionHash)
|
||||
if (mappedAccount && mappedAccount.accountType === 'ccr') {
|
||||
// 验证映射的CCR账户是否仍然可用
|
||||
const isAvailable = await this._isAccountAvailable(
|
||||
mappedAccount.accountId,
|
||||
mappedAccount.accountType,
|
||||
effectiveModel
|
||||
)
|
||||
if (isAvailable) {
|
||||
// 🚀 智能会话续期:续期 unified 映射键
|
||||
await this._extendSessionMappingTTL(sessionHash)
|
||||
logger.info(
|
||||
`🎯 Using sticky CCR session account: ${mappedAccount.accountId} for session ${sessionHash}`
|
||||
)
|
||||
return mappedAccount
|
||||
} else {
|
||||
logger.warn(
|
||||
`⚠️ Mapped CCR account ${mappedAccount.accountId} is no longer available, selecting new account`
|
||||
)
|
||||
await this._deleteSessionMapping(sessionHash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 获取所有可用的CCR账户
|
||||
const availableCcrAccounts = await this._getAvailableCcrAccounts(effectiveModel)
|
||||
|
||||
if (availableCcrAccounts.length === 0) {
|
||||
throw new Error(
|
||||
`No available CCR accounts support the requested model: ${effectiveModel || 'unspecified'}`
|
||||
)
|
||||
}
|
||||
|
||||
// 3. 按优先级和最后使用时间排序
|
||||
const sortedAccounts = this._sortAccountsByPriority(availableCcrAccounts)
|
||||
const selectedAccount = sortedAccounts[0]
|
||||
|
||||
// 4. 建立会话映射
|
||||
if (sessionHash) {
|
||||
await this._setSessionMapping(
|
||||
sessionHash,
|
||||
selectedAccount.accountId,
|
||||
selectedAccount.accountType
|
||||
)
|
||||
logger.info(
|
||||
`🎯 Created new sticky CCR session mapping: ${selectedAccount.name} (${selectedAccount.accountId}) for session ${sessionHash}`
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🎯 Selected CCR account: ${selectedAccount.name} (${selectedAccount.accountId}) with priority ${selectedAccount.priority} for API key ${apiKeyData.name}`
|
||||
)
|
||||
|
||||
return {
|
||||
accountId: selectedAccount.accountId,
|
||||
accountType: selectedAccount.accountType
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to select CCR account:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 📋 获取所有可用的CCR账户
|
||||
async _getAvailableCcrAccounts(requestedModel = null) {
|
||||
const availableAccounts = []
|
||||
|
||||
try {
|
||||
const ccrAccounts = await ccrAccountService.getAllAccounts()
|
||||
logger.info(`📋 Found ${ccrAccounts.length} total CCR accounts for CCR-only selection`)
|
||||
|
||||
for (const account of ccrAccounts) {
|
||||
logger.debug(
|
||||
`🔍 Checking CCR account: ${account.name} - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}`
|
||||
)
|
||||
|
||||
if (
|
||||
account.isActive === true &&
|
||||
account.status === 'active' &&
|
||||
account.accountType === 'shared' &&
|
||||
this._isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 检查模型支持
|
||||
if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel)) {
|
||||
logger.debug(`CCR account ${account.name} does not support model ${requestedModel}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否被限流或超额
|
||||
const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id)
|
||||
const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id)
|
||||
const isOverloaded = await ccrAccountService.isAccountOverloaded(account.id)
|
||||
|
||||
if (!isRateLimited && !isQuotaExceeded && !isOverloaded) {
|
||||
availableAccounts.push({
|
||||
...account,
|
||||
accountId: account.id,
|
||||
accountType: 'ccr',
|
||||
priority: parseInt(account.priority) || 50,
|
||||
lastUsedAt: account.lastUsedAt || '0'
|
||||
})
|
||||
logger.debug(`✅ Added CCR account to available pool: ${account.name}`)
|
||||
} else {
|
||||
logger.debug(
|
||||
`❌ CCR account ${account.name} not available - rateLimited: ${isRateLimited}, quotaExceeded: ${isQuotaExceeded}, overloaded: ${isOverloaded}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
logger.debug(
|
||||
`❌ CCR account ${account.name} not eligible - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`📊 Total available CCR accounts: ${availableAccounts.length}`)
|
||||
return availableAccounts
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get available CCR accounts:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new UnifiedClaudeScheduler()
|
||||
|
||||
@@ -61,8 +61,8 @@ class UnifiedGeminiScheduler {
|
||||
mappedAccount.accountType
|
||||
)
|
||||
if (isAvailable) {
|
||||
// 🚀 智能会话续期:剩余时间少于14天时自动续期到15天
|
||||
await redis.extendSessionAccountMappingTTL(sessionHash)
|
||||
// 🚀 智能会话续期(续期 unified 映射键,按配置)
|
||||
await this._extendSessionMappingTTL(sessionHash)
|
||||
logger.info(
|
||||
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
||||
)
|
||||
@@ -285,9 +285,11 @@ class UnifiedGeminiScheduler {
|
||||
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)
|
||||
// 依据配置设置TTL(小时)
|
||||
const appConfig = require('../../config/config')
|
||||
const ttlHours = appConfig.session?.stickyTtlHours || 1
|
||||
const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60))
|
||||
await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, ttlSeconds, mappingData)
|
||||
}
|
||||
|
||||
// 🗑️ 删除会话映射
|
||||
@@ -296,6 +298,47 @@ class UnifiedGeminiScheduler {
|
||||
await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`)
|
||||
}
|
||||
|
||||
// 🔁 续期统一调度会话映射TTL(针对 unified_gemini_session_mapping:* 键),遵循会话配置
|
||||
async _extendSessionMappingTTL(sessionHash) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const key = `${this.SESSION_MAPPING_PREFIX}${sessionHash}`
|
||||
const remainingTTL = await client.ttl(key)
|
||||
|
||||
if (remainingTTL === -2) {
|
||||
return false
|
||||
}
|
||||
if (remainingTTL === -1) {
|
||||
return true
|
||||
}
|
||||
|
||||
const appConfig = require('../../config/config')
|
||||
const ttlHours = appConfig.session?.stickyTtlHours || 1
|
||||
const renewalThresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0
|
||||
if (!renewalThresholdMinutes) {
|
||||
return true
|
||||
}
|
||||
|
||||
const fullTTL = Math.max(1, Math.floor(ttlHours * 60 * 60))
|
||||
const threshold = Math.max(0, Math.floor(renewalThresholdMinutes * 60))
|
||||
|
||||
if (remainingTTL < threshold) {
|
||||
await client.expire(key, fullTTL)
|
||||
logger.debug(
|
||||
`🔄 Renewed unified Gemini session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 60)}m, renewed to ${ttlHours}h)`
|
||||
)
|
||||
} else {
|
||||
logger.debug(
|
||||
`✅ Unified Gemini session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 60)}m)`
|
||||
)
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to extend unified Gemini session TTL:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 标记账户为限流状态
|
||||
async markAccountRateLimited(accountId, accountType, sessionHash = null) {
|
||||
try {
|
||||
@@ -384,8 +427,8 @@ class UnifiedGeminiScheduler {
|
||||
mappedAccount.accountType
|
||||
)
|
||||
if (isAvailable) {
|
||||
// 🚀 智能会话续期:剩余时间少于14天时自动续期到15天
|
||||
await redis.extendSessionAccountMappingTTL(sessionHash)
|
||||
// 🚀 智能会话续期(续期 unified 映射键,按配置)
|
||||
await this._extendSessionMappingTTL(sessionHash)
|
||||
logger.info(
|
||||
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const openaiAccountService = require('./openaiAccountService')
|
||||
const openaiResponsesAccountService = require('./openaiResponsesAccountService')
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
@@ -32,23 +33,53 @@ class UnifiedOpenAIScheduler {
|
||||
return await this.selectAccountFromGroup(groupId, sessionHash, requestedModel, apiKeyData)
|
||||
}
|
||||
|
||||
// 普通专属账户
|
||||
const boundAccount = await openaiAccountService.getAccount(apiKeyData.openaiAccountId)
|
||||
// 普通专属账户 - 根据前缀判断是 OpenAI 还是 OpenAI-Responses 类型
|
||||
let boundAccount = null
|
||||
let accountType = 'openai'
|
||||
|
||||
// 检查是否有 responses: 前缀(用于区分 OpenAI-Responses 账户)
|
||||
if (apiKeyData.openaiAccountId.startsWith('responses:')) {
|
||||
const accountId = apiKeyData.openaiAccountId.replace('responses:', '')
|
||||
boundAccount = await openaiResponsesAccountService.getAccount(accountId)
|
||||
accountType = 'openai-responses'
|
||||
} else {
|
||||
// 普通 OpenAI 账户
|
||||
boundAccount = await openaiAccountService.getAccount(apiKeyData.openaiAccountId)
|
||||
accountType = 'openai'
|
||||
}
|
||||
|
||||
if (
|
||||
boundAccount &&
|
||||
(boundAccount.isActive === true || boundAccount.isActive === 'true') &&
|
||||
boundAccount.status !== 'error'
|
||||
) {
|
||||
// 检查是否被限流
|
||||
const isRateLimited = await this.isAccountRateLimited(boundAccount.id)
|
||||
if (isRateLimited) {
|
||||
const errorMsg = `Dedicated account ${boundAccount.name} is currently rate limited`
|
||||
logger.warn(`⚠️ ${errorMsg}`)
|
||||
throw new Error(errorMsg)
|
||||
if (accountType === 'openai') {
|
||||
const isRateLimited = await this.isAccountRateLimited(boundAccount.id)
|
||||
if (isRateLimited) {
|
||||
const errorMsg = `Dedicated account ${boundAccount.name} is currently rate limited`
|
||||
logger.warn(`⚠️ ${errorMsg}`)
|
||||
throw new Error(errorMsg)
|
||||
}
|
||||
} else if (
|
||||
accountType === 'openai-responses' &&
|
||||
boundAccount.rateLimitStatus === 'limited'
|
||||
) {
|
||||
// OpenAI-Responses 账户的限流检查
|
||||
const isRateLimitCleared = await openaiResponsesAccountService.checkAndClearRateLimit(
|
||||
boundAccount.id
|
||||
)
|
||||
if (!isRateLimitCleared) {
|
||||
const errorMsg = `Dedicated account ${boundAccount.name} is currently rate limited`
|
||||
logger.warn(`⚠️ ${errorMsg}`)
|
||||
throw new Error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// 专属账户:可选的模型检查(只有明确配置了supportedModels且不为空才检查)
|
||||
// OpenAI-Responses 账户默认支持所有模型
|
||||
if (
|
||||
accountType === 'openai' &&
|
||||
requestedModel &&
|
||||
boundAccount.supportedModels &&
|
||||
boundAccount.supportedModels.length > 0
|
||||
@@ -62,13 +93,19 @@ class UnifiedOpenAIScheduler {
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🎯 Using bound dedicated OpenAI account: ${boundAccount.name} (${apiKeyData.openaiAccountId}) for API key ${apiKeyData.name}`
|
||||
`🎯 Using bound dedicated ${accountType} account: ${boundAccount.name} (${boundAccount.id}) for API key ${apiKeyData.name}`
|
||||
)
|
||||
// 更新账户的最后使用时间
|
||||
await openaiAccountService.recordUsage(apiKeyData.openaiAccountId, 0)
|
||||
if (accountType === 'openai') {
|
||||
await openaiAccountService.recordUsage(boundAccount.id, 0)
|
||||
} else {
|
||||
await openaiResponsesAccountService.updateAccount(boundAccount.id, {
|
||||
lastUsedAt: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
return {
|
||||
accountId: apiKeyData.openaiAccountId,
|
||||
accountType: 'openai'
|
||||
accountId: boundAccount.id,
|
||||
accountType
|
||||
}
|
||||
} else {
|
||||
// 专属账户不可用时直接报错,不降级到共享池
|
||||
@@ -90,8 +127,8 @@ class UnifiedOpenAIScheduler {
|
||||
mappedAccount.accountType
|
||||
)
|
||||
if (isAvailable) {
|
||||
// 🚀 智能会话续期:剩余时间少于14天时自动续期到15天
|
||||
await redis.extendSessionAccountMappingTTL(sessionHash)
|
||||
// 🚀 智能会话续期(续期 unified 映射键,按配置)
|
||||
await this._extendSessionMappingTTL(sessionHash)
|
||||
logger.info(
|
||||
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
||||
)
|
||||
@@ -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) {
|
||||
@@ -291,9 +380,11 @@ class UnifiedOpenAIScheduler {
|
||||
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)
|
||||
// 依据配置设置TTL(小时)
|
||||
const appConfig = require('../../config/config')
|
||||
const ttlHours = appConfig.session?.stickyTtlHours || 1
|
||||
const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60))
|
||||
await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, ttlSeconds, mappingData)
|
||||
}
|
||||
|
||||
// 🗑️ 删除会话映射
|
||||
@@ -302,11 +393,64 @@ class UnifiedOpenAIScheduler {
|
||||
await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`)
|
||||
}
|
||||
|
||||
// 🔁 续期统一调度会话映射TTL(针对 unified_openai_session_mapping:* 键),遵循会话配置
|
||||
async _extendSessionMappingTTL(sessionHash) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const key = `${this.SESSION_MAPPING_PREFIX}${sessionHash}`
|
||||
const remainingTTL = await client.ttl(key)
|
||||
|
||||
if (remainingTTL === -2) {
|
||||
return false
|
||||
}
|
||||
if (remainingTTL === -1) {
|
||||
return true
|
||||
}
|
||||
|
||||
const appConfig = require('../../config/config')
|
||||
const ttlHours = appConfig.session?.stickyTtlHours || 1
|
||||
const renewalThresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0
|
||||
if (!renewalThresholdMinutes) {
|
||||
return true
|
||||
}
|
||||
|
||||
const fullTTL = Math.max(1, Math.floor(ttlHours * 60 * 60))
|
||||
const threshold = Math.max(0, Math.floor(renewalThresholdMinutes * 60))
|
||||
|
||||
if (remainingTTL < threshold) {
|
||||
await client.expire(key, fullTTL)
|
||||
logger.debug(
|
||||
`🔄 Renewed unified OpenAI session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 60)}m, renewed to ${ttlHours}h)`
|
||||
)
|
||||
} else {
|
||||
logger.debug(
|
||||
`✅ Unified OpenAI session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 60)}m)`
|
||||
)
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to extend unified OpenAI session TTL:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 标记账户为限流状态
|
||||
async markAccountRateLimited(accountId, accountType, sessionHash = null, resetsInSeconds = null) {
|
||||
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 +473,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 }
|
||||
@@ -408,8 +563,8 @@ class UnifiedOpenAIScheduler {
|
||||
mappedAccount.accountType
|
||||
)
|
||||
if (isAvailable) {
|
||||
// 🚀 智能会话续期:剩余时间少于14天时自动续期到15天
|
||||
await redis.extendSessionAccountMappingTTL(sessionHash)
|
||||
// 🚀 智能会话续期(续期 unified 映射键,按配置)
|
||||
await this._extendSessionMappingTTL(sessionHash)
|
||||
logger.info(
|
||||
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType})`
|
||||
)
|
||||
|
||||
78
src/utils/modelHelper.js
Normal file
78
src/utils/modelHelper.js
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Model Helper Utility
|
||||
*
|
||||
* Provides utilities for parsing vendor-prefixed model names.
|
||||
* Supports parsing model strings like "ccr,model_name" to extract vendor type and base model.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse vendor-prefixed model string
|
||||
* @param {string} modelStr - Model string, potentially with vendor prefix (e.g., "ccr,gemini-2.5-pro")
|
||||
* @returns {{vendor: string|null, baseModel: string}} - Parsed vendor and base model
|
||||
*/
|
||||
function parseVendorPrefixedModel(modelStr) {
|
||||
if (!modelStr || typeof modelStr !== 'string') {
|
||||
return { vendor: null, baseModel: modelStr || '' }
|
||||
}
|
||||
|
||||
// Trim whitespace and convert to lowercase for comparison
|
||||
const trimmed = modelStr.trim()
|
||||
const lowerTrimmed = trimmed.toLowerCase()
|
||||
|
||||
// Check for ccr prefix (case insensitive)
|
||||
if (lowerTrimmed.startsWith('ccr,')) {
|
||||
const parts = trimmed.split(',')
|
||||
if (parts.length >= 2) {
|
||||
// Extract base model (everything after the first comma, rejoined in case model name contains commas)
|
||||
const baseModel = parts.slice(1).join(',').trim()
|
||||
return {
|
||||
vendor: 'ccr',
|
||||
baseModel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No recognized vendor prefix found
|
||||
return {
|
||||
vendor: null,
|
||||
baseModel: trimmed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model string has a vendor prefix
|
||||
* @param {string} modelStr - Model string to check
|
||||
* @returns {boolean} - True if the model has a vendor prefix
|
||||
*/
|
||||
function hasVendorPrefix(modelStr) {
|
||||
const { vendor } = parseVendorPrefixedModel(modelStr)
|
||||
return vendor !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective model name for scheduling and processing
|
||||
* This removes vendor prefixes to get the actual model name used for API calls
|
||||
* @param {string} modelStr - Original model string
|
||||
* @returns {string} - Effective model name without vendor prefix
|
||||
*/
|
||||
function getEffectiveModel(modelStr) {
|
||||
const { baseModel } = parseVendorPrefixedModel(modelStr)
|
||||
return baseModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the vendor type from a model string
|
||||
* @param {string} modelStr - Model string to parse
|
||||
* @returns {string|null} - Vendor type ('ccr') or null if no prefix
|
||||
*/
|
||||
function getVendorType(modelStr) {
|
||||
const { vendor } = parseVendorPrefixedModel(modelStr)
|
||||
return vendor
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseVendorPrefixedModel,
|
||||
hasVendorPrefix,
|
||||
getEffectiveModel,
|
||||
getVendorType
|
||||
}
|
||||
@@ -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 - 平台类型
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
/* Glass效果 */
|
||||
/* Glass效果 - 优化版 */
|
||||
.glass {
|
||||
background: var(--glass-color);
|
||||
backdrop-filter: blur(20px);
|
||||
/* 降低模糊强度 */
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.glass-strong {
|
||||
background: var(--surface-color);
|
||||
backdrop-filter: blur(25px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
/* 降低模糊强度 */
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* 标签按钮 */
|
||||
@@ -216,13 +218,13 @@
|
||||
|
||||
/* 表单输入 */
|
||||
.form-input {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
transition: all 0.2s ease;
|
||||
/* 移除模糊效果,使用纯色背景 */
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
@@ -255,18 +257,18 @@
|
||||
|
||||
/* 模态框 */
|
||||
.modal {
|
||||
backdrop-filter: blur(8px);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
/* 移除模糊,使用半透明背景 */
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 10px 25px -5px rgba(0, 0, 0, 0.15),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(20px);
|
||||
/* 移除模糊效果 */
|
||||
}
|
||||
|
||||
/* 弹窗滚动内容样式 */
|
||||
|
||||
@@ -108,13 +108,13 @@ body::before {
|
||||
|
||||
.glass {
|
||||
background: var(--glass-color);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
/* 降低模糊强度 */
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
border-color 0.3s ease;
|
||||
@@ -129,13 +129,12 @@ body::before {
|
||||
|
||||
.glass-strong {
|
||||
background: var(--glass-strong-color);
|
||||
backdrop-filter: blur(25px);
|
||||
-webkit-backdrop-filter: blur(25px);
|
||||
/* 降低模糊强度 */
|
||||
/* 移除模糊效果 */
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
border-color 0.3s ease;
|
||||
@@ -269,8 +268,7 @@ body::before {
|
||||
background-color 0.3s ease,
|
||||
border-color 0.3s ease,
|
||||
box-shadow 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
/* 移除模糊效果 */
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
@@ -461,8 +459,7 @@ body::before {
|
||||
background-color 0.3s ease,
|
||||
border-color 0.3s ease,
|
||||
box-shadow 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
/* 移除模糊效果 */
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
@@ -511,8 +508,7 @@ body::before {
|
||||
}
|
||||
|
||||
.modal {
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
/* 移除模糊效果 */
|
||||
background: var(--modal-bg);
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
@@ -522,10 +518,9 @@ body::before {
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(229, 231, 235, 0.8);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 10px 25px -5px rgba(0, 0, 0, 0.15),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
/* 移除模糊效果 */
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
border-color 0.3s ease;
|
||||
@@ -730,7 +725,12 @@ body::before {
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s infinite;
|
||||
/* 移除无限脉冲动画,改为 hover 效果 */
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.animate-pulse:hover {
|
||||
animation: pulse 0.3s ease;
|
||||
}
|
||||
|
||||
/* 用户菜单下拉框优化 */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
451
web/admin-spa/src/components/accounts/CcrAccountForm.vue
Normal file
451
web/admin-spa/src/components/accounts/CcrAccountForm.vue
Normal file
@@ -0,0 +1,451 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="show" class="modal fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4">
|
||||
<div
|
||||
class="modal-content custom-scrollbar mx-auto max-h-[90vh] w-full max-w-2xl overflow-y-auto p-4 sm:p-6 md:p-8"
|
||||
>
|
||||
<div class="mb-4 flex items-center justify-between sm:mb-6">
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-teal-500 to-emerald-600 sm:h-10 sm:w-10 sm:rounded-xl"
|
||||
>
|
||||
<i class="fas fa-code-branch text-sm text-white sm:text-base" />
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl">
|
||||
{{ isEdit ? '编辑 CCR 账户' : '添加 CCR 账户' }}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
class="p-1 text-gray-400 transition-colors hover:text-gray-600"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<i class="fas fa-times text-lg sm:text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- 基本信息 -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>账户名称 *</label
|
||||
>
|
||||
<input
|
||||
v-model="form.name"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
:class="{ 'border-red-500': errors.name }"
|
||||
placeholder="为账户设置一个易识别的名称"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<p v-if="errors.name" class="mt-1 text-xs text-red-500">{{ errors.name }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>描述 (可选)</label
|
||||
>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
class="form-input w-full resize-none border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
placeholder="账户用途说明..."
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>API URL *</label
|
||||
>
|
||||
<input
|
||||
v-model="form.apiUrl"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
:class="{ 'border-red-500': errors.apiUrl }"
|
||||
placeholder="例如:https://api.example.com/v1/messages"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<p v-if="errors.apiUrl" class="mt-1 text-xs text-red-500">{{ errors.apiUrl }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>API Key {{ isEdit ? '(留空不更新)' : '*' }}</label
|
||||
>
|
||||
<input
|
||||
v-model="form.apiKey"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
:class="{ 'border-red-500': errors.apiKey }"
|
||||
:placeholder="isEdit ? '留空表示不更新' : '必填'"
|
||||
:required="!isEdit"
|
||||
type="password"
|
||||
/>
|
||||
<p v-if="errors.apiKey" class="mt-1 text-xs text-red-500">{{ errors.apiKey }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>优先级</label
|
||||
>
|
||||
<input
|
||||
v-model.number="form.priority"
|
||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
max="100"
|
||||
min="1"
|
||||
placeholder="默认50,数字越小优先级越高"
|
||||
type="number"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
建议范围:1-100,数字越小优先级越高
|
||||
</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"
|
||||
placeholder="留空则透传客户端 User-Agent"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 限流设置 -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>限流机制</label
|
||||
>
|
||||
<div class="mb-3">
|
||||
<label class="inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="enableRateLimit"
|
||||
class="mr-2 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300"
|
||||
>启用限流机制(429 时暂停调度)</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="enableRateLimit">
|
||||
<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">
|
||||
账号被限流后暂停调度的时间(分钟)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 额度管理 -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<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"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
设置每日使用额度,0 表示不限制
|
||||
</p>
|
||||
</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"
|
||||
placeholder="00:00"
|
||||
type="time"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">每日自动重置额度的时间</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型映射表(可选) -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>模型映射表 (可选)</label
|
||||
>
|
||||
<div class="mb-3 rounded-lg bg-blue-50 p-3 dark:bg-blue-900/30">
|
||||
<p class="text-xs text-blue-700 dark:text-blue-400">
|
||||
<i class="fas fa-info-circle mr-1" />
|
||||
留空表示支持所有模型且不修改请求。配置映射后,左侧模型会被识别为支持的模型,右侧是实际发送的模型。
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-3 space-y-2">
|
||||
<div
|
||||
v-for="(mapping, index) in modelMappings"
|
||||
:key="index"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
v-model="mapping.from"
|
||||
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
placeholder="原始模型名称"
|
||||
type="text"
|
||||
/>
|
||||
<i class="fas fa-arrow-right text-gray-400 dark:text-gray-500" />
|
||||
<input
|
||||
v-model="mapping.to"
|
||||
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||
placeholder="映射后的模型名称"
|
||||
type="text"
|
||||
/>
|
||||
<button
|
||||
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
type="button"
|
||||
@click="removeModelMapping(index)"
|
||||
>
|
||||
<i class="fas fa-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-gray-600 dark:text-gray-400 dark:hover:border-gray-500 dark:hover:text-gray-300"
|
||||
type="button"
|
||||
@click="addModelMapping"
|
||||
>
|
||||
<i class="fas fa-plus mr-2" /> 添加模型映射
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 代理配置 -->
|
||||
<div>
|
||||
<ProxyConfig v-model="form.proxy" />
|
||||
</div>
|
||||
|
||||
<!-- 操作区 -->
|
||||
<div class="mt-2 flex gap-3">
|
||||
<button
|
||||
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
||||
:disabled="loading"
|
||||
type="button"
|
||||
@click="submit"
|
||||
>
|
||||
<div v-if="loading" class="loading-spinner mr-2" />
|
||||
{{ loading ? (isEdit ? '保存中...' : '创建中...') : isEdit ? '保存' : '创建' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import ProxyConfig from '@/components/accounts/ProxyConfig.vue'
|
||||
|
||||
const props = defineProps({
|
||||
account: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
|
||||
const show = ref(true)
|
||||
const isEdit = computed(() => !!props.account)
|
||||
const loading = ref(false)
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
apiUrl: '',
|
||||
apiKey: '',
|
||||
priority: 50,
|
||||
userAgent: '',
|
||||
rateLimitDuration: 60,
|
||||
dailyQuota: 0,
|
||||
quotaResetTime: '00:00',
|
||||
proxy: null,
|
||||
supportedModels: {}
|
||||
})
|
||||
|
||||
const enableRateLimit = ref(true)
|
||||
const errors = ref({})
|
||||
|
||||
const modelMappings = ref([]) // [{from,to}]
|
||||
|
||||
const buildSupportedModels = () => {
|
||||
const map = {}
|
||||
for (const m of modelMappings.value) {
|
||||
const from = (m.from || '').trim()
|
||||
const to = (m.to || '').trim()
|
||||
if (from && to) map[from] = to
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
const addModelMapping = () => {
|
||||
modelMappings.value.push({ from: '', to: '' })
|
||||
}
|
||||
|
||||
const removeModelMapping = (index) => {
|
||||
modelMappings.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const validate = () => {
|
||||
const e = {}
|
||||
if (!form.value.name || form.value.name.trim().length === 0) e.name = '名称不能为空'
|
||||
if (!form.value.apiUrl || form.value.apiUrl.trim().length === 0) e.apiUrl = 'API URL 不能为空'
|
||||
if (!isEdit.value && (!form.value.apiKey || form.value.apiKey.trim().length === 0))
|
||||
e.apiKey = 'API Key 不能为空'
|
||||
errors.value = e
|
||||
return Object.keys(e).length === 0
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!validate()) return
|
||||
loading.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
// 更新
|
||||
const updates = {
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
apiUrl: form.value.apiUrl,
|
||||
priority: form.value.priority,
|
||||
userAgent: form.value.userAgent,
|
||||
rateLimitDuration: enableRateLimit.value ? Number(form.value.rateLimitDuration || 60) : 0,
|
||||
dailyQuota: Number(form.value.dailyQuota || 0),
|
||||
quotaResetTime: form.value.quotaResetTime || '00:00',
|
||||
proxy: form.value.proxy || null,
|
||||
supportedModels: buildSupportedModels()
|
||||
}
|
||||
if (form.value.apiKey && form.value.apiKey.trim().length > 0) {
|
||||
updates.apiKey = form.value.apiKey
|
||||
}
|
||||
const res = await apiClient.put(`/admin/ccr-accounts/${props.account.id}`, updates)
|
||||
if (res.success) {
|
||||
// 不在这里显示 toast,由父组件统一处理
|
||||
emit('success')
|
||||
} else {
|
||||
showToast(res.message || '保存失败', 'error')
|
||||
}
|
||||
} else {
|
||||
// 创建
|
||||
const payload = {
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
apiUrl: form.value.apiUrl,
|
||||
apiKey: form.value.apiKey,
|
||||
priority: Number(form.value.priority || 50),
|
||||
supportedModels: buildSupportedModels(),
|
||||
userAgent: form.value.userAgent,
|
||||
rateLimitDuration: enableRateLimit.value ? Number(form.value.rateLimitDuration || 60) : 0,
|
||||
proxy: form.value.proxy,
|
||||
accountType: 'shared',
|
||||
dailyQuota: Number(form.value.dailyQuota || 0),
|
||||
quotaResetTime: form.value.quotaResetTime || '00:00'
|
||||
}
|
||||
const res = await apiClient.post('/admin/ccr-accounts', payload)
|
||||
if (res.success) {
|
||||
// 不在这里显示 toast,由父组件统一处理
|
||||
emit('success')
|
||||
} else {
|
||||
showToast(res.message || '创建失败', 'error')
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(err.message || '请求失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const populateFromAccount = () => {
|
||||
if (!props.account) return
|
||||
const a = props.account
|
||||
form.value.name = a.name || ''
|
||||
form.value.description = a.description || ''
|
||||
form.value.apiUrl = a.apiUrl || ''
|
||||
form.value.priority = Number(a.priority || 50)
|
||||
form.value.userAgent = a.userAgent || ''
|
||||
form.value.rateLimitDuration = Number(a.rateLimitDuration || 60)
|
||||
form.value.dailyQuota = Number(a.dailyQuota || 0)
|
||||
form.value.quotaResetTime = a.quotaResetTime || '00:00'
|
||||
form.value.proxy = a.proxy || null
|
||||
enableRateLimit.value = form.value.rateLimitDuration > 0
|
||||
|
||||
// supportedModels 对象转为数组
|
||||
modelMappings.value = []
|
||||
const mapping = a.supportedModels || {}
|
||||
if (mapping && typeof mapping === 'object') {
|
||||
for (const k of Object.keys(mapping)) {
|
||||
modelMappings.value.push({ from: k, to: mapping[k] })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isEdit.value) populateFromAccount()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.account,
|
||||
() => {
|
||||
if (isEdit.value) populateFromAccount()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-content {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 16px;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:global(.dark) .modal-content {
|
||||
background: rgba(17, 24, 39, 0.85);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-top: 2px solid #14b8a6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -30,6 +30,45 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快速配置输入框 -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
快速配置
|
||||
<span class="ml-1 text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
(粘贴完整代理URL自动填充)
|
||||
</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="proxyUrl"
|
||||
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="例如: socks5://username:password@host:port 或 http://host:port"
|
||||
type="text"
|
||||
@input="handleInput"
|
||||
@keyup.enter="parseProxyUrl"
|
||||
@paste="handlePaste"
|
||||
/>
|
||||
<button
|
||||
v-if="proxyUrl"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-400"
|
||||
type="button"
|
||||
@click="clearProxyUrl"
|
||||
>
|
||||
<i class="fas fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="parseError" class="mt-1 text-xs text-red-500">
|
||||
<i class="fas fa-exclamation-circle mr-1" />
|
||||
{{ parseError }}
|
||||
</p>
|
||||
<p v-else-if="parseSuccess" class="mt-1 text-xs text-green-500">
|
||||
<i class="fas fa-check-circle mr-1" />
|
||||
代理配置已自动填充
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="my-3 border-t border-gray-200 dark:border-gray-600"></div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>代理类型</label
|
||||
@@ -159,6 +198,11 @@ const proxy = ref({ ...props.modelValue })
|
||||
const showAuth = ref(!!(proxy.value.username || proxy.value.password))
|
||||
const showPassword = ref(false)
|
||||
|
||||
// 快速配置相关
|
||||
const proxyUrl = ref('')
|
||||
const parseError = ref('')
|
||||
const parseSuccess = ref(false)
|
||||
|
||||
// 监听modelValue变化,只在真正需要更新时才更新
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
@@ -246,6 +290,122 @@ function emitUpdate() {
|
||||
}, 100) // 100ms 延迟
|
||||
}
|
||||
|
||||
// 解析代理URL
|
||||
function parseProxyUrl() {
|
||||
parseError.value = ''
|
||||
parseSuccess.value = false
|
||||
|
||||
if (!proxyUrl.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 移除 # 后面的别名部分
|
||||
const urlWithoutAlias = proxyUrl.value.split('#')[0].trim()
|
||||
|
||||
if (!urlWithoutAlias) {
|
||||
return
|
||||
}
|
||||
|
||||
// 正则表达式匹配代理URL格式
|
||||
// 支持格式:protocol://[username:password@]host:port
|
||||
const proxyPattern = /^(socks5|https?):\/\/(?:([^:@]+):([^@]+)@)?([^:]+):(\d+)$/i
|
||||
const match = urlWithoutAlias.match(proxyPattern)
|
||||
|
||||
if (!match) {
|
||||
// 尝试简单格式:host:port(默认为socks5)
|
||||
const simplePattern = /^([^:]+):(\d+)$/
|
||||
const simpleMatch = urlWithoutAlias.match(simplePattern)
|
||||
|
||||
if (simpleMatch) {
|
||||
proxy.value.type = 'socks5'
|
||||
proxy.value.host = simpleMatch[1]
|
||||
proxy.value.port = simpleMatch[2]
|
||||
proxy.value.username = ''
|
||||
proxy.value.password = ''
|
||||
showAuth.value = false
|
||||
parseSuccess.value = true
|
||||
emitUpdate()
|
||||
|
||||
// 3秒后清除成功提示
|
||||
setTimeout(() => {
|
||||
parseSuccess.value = false
|
||||
}, 3000)
|
||||
return
|
||||
}
|
||||
|
||||
parseError.value = '无效的代理URL格式,请检查输入'
|
||||
return
|
||||
}
|
||||
|
||||
// 解析匹配结果
|
||||
const [, protocol, username, password, host, port] = match
|
||||
|
||||
// 填充表单
|
||||
proxy.value.type = protocol.toLowerCase()
|
||||
proxy.value.host = host
|
||||
proxy.value.port = port
|
||||
|
||||
// 处理认证信息
|
||||
if (username && password) {
|
||||
proxy.value.username = decodeURIComponent(username)
|
||||
proxy.value.password = decodeURIComponent(password)
|
||||
showAuth.value = true
|
||||
} else {
|
||||
proxy.value.username = ''
|
||||
proxy.value.password = ''
|
||||
showAuth.value = false
|
||||
}
|
||||
|
||||
parseSuccess.value = true
|
||||
emitUpdate()
|
||||
|
||||
// 3秒后清除成功提示
|
||||
setTimeout(() => {
|
||||
parseSuccess.value = false
|
||||
}, 3000)
|
||||
} catch (error) {
|
||||
// 解析代理URL失败
|
||||
parseError.value = '解析失败,请检查URL格式'
|
||||
}
|
||||
}
|
||||
|
||||
// 清空快速配置输入
|
||||
function clearProxyUrl() {
|
||||
proxyUrl.value = ''
|
||||
parseError.value = ''
|
||||
parseSuccess.value = false
|
||||
}
|
||||
|
||||
// 处理粘贴事件
|
||||
function handlePaste() {
|
||||
// 延迟一下以确保v-model已经更新
|
||||
setTimeout(() => {
|
||||
parseProxyUrl()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
// 处理输入事件
|
||||
function handleInput() {
|
||||
// 检测是否输入了代理URL格式
|
||||
const value = proxyUrl.value.trim()
|
||||
|
||||
// 如果输入包含://,说明可能是完整的代理URL
|
||||
if (value.includes('://')) {
|
||||
// 检查是否看起来像完整的URL(有协议、主机和端口)
|
||||
if (
|
||||
/^(socks5|https?):\/\/[^:]+:\d+/i.test(value) ||
|
||||
/^(socks5|https?):\/\/[^:@]+:[^@]+@[^:]+:\d+/i.test(value)
|
||||
) {
|
||||
parseProxyUrl()
|
||||
}
|
||||
}
|
||||
// 如果是简单的 host:port 格式,并且端口号输入完整
|
||||
else if (/^[^:]+:\d{2,5}$/.test(value)) {
|
||||
parseProxyUrl()
|
||||
}
|
||||
}
|
||||
|
||||
// 组件销毁时清理定时器
|
||||
onUnmounted(() => {
|
||||
if (updateTimer) {
|
||||
|
||||
@@ -886,31 +886,61 @@ onMounted(async () => {
|
||||
availableTags.value = await apiKeysStore.fetchTags()
|
||||
// 初始化账号数据
|
||||
if (props.accounts) {
|
||||
// 合并 OpenAI 和 OpenAI-Responses 账号
|
||||
const openaiAccounts = []
|
||||
if (props.accounts.openai) {
|
||||
props.accounts.openai.forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai'
|
||||
})
|
||||
})
|
||||
}
|
||||
if (props.accounts.openaiResponses) {
|
||||
props.accounts.openaiResponses.forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai-responses'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
localAccounts.value = {
|
||||
claude: props.accounts.claude || [],
|
||||
gemini: props.accounts.gemini || [],
|
||||
openai: props.accounts.openai || [],
|
||||
openai: openaiAccounts,
|
||||
bedrock: props.accounts.bedrock || [], // 添加 Bedrock 账号
|
||||
claudeGroups: props.accounts.claudeGroups || [],
|
||||
geminiGroups: props.accounts.geminiGroups || [],
|
||||
openaiGroups: props.accounts.openaiGroups || []
|
||||
}
|
||||
}
|
||||
|
||||
// 自动加载账号数据
|
||||
await refreshAccounts()
|
||||
})
|
||||
|
||||
// 刷新账号列表
|
||||
const refreshAccounts = async () => {
|
||||
accountsLoading.value = true
|
||||
try {
|
||||
const [claudeData, claudeConsoleData, geminiData, openaiData, bedrockData, groupsData] =
|
||||
await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/claude-console-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts'),
|
||||
apiClient.get('/admin/openai-accounts'),
|
||||
apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取
|
||||
apiClient.get('/admin/account-groups')
|
||||
])
|
||||
const [
|
||||
claudeData,
|
||||
claudeConsoleData,
|
||||
geminiData,
|
||||
openaiData,
|
||||
openaiResponsesData,
|
||||
bedrockData,
|
||||
groupsData
|
||||
] = await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/claude-console-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts'),
|
||||
apiClient.get('/admin/openai-accounts'),
|
||||
apiClient.get('/admin/openai-responses-accounts'), // 获取 OpenAI-Responses 账号
|
||||
apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取
|
||||
apiClient.get('/admin/account-groups')
|
||||
])
|
||||
|
||||
// 合并Claude OAuth账户和Claude Console账户
|
||||
const claudeAccounts = []
|
||||
@@ -944,13 +974,31 @@ const refreshAccounts = async () => {
|
||||
}))
|
||||
}
|
||||
|
||||
// 合并 OpenAI 和 OpenAI-Responses 账号
|
||||
const openaiAccounts = []
|
||||
|
||||
if (openaiData.success) {
|
||||
localAccounts.value.openai = (openaiData.data || []).map((account) => ({
|
||||
...account,
|
||||
isDedicated: account.accountType === 'dedicated' // 保留以便向后兼容
|
||||
}))
|
||||
;(openaiData.data || []).forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai',
|
||||
isDedicated: account.accountType === 'dedicated' // 保留以便向后兼容
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (openaiResponsesData.success) {
|
||||
;(openaiResponsesData.data || []).forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai-responses',
|
||||
isDedicated: account.accountType === 'dedicated' // 保留以便向后兼容
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
localAccounts.value.openai = openaiAccounts
|
||||
|
||||
if (bedrockData.success) {
|
||||
localAccounts.value.bedrock = (bedrockData.data || []).map((account) => ({
|
||||
...account,
|
||||
|
||||
@@ -911,15 +911,23 @@ const updateApiKey = async () => {
|
||||
const refreshAccounts = async () => {
|
||||
accountsLoading.value = true
|
||||
try {
|
||||
const [claudeData, claudeConsoleData, geminiData, openaiData, bedrockData, groupsData] =
|
||||
await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/claude-console-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts'),
|
||||
apiClient.get('/admin/openai-accounts'),
|
||||
apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取
|
||||
apiClient.get('/admin/account-groups')
|
||||
])
|
||||
const [
|
||||
claudeData,
|
||||
claudeConsoleData,
|
||||
geminiData,
|
||||
openaiData,
|
||||
openaiResponsesData,
|
||||
bedrockData,
|
||||
groupsData
|
||||
] = await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/claude-console-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts'),
|
||||
apiClient.get('/admin/openai-accounts'),
|
||||
apiClient.get('/admin/openai-responses-accounts'), // 获取 OpenAI-Responses 账号
|
||||
apiClient.get('/admin/bedrock-accounts'), // 添加 Bedrock 账号获取
|
||||
apiClient.get('/admin/account-groups')
|
||||
])
|
||||
|
||||
// 合并Claude OAuth账户和Claude Console账户
|
||||
const claudeAccounts = []
|
||||
@@ -953,13 +961,31 @@ const refreshAccounts = async () => {
|
||||
}))
|
||||
}
|
||||
|
||||
// 合并 OpenAI 和 OpenAI-Responses 账号
|
||||
const openaiAccounts = []
|
||||
|
||||
if (openaiData.success) {
|
||||
localAccounts.value.openai = (openaiData.data || []).map((account) => ({
|
||||
...account,
|
||||
isDedicated: account.accountType === 'dedicated'
|
||||
}))
|
||||
;(openaiData.data || []).forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai',
|
||||
isDedicated: account.accountType === 'dedicated'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (openaiResponsesData.success) {
|
||||
;(openaiResponsesData.data || []).forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai-responses',
|
||||
isDedicated: account.accountType === 'dedicated'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
localAccounts.value.openai = openaiAccounts
|
||||
|
||||
if (bedrockData.success) {
|
||||
localAccounts.value.bedrock = (bedrockData.data || []).map((account) => ({
|
||||
...account,
|
||||
@@ -991,7 +1017,7 @@ const loadUsers = async () => {
|
||||
availableUsers.value = response.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load users:', error)
|
||||
// console.error('Failed to load users:', error)
|
||||
availableUsers.value = [
|
||||
{
|
||||
id: 'admin',
|
||||
@@ -1017,7 +1043,7 @@ onMounted(async () => {
|
||||
supportedClients.value = clients || []
|
||||
availableTags.value = tags || []
|
||||
} catch (error) {
|
||||
console.error('Error loading initial data:', error)
|
||||
// console.error('Error loading initial data:', error)
|
||||
// Fallback to empty arrays if loading fails
|
||||
supportedClients.value = []
|
||||
availableTags.value = []
|
||||
@@ -1025,10 +1051,29 @@ onMounted(async () => {
|
||||
|
||||
// 初始化账号数据
|
||||
if (props.accounts) {
|
||||
// 合并 OpenAI 和 OpenAI-Responses 账号
|
||||
const openaiAccounts = []
|
||||
if (props.accounts.openai) {
|
||||
props.accounts.openai.forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai'
|
||||
})
|
||||
})
|
||||
}
|
||||
if (props.accounts.openaiResponses) {
|
||||
props.accounts.openaiResponses.forEach((account) => {
|
||||
openaiAccounts.push({
|
||||
...account,
|
||||
platform: 'openai-responses'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
localAccounts.value = {
|
||||
claude: props.accounts.claude || [],
|
||||
gemini: props.accounts.gemini || [],
|
||||
openai: props.accounts.openai || [],
|
||||
openai: openaiAccounts,
|
||||
bedrock: props.accounts.bedrock || [], // 添加 Bedrock 账号
|
||||
claudeGroups: props.accounts.claudeGroups || [],
|
||||
geminiGroups: props.accounts.geminiGroups || [],
|
||||
@@ -1036,6 +1081,9 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 自动加载账号数据
|
||||
await refreshAccounts()
|
||||
|
||||
form.name = props.apiKey.name
|
||||
|
||||
// 处理速率限制迁移:如果有tokenLimit且没有rateLimitCost,提示用户
|
||||
@@ -1045,7 +1093,7 @@ onMounted(async () => {
|
||||
// 如果有历史tokenLimit但没有rateLimitCost,提示用户需要重新设置
|
||||
if (props.apiKey.tokenLimit > 0 && !props.apiKey.rateLimitCost) {
|
||||
// 可以根据需要添加提示,或者自动迁移(这里选择让用户手动设置)
|
||||
console.log('检测到历史Token限制,请考虑设置费用限制')
|
||||
// console.log('检测到历史Token限制,请考虑设置费用限制')
|
||||
}
|
||||
|
||||
form.rateLimitWindow = props.apiKey.rateLimitWindow || ''
|
||||
@@ -1061,7 +1109,10 @@ onMounted(async () => {
|
||||
form.claudeAccountId = props.apiKey.claudeAccountId || ''
|
||||
}
|
||||
form.geminiAccountId = props.apiKey.geminiAccountId || ''
|
||||
|
||||
// 处理 OpenAI 账号 - 直接使用后端传来的值(已包含 responses: 前缀)
|
||||
form.openaiAccountId = props.apiKey.openaiAccountId || ''
|
||||
|
||||
form.bedrockAccountId = props.apiKey.bedrockAccountId || '' // 添加 Bedrock 账号ID初始化
|
||||
form.restrictedModels = props.apiKey.restrictedModels || []
|
||||
form.allowedClients = props.apiKey.allowedClients || []
|
||||
|
||||
@@ -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
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return props.groups.filter((group) => group.name.toLowerCase().includes(query))
|
||||
// 只显示与当前平台匹配的分组
|
||||
let groups = props.groups.filter((group) => {
|
||||
// 如果分组有platform属性,则必须匹配当前平台
|
||||
// 如果没有platform属性,则认为是旧数据,根据平台判断
|
||||
if (group.platform) {
|
||||
return group.platform === props.platform
|
||||
}
|
||||
// 向后兼容:如果没有platform字段,通过其他方式判断
|
||||
return true
|
||||
})
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
groups = groups.filter((group) => group.name.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
// 过滤的 OAuth 账号
|
||||
const filteredOAuthAccounts = computed(() => {
|
||||
let accounts = sortedAccounts.value.filter((a) =>
|
||||
props.platform === 'claude' ? a.platform === 'claude-oauth' : a.platform !== 'claude-console'
|
||||
)
|
||||
let accounts = []
|
||||
|
||||
if (props.platform === 'claude') {
|
||||
accounts = sortedAccounts.value.filter((a) => a.platform === 'claude-oauth')
|
||||
} else if (props.platform === 'openai') {
|
||||
// 对于 OpenAI,只显示 openai 类型的账号
|
||||
accounts = sortedAccounts.value.filter((a) => a.platform === 'openai')
|
||||
} else {
|
||||
// 其他平台显示所有非特殊类型的账号
|
||||
accounts = sortedAccounts.value.filter(
|
||||
(a) => !['claude-oauth', 'claude-console', 'openai-responses'].includes(a.platform)
|
||||
)
|
||||
}
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
@@ -324,12 +412,27 @@ const filteredConsoleAccounts = computed(() => {
|
||||
return accounts
|
||||
})
|
||||
|
||||
// 过滤的 OpenAI-Responses 账号
|
||||
const filteredOpenAIResponsesAccounts = computed(() => {
|
||||
if (props.platform !== 'openai') return []
|
||||
|
||||
let accounts = sortedAccounts.value.filter((a) => a.platform === 'openai-responses')
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
accounts = accounts.filter((account) => account.name.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
return accounts
|
||||
})
|
||||
|
||||
// 是否有搜索结果
|
||||
const hasResults = computed(() => {
|
||||
return (
|
||||
filteredGroups.value.length > 0 ||
|
||||
filteredOAuthAccounts.value.length > 0 ||
|
||||
filteredConsoleAccounts.value.length > 0
|
||||
filteredConsoleAccounts.value.length > 0 ||
|
||||
filteredOpenAIResponsesAccounts.value.length > 0
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -138,57 +138,42 @@ const selectTheme = (mode) => {
|
||||
.theme-toggle-button {
|
||||
@apply flex items-center justify-center;
|
||||
@apply h-9 w-9 rounded-full;
|
||||
@apply bg-white/80 dark:bg-gray-800/80;
|
||||
@apply hover:bg-white/90 dark:hover:bg-gray-700/90;
|
||||
@apply bg-white/90 dark:bg-gray-800/90;
|
||||
@apply hover:bg-white dark:hover:bg-gray-700;
|
||||
@apply text-gray-600 dark:text-gray-300;
|
||||
@apply border border-gray-200/50 dark:border-gray-600/50;
|
||||
@apply transition-all duration-200 ease-out;
|
||||
@apply shadow-md backdrop-blur-sm hover:shadow-lg;
|
||||
/* 移除 backdrop-blur 减少 GPU 负担 */
|
||||
@apply shadow-md hover:shadow-lg;
|
||||
@apply hover:scale-110 active:scale-95;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 添加优雅的光环效果 */
|
||||
/* 简化的 hover 效果 */
|
||||
.theme-toggle-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: conic-gradient(
|
||||
from 180deg at 50% 50%,
|
||||
rgba(59, 130, 246, 0.2),
|
||||
rgba(147, 51, 234, 0.2),
|
||||
rgba(59, 130, 246, 0.2)
|
||||
);
|
||||
background: radial-gradient(circle, rgba(59, 130, 246, 0.1), transparent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
transition: opacity 0.2s ease;
|
||||
pointer-events: none;
|
||||
animation: rotate 3s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-toggle-button:hover::before {
|
||||
opacity: 0.6;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 图标样式优化 - 更生动 */
|
||||
/* 图标样式优化 - 简洁高效 */
|
||||
.theme-toggle-button i {
|
||||
@apply text-base;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
|
||||
transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.theme-toggle-button:hover i {
|
||||
transform: rotate(180deg) scale(1.1);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 不同主题的图标颜色 */
|
||||
@@ -300,13 +285,13 @@ const selectTheme = (mode) => {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 星星装饰(深色模式) */
|
||||
/* 星星装饰(深色模式) - 优化版 */
|
||||
.stars {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.theme-switch.is-dark .stars {
|
||||
@@ -320,56 +305,42 @@ const selectTheme = (mode) => {
|
||||
height: 2px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 2px white;
|
||||
animation: twinkle 3s infinite;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.stars span:nth-child(1) {
|
||||
top: 25%;
|
||||
left: 20%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.stars span:nth-child(2) {
|
||||
top: 40%;
|
||||
left: 40%;
|
||||
animation-delay: 1s;
|
||||
width: 1.5px;
|
||||
height: 1.5px;
|
||||
}
|
||||
|
||||
.stars span:nth-child(3) {
|
||||
top: 60%;
|
||||
left: 25%;
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
@keyframes twinkle {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 云朵装饰(浅色模式) */
|
||||
/* 云朵装饰(浅色模式) - 优化版 */
|
||||
.clouds {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.theme-switch:not(.is-dark):not(.is-auto) .clouds {
|
||||
opacity: 1;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.clouds span {
|
||||
position: absolute;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 100px;
|
||||
}
|
||||
|
||||
@@ -378,7 +349,6 @@ const selectTheme = (mode) => {
|
||||
height: 8px;
|
||||
top: 40%;
|
||||
left: 15%;
|
||||
animation: float 4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.clouds span:nth-child(2) {
|
||||
@@ -386,18 +356,6 @@ const selectTheme = (mode) => {
|
||||
height: 6px;
|
||||
top: 60%;
|
||||
left: 35%;
|
||||
animation: float 4s infinite ease-in-out;
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 切换滑块 */
|
||||
@@ -428,16 +386,17 @@ const selectTheme = (mode) => {
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 自动模式滑块位置 - 玻璃态设计 */
|
||||
/* 自动模式滑块位置 - 优化后的半透明设计 */
|
||||
.theme-switch.is-auto .switch-handle {
|
||||
transform: translateY(-50%) translateX(19px);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
/* 降低 blur 强度,减少 GPU 负担 */
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.1),
|
||||
inset 0 0 8px rgba(255, 255, 255, 0.2);
|
||||
inset 0 0 8px rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
/* 滑块图标 */
|
||||
@@ -471,28 +430,7 @@ const selectTheme = (mode) => {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* 滑块悬停动画 */
|
||||
.theme-switch:hover .switch-handle {
|
||||
animation: bounce 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(-50%) translateX(var(--handle-x, 0));
|
||||
}
|
||||
50% {
|
||||
transform: translateY(calc(-50% - 3px)) translateX(var(--handle-x, 0));
|
||||
}
|
||||
}
|
||||
|
||||
.theme-switch.is-dark:hover .switch-handle {
|
||||
--handle-x: 38px;
|
||||
}
|
||||
|
||||
.theme-switch.is-auto:hover .switch-handle {
|
||||
--handle-x: 19px;
|
||||
}
|
||||
/* 移除弹跳动画,保持简洁 */
|
||||
|
||||
/* 分段按钮样式 - 更现代 */
|
||||
.theme-segmented {
|
||||
|
||||
@@ -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 与 CCR 账户及代理配置
|
||||
</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"
|
||||
@@ -363,6 +376,15 @@
|
||||
{{ getClaudeAuthType(account) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="account.platform === 'ccr'"
|
||||
class="flex items-center gap-1.5 rounded-lg border border-teal-200 bg-gradient-to-r from-teal-100 to-emerald-100 px-2.5 py-1 dark:border-teal-700 dark:from-teal-900/20 dark:to-emerald-900/20"
|
||||
>
|
||||
<i class="fas fa-code-branch text-xs text-teal-700 dark:text-teal-400" />
|
||||
<span class="text-xs font-semibold text-teal-800 dark:text-teal-300">CCR</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-300">Relay</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-gradient-to-r from-gray-100 to-gray-200 px-2.5 py-1"
|
||||
@@ -470,7 +492,9 @@
|
||||
account.platform === 'bedrock' ||
|
||||
account.platform === 'gemini' ||
|
||||
account.platform === 'openai' ||
|
||||
account.platform === 'azure_openai'
|
||||
account.platform === 'openai-responses' ||
|
||||
account.platform === 'azure_openai' ||
|
||||
account.platform === 'ccr'
|
||||
"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
@@ -643,7 +667,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 ||
|
||||
@@ -723,7 +748,9 @@
|
||||
? 'bg-gradient-to-br from-blue-500 to-cyan-600'
|
||||
: account.platform === 'openai'
|
||||
? 'bg-gradient-to-br from-gray-600 to-gray-700'
|
||||
: 'bg-gradient-to-br from-blue-500 to-blue-600'
|
||||
: account.platform === 'ccr'
|
||||
? 'bg-gradient-to-br from-teal-500 to-emerald-600'
|
||||
: 'bg-gradient-to-br from-blue-500 to-blue-600'
|
||||
]"
|
||||
>
|
||||
<i
|
||||
@@ -737,7 +764,9 @@
|
||||
? 'fab fa-microsoft'
|
||||
: account.platform === 'openai'
|
||||
? 'fas fa-openai'
|
||||
: 'fas fa-robot'
|
||||
: account.platform === 'ccr'
|
||||
? 'fas fa-code-branch'
|
||||
: 'fas fa-robot'
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
@@ -932,14 +961,26 @@
|
||||
|
||||
<!-- 添加账户模态框 -->
|
||||
<AccountForm
|
||||
v-if="showCreateAccountModal"
|
||||
@close="showCreateAccountModal = false"
|
||||
v-if="showCreateAccountModal && (!newAccountPlatform || newAccountPlatform !== 'ccr')"
|
||||
@close="closeCreateAccountModal"
|
||||
@platform-changed="newAccountPlatform = $event"
|
||||
@success="handleCreateSuccess"
|
||||
/>
|
||||
<CcrAccountForm
|
||||
v-else-if="showCreateAccountModal && newAccountPlatform === 'ccr'"
|
||||
@close="closeCreateAccountModal"
|
||||
@success="handleCreateSuccess"
|
||||
/>
|
||||
|
||||
<!-- 编辑账户模态框 -->
|
||||
<CcrAccountForm
|
||||
v-if="showEditAccountModal && editingAccount && editingAccount.platform === 'ccr'"
|
||||
:account="editingAccount"
|
||||
@close="showEditAccountModal = false"
|
||||
@success="handleEditSuccess"
|
||||
/>
|
||||
<AccountForm
|
||||
v-if="showEditAccountModal"
|
||||
v-else-if="showEditAccountModal"
|
||||
:account="editingAccount"
|
||||
@close="showEditAccountModal = false"
|
||||
@success="handleEditSuccess"
|
||||
@@ -964,6 +1005,7 @@ import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import AccountForm from '@/components/accounts/AccountForm.vue'
|
||||
import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
||||
|
||||
@@ -1003,7 +1045,9 @@ 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' },
|
||||
{ value: 'ccr', label: 'CCR', icon: 'fa-code-branch' }
|
||||
])
|
||||
|
||||
const groupOptions = computed(() => {
|
||||
@@ -1028,6 +1072,7 @@ const groupOptions = computed(() => {
|
||||
|
||||
// 模态框状态
|
||||
const showCreateAccountModal = ref(false)
|
||||
const newAccountPlatform = ref(null) // 跟踪新建账户选择的平台
|
||||
const showEditAccountModal = ref(false)
|
||||
const editingAccount = ref(null)
|
||||
|
||||
@@ -1108,7 +1153,9 @@ 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 }),
|
||||
apiClient.get('/admin/ccr-accounts', { params })
|
||||
)
|
||||
} else {
|
||||
// 只请求指定平台,其他平台设为null占位
|
||||
@@ -1120,7 +1167,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 +1178,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 +1189,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 +1200,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 +1211,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 +1222,30 @@ 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
|
||||
case 'ccr':
|
||||
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 占位
|
||||
apiClient.get('/admin/ccr-accounts', { params })
|
||||
)
|
||||
break
|
||||
default:
|
||||
@@ -1181,6 +1256,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 +1269,16 @@ 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,
|
||||
ccrData
|
||||
] = await Promise.all(requests)
|
||||
|
||||
const allAccounts = []
|
||||
|
||||
@@ -1212,9 +1296,12 @@ const loadAccounts = async (forceReload = false) => {
|
||||
|
||||
if (claudeConsoleData.success) {
|
||||
const claudeConsoleAccounts = (claudeConsoleData.data || []).map((acc) => {
|
||||
// Claude Console账户暂时不支持直接绑定
|
||||
// 计算每个Claude Console账户绑定的API Key数量
|
||||
const boundApiKeysCount = apiKeys.value.filter(
|
||||
(key) => key.claudeConsoleAccountId === acc.id
|
||||
).length
|
||||
// 后端已经包含了groupInfos,直接使用
|
||||
return { ...acc, platform: 'claude-console', boundApiKeysCount: 0 }
|
||||
return { ...acc, platform: 'claude-console', boundApiKeysCount }
|
||||
})
|
||||
allAccounts.push(...claudeConsoleAccounts)
|
||||
}
|
||||
@@ -1262,6 +1349,28 @@ 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)
|
||||
}
|
||||
|
||||
// CCR 账户
|
||||
if (ccrData && ccrData.success) {
|
||||
const ccrAccounts = (ccrData.data || []).map((acc) => {
|
||||
// CCR 不支持 API Key 绑定,固定为 0
|
||||
return { ...acc, platform: 'ccr', boundApiKeysCount: 0 }
|
||||
})
|
||||
allAccounts.push(...ccrAccounts)
|
||||
}
|
||||
|
||||
// 根据分组筛选器过滤账户
|
||||
let filteredAccounts = allAccounts
|
||||
if (groupFilter.value !== 'all') {
|
||||
@@ -1467,9 +1576,16 @@ const formatRateLimitTime = (minutes) => {
|
||||
|
||||
// 打开创建账户模态框
|
||||
const openCreateAccountModal = () => {
|
||||
newAccountPlatform.value = null // 重置选择的平台
|
||||
showCreateAccountModal.value = true
|
||||
}
|
||||
|
||||
// 关闭创建账户模态框
|
||||
const closeCreateAccountModal = () => {
|
||||
showCreateAccountModal.value = false
|
||||
newAccountPlatform.value = null
|
||||
}
|
||||
|
||||
// 编辑账户
|
||||
const editAccount = (account) => {
|
||||
editingAccount.value = account
|
||||
@@ -1482,8 +1598,11 @@ const deleteAccount = async (account) => {
|
||||
const boundKeysCount = apiKeys.value.filter(
|
||||
(key) =>
|
||||
key.claudeAccountId === account.id ||
|
||||
key.claudeConsoleAccountId === account.id ||
|
||||
key.geminiAccountId === account.id ||
|
||||
key.openaiAccountId === account.id
|
||||
key.openaiAccountId === account.id ||
|
||||
key.azureOpenaiAccountId === account.id ||
|
||||
key.openaiAccountId === `responses:${account.id}`
|
||||
).length
|
||||
|
||||
if (boundKeysCount > 0) {
|
||||
@@ -1515,6 +1634,10 @@ 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 if (account.platform === 'ccr') {
|
||||
endpoint = `/admin/ccr-accounts/${account.id}`
|
||||
} else {
|
||||
endpoint = `/admin/gemini-accounts/${account.id}`
|
||||
}
|
||||
@@ -1559,10 +1682,14 @@ 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') {
|
||||
endpoint = `/admin/claude-console-accounts/${account.id}/reset-status`
|
||||
} else if (account.platform === 'ccr') {
|
||||
endpoint = `/admin/ccr-accounts/${account.id}/reset-status`
|
||||
} else {
|
||||
showToast('不支持的账户类型', 'error')
|
||||
account.isResetting = false
|
||||
@@ -1605,6 +1732,10 @@ 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 if (account.platform === 'ccr') {
|
||||
endpoint = `/admin/ccr-accounts/${account.id}/toggle-schedulable`
|
||||
} else {
|
||||
showToast('该账户类型暂不支持调度控制', 'warning')
|
||||
return
|
||||
@@ -1752,6 +1883,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,15 +2017,23 @@ const paginatedApiKeys = computed(() => {
|
||||
// 加载账户列表
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
const [claudeData, claudeConsoleData, geminiData, openaiData, bedrockData, groupsData] =
|
||||
await Promise.all([
|
||||
apiClient.get('/admin/claude-accounts'),
|
||||
apiClient.get('/admin/claude-console-accounts'),
|
||||
apiClient.get('/admin/gemini-accounts'),
|
||||
apiClient.get('/admin/openai-accounts'),
|
||||
apiClient.get('/admin/bedrock-accounts'),
|
||||
apiClient.get('/admin/account-groups')
|
||||
])
|
||||
const [
|
||||
claudeData,
|
||||
claudeConsoleData,
|
||||
geminiData,
|
||||
openaiData,
|
||||
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')
|
||||
])
|
||||
|
||||
// 合并Claude OAuth账户和Claude Console账户
|
||||
const claudeAccounts = []
|
||||
@@ -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