mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 添加 CCR (Claude Code Router) 账户类型支持
实现通过供应商前缀语法进行 CCR 后端路由的完整支持。 用户现在可以在 Claude Code 中使用 `/model ccr,model_name` 将请求路由到 CCR 后端。 暂时没有实现`/v1/messages/count_tokens`,因为这需要在CCR后端支持。 CCR类型的账户也暂时没有考虑模型的支持情况 ## 核心实现 ### 供应商前缀路由 - 添加 modelHelper 工具用于解析模型名称中的 `ccr,` 供应商前缀 - 检测到前缀时自动路由到 CCR 账户池 - 转发到 CCR 后端前移除供应商前缀 ### 账户管理 - 创建 ccrAccountService 实现 CCR 账户的完整 CRUD 操作 - 支持账户属性:名称、API URL、API Key、代理、优先级、配额 - 实现账户状态:active、rate_limited、unauthorized、overloaded - 支持模型映射和支持模型配置 ### 请求转发 - 实现 ccrRelayService 处理 CCR 后端通信 - 支持流式和非流式请求 - 从 SSE 流中解析和捕获使用数据 - 支持 Bearer 和 x-api-key 两种认证格式 ### 统一调度 - 将 CCR 账户集成到 unifiedClaudeScheduler - 添加 \_selectCcrAccount 方法用于 CCR 特定账户选择 - 支持 CCR 账户的会话粘性 - 防止跨类型会话映射(CCR 会话仅用于 CCR 请求) ### 错误处理 - 实现全面的错误状态管理 - 处理 401(未授权)、429(速率限制)、529(过载)错误 - 成功请求后自动从错误状态恢复 - 支持可配置的速率限制持续时间 ### Web 管理界面 - 添加 CcrAccountForm 组件用于创建/编辑 CCR 账户 - 将 CCR 账户集成到 AccountsView 中,提供完整管理功能 - 支持账户切换、重置和使用统计 - 在界面中显示账户状态和错误信息 ### API 端点 - POST /admin/ccr-accounts - 创建 CCR 账户 - GET /admin/ccr-accounts - 列出所有 CCR 账户 - PUT /admin/ccr-accounts/:id - 更新 CCR 账户 - DELETE /admin/ccr-accounts/:id - 删除 CCR 账户 - PUT /admin/ccr-accounts/:id/toggle - 切换账户启用状态 - PUT /admin/ccr-accounts/:id/toggle-schedulable - 切换可调度状态 - POST /admin/ccr-accounts/:id/reset-usage - 重置每日使用量 - POST /admin/ccr-accounts/:id/reset-status - 重置错误状态 ## 技术细节 - CCR 账户使用 'ccr' 作为 accountType 标识符 - 带有 `ccr,` 前缀的请求绕过普通账户池 - 转发到 CCR 后端前清理模型名称内的`ccr,` - 从流式和非流式响应中捕获使用数据 - 支持缓存令牌跟踪(创建和读取)
This commit is contained in:
@@ -3,6 +3,7 @@ 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 azureOpenaiAccountService = require('../services/azureOpenaiAccountService')
|
||||
@@ -2497,9 +2498,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 +2741,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账户
|
||||
@@ -3565,6 +3942,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
geminiAccounts,
|
||||
bedrockAccountsResult,
|
||||
openaiAccounts,
|
||||
ccrAccounts,
|
||||
todayStats,
|
||||
systemAverages,
|
||||
realtimeMetrics
|
||||
@@ -3575,6 +3953,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
claudeConsoleAccountService.getAllAccounts(),
|
||||
geminiAccountService.getAllAccounts(),
|
||||
bedrockAccountService.getAllAccounts(),
|
||||
ccrAccountService.getAllAccounts(),
|
||||
redis.getAllOpenAIAccounts(),
|
||||
redis.getTodayStats(),
|
||||
redis.getSystemAverages(),
|
||||
@@ -3746,6 +4125,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 +4158,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 +4224,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 +4239,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,
|
||||
|
||||
@@ -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
|
||||
@@ -762,6 +899,23 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
|
||||
|
||||
logger.info(`🔢 Processing token count request for key: ${req.apiKey.name}`)
|
||||
|
||||
// 模型限制(允许列表)校验:统一在此处处理(去除供应商前缀)
|
||||
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: '暂无该模型访问权限'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 生成会话哈希用于sticky会话
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
@@ -801,6 +955,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({
|
||||
|
||||
@@ -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()
|
||||
@@ -79,7 +79,7 @@ class ClaudeRelayService {
|
||||
requestedModel: requestBody.model
|
||||
})
|
||||
|
||||
// 检查模型限制
|
||||
// 检查模型限制(restrictedModels 作为允许列表)
|
||||
if (
|
||||
apiKeyData.enableModelRestriction &&
|
||||
apiKeyData.restrictedModels &&
|
||||
@@ -87,12 +87,12 @@ class ClaudeRelayService {
|
||||
) {
|
||||
const requestedModel = requestBody.model
|
||||
logger.info(
|
||||
`🔒 Model restriction check - Requested model: ${requestedModel}, Restricted models: ${JSON.stringify(apiKeyData.restrictedModels)}`
|
||||
`🔒 Model restriction check - Requested model: ${requestedModel}, Allowed models: ${JSON.stringify(apiKeyData.restrictedModels)}`
|
||||
)
|
||||
|
||||
if (requestedModel && apiKeyData.restrictedModels.includes(requestedModel)) {
|
||||
if (requestedModel && !apiKeyData.restrictedModels.includes(requestedModel)) {
|
||||
logger.warn(
|
||||
`🚫 Model restriction violation for key ${apiKeyData.name}: Attempted to use restricted model ${requestedModel}`
|
||||
`🚫 Model restriction violation for key ${apiKeyData.name}: Attempted model ${requestedModel} not in allowed list`
|
||||
)
|
||||
return {
|
||||
statusCode: 403,
|
||||
@@ -844,7 +844,7 @@ class ClaudeRelayService {
|
||||
requestedModel: requestBody.model
|
||||
})
|
||||
|
||||
// 检查模型限制
|
||||
// 检查模型限制(restrictedModels 作为允许列表)
|
||||
if (
|
||||
apiKeyData.enableModelRestriction &&
|
||||
apiKeyData.restrictedModels &&
|
||||
@@ -852,12 +852,12 @@ class ClaudeRelayService {
|
||||
) {
|
||||
const requestedModel = requestBody.model
|
||||
logger.info(
|
||||
`🔒 [Stream] Model restriction check - Requested model: ${requestedModel}, Restricted models: ${JSON.stringify(apiKeyData.restrictedModels)}`
|
||||
`🔒 [Stream] Model restriction check - Requested model: ${requestedModel}, Allowed models: ${JSON.stringify(apiKeyData.restrictedModels)}`
|
||||
)
|
||||
|
||||
if (requestedModel && apiKeyData.restrictedModels.includes(requestedModel)) {
|
||||
if (requestedModel && !apiKeyData.restrictedModels.includes(requestedModel)) {
|
||||
logger.warn(
|
||||
`🚫 Model restriction violation for key ${apiKeyData.name}: Attempted to use restricted model ${requestedModel}`
|
||||
`🚫 Model restriction violation for key ${apiKeyData.name}: Attempted model ${requestedModel} not in allowed list`
|
||||
)
|
||||
|
||||
// 对于流式响应,需要写入错误并结束流
|
||||
|
||||
@@ -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,15 +224,24 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
// CCR 账户不支持绑定(仅通过 ccr, 前缀进行 CCR 路由)
|
||||
|
||||
// 如果有会话哈希,检查是否有已映射的账户
|
||||
if (sessionHash) {
|
||||
const mappedAccount = await this._getSessionMapping(sessionHash)
|
||||
if (mappedAccount) {
|
||||
// 当本次请求不是 CCR 前缀时,不允许使用指向 CCR 的粘性会话映射
|
||||
if (vendor !== 'ccr' && mappedAccount.accountType === 'ccr') {
|
||||
logger.info(
|
||||
`ℹ️ 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,
|
||||
requestedModel
|
||||
effectiveModel
|
||||
)
|
||||
if (isAvailable) {
|
||||
// 🚀 智能会话续期:剩余时间少于14天时自动续期到15天
|
||||
@@ -199,17 +256,22 @@ class UnifiedClaudeScheduler {
|
||||
)
|
||||
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) {
|
||||
@@ -673,6 +833,8 @@ class UnifiedClaudeScheduler {
|
||||
)
|
||||
} else if (accountType === 'claude-console') {
|
||||
await claudeConsoleAccountService.markAccountRateLimited(accountId)
|
||||
} else if (accountType === 'ccr') {
|
||||
await ccrAccountService.markAccountRateLimited(accountId)
|
||||
}
|
||||
|
||||
// 删除会话映射
|
||||
@@ -697,6 +859,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 +880,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 +957,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 +979,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) {
|
||||
// 🚀 智能会话续期:剩余时间少于14天时自动续期到15天
|
||||
await redis.extendSessionAccountMappingTTL(sessionHash)
|
||||
logger.info(
|
||||
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
||||
)
|
||||
return mappedAccount
|
||||
}
|
||||
}
|
||||
}
|
||||
// 如果映射的账户不可用或不在分组中,删除映射
|
||||
@@ -851,6 +1027,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 +1057,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 +1116,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) {
|
||||
// 🚀 智能会话续期:剩余时间少于14天时自动续期到15天
|
||||
await redis.extendSessionAccountMappingTTL(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()
|
||||
|
||||
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
|
||||
}
|
||||
@@ -123,6 +123,15 @@
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Bedrock</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.platform"
|
||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="radio"
|
||||
value="ccr"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">CCR</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -131,7 +140,8 @@
|
||||
!isEdit &&
|
||||
form.platform !== 'claude-console' &&
|
||||
form.platform !== 'bedrock' &&
|
||||
form.platform !== 'azure_openai'
|
||||
form.platform !== 'azure_openai' &&
|
||||
form.platform !== 'ccr'
|
||||
"
|
||||
>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
@@ -2247,7 +2257,7 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
const emit = defineEmits(['close', 'success', 'platform-changed'])
|
||||
|
||||
const accountsStore = useAccountsStore()
|
||||
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
|
||||
@@ -3439,6 +3449,17 @@ watch(setupTokenAuthCode, (newValue) => {
|
||||
// 如果不是 URL,保持原值(兼容直接输入授权码)
|
||||
})
|
||||
|
||||
// 监听平台变化
|
||||
watch(
|
||||
() => form.value.platform,
|
||||
(newPlatform) => {
|
||||
// 当选择 CCR 平台时,通知父组件
|
||||
if (!isEdit.value) {
|
||||
emit('platform-changed', newPlatform)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 监听账户类型变化
|
||||
watch(
|
||||
() => form.value.accountType,
|
||||
|
||||
454
web/admin-spa/src/components/accounts/CcrAccountForm.vue
Normal file
454
web/admin-spa/src/components/accounts/CcrAccountForm.vue
Normal file
@@ -0,0 +1,454 @@
|
||||
<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>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>代理设置 (可选)</label
|
||||
>
|
||||
<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) {
|
||||
showToast('保存成功', 'success')
|
||||
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) {
|
||||
showToast('创建成功', 'success')
|
||||
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>
|
||||
@@ -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 与 CCR 账户及代理配置
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -363,6 +363,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 +479,8 @@
|
||||
account.platform === 'bedrock' ||
|
||||
account.platform === 'gemini' ||
|
||||
account.platform === 'openai' ||
|
||||
account.platform === 'azure_openai'
|
||||
account.platform === 'azure_openai' ||
|
||||
account.platform === 'ccr'
|
||||
"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
@@ -723,7 +733,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 +749,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 +946,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 +990,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 +1030,8 @@ const platformOptions = ref([
|
||||
{ value: 'gemini', label: 'Gemini', icon: 'fa-google' },
|
||||
{ value: 'openai', label: 'OpenAi', icon: 'fa-openai' },
|
||||
{ value: 'azure_openai', label: 'Azure OpenAI', icon: 'fab fa-microsoft' },
|
||||
{ value: 'bedrock', label: 'Bedrock', icon: 'fab fa-aws' }
|
||||
{ value: 'bedrock', label: 'Bedrock', icon: 'fab fa-aws' },
|
||||
{ value: 'ccr', label: 'CCR', icon: 'fa-code-branch' }
|
||||
])
|
||||
|
||||
const groupOptions = computed(() => {
|
||||
@@ -1028,6 +1056,7 @@ const groupOptions = computed(() => {
|
||||
|
||||
// 模态框状态
|
||||
const showCreateAccountModal = ref(false)
|
||||
const newAccountPlatform = ref(null) // 跟踪新建账户选择的平台
|
||||
const showEditAccountModal = ref(false)
|
||||
const editingAccount = ref(null)
|
||||
|
||||
@@ -1108,7 +1137,8 @@ const loadAccounts = async (forceReload = false) => {
|
||||
apiClient.get('/admin/bedrock-accounts', { params }),
|
||||
apiClient.get('/admin/gemini-accounts', { params }),
|
||||
apiClient.get('/admin/openai-accounts', { params }),
|
||||
apiClient.get('/admin/azure-openai-accounts', { params })
|
||||
apiClient.get('/admin/azure-openai-accounts', { params }),
|
||||
apiClient.get('/admin/ccr-accounts', { params })
|
||||
)
|
||||
} else {
|
||||
// 只请求指定平台,其他平台设为null占位
|
||||
@@ -1173,6 +1203,17 @@ const loadAccounts = async (forceReload = false) => {
|
||||
apiClient.get('/admin/azure-openai-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:
|
||||
// 默认情况下返回空数组
|
||||
requests.push(
|
||||
@@ -1181,6 +1222,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 +1235,15 @@ const loadAccounts = async (forceReload = false) => {
|
||||
// 后端账户API已经包含分组信息,不需要单独加载分组成员关系
|
||||
// await loadGroupMembers(forceReload)
|
||||
|
||||
const [claudeData, claudeConsoleData, bedrockData, geminiData, openaiData, azureOpenaiData] =
|
||||
await Promise.all(requests)
|
||||
const [
|
||||
claudeData,
|
||||
claudeConsoleData,
|
||||
bedrockData,
|
||||
geminiData,
|
||||
openaiData,
|
||||
azureOpenaiData,
|
||||
ccrData
|
||||
] = await Promise.all(requests)
|
||||
|
||||
const allAccounts = []
|
||||
|
||||
@@ -1262,6 +1311,15 @@ const loadAccounts = async (forceReload = false) => {
|
||||
allAccounts.push(...azureOpenaiAccounts)
|
||||
}
|
||||
|
||||
// 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 +1525,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
|
||||
@@ -1515,6 +1580,8 @@ const deleteAccount = async (account) => {
|
||||
endpoint = `/admin/openai-accounts/${account.id}`
|
||||
} else if (account.platform === 'azure_openai') {
|
||||
endpoint = `/admin/azure-openai-accounts/${account.id}`
|
||||
} else if (account.platform === 'ccr') {
|
||||
endpoint = `/admin/ccr-accounts/${account.id}`
|
||||
} else {
|
||||
endpoint = `/admin/gemini-accounts/${account.id}`
|
||||
}
|
||||
@@ -1563,6 +1630,8 @@ const resetAccountStatus = async (account) => {
|
||||
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 +1674,8 @@ const toggleSchedulable = async (account) => {
|
||||
endpoint = `/admin/openai-accounts/${account.id}/toggle-schedulable`
|
||||
} else if (account.platform === 'azure_openai') {
|
||||
endpoint = `/admin/azure-openai-accounts/${account.id}/toggle-schedulable`
|
||||
} else if (account.platform === 'ccr') {
|
||||
endpoint = `/admin/ccr-accounts/${account.id}/toggle-schedulable`
|
||||
} else {
|
||||
showToast('该账户类型暂不支持调度控制', 'warning')
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user