mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +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({
|
||||
|
||||
Reference in New Issue
Block a user