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:
sususu98
2025-09-10 14:21:15 +08:00
parent 1c3b74f45b
commit 7f9869ae20
11 changed files with 3117 additions and 52 deletions

View File

@@ -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,

View File

@@ -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({

View File

@@ -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 模型,直接返回
}
// 判断是否为 claudeclaude-console 账户
if (!accountType || (accountType !== 'claude' && accountType !== 'claude-console')) {
// 判断是否为 claudeclaude-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 账户,直接返回
}

View 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, // 默认优先级501-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}`)
// 解密敏感字段只解密apiKeyapiUrl不加密
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()

View 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()

View File

@@ -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`
)
// 对于流式响应,需要写入错误并结束流

View File

@@ -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
View 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
}

View File

@@ -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,

View 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>

View File

@@ -7,7 +7,7 @@
账户管理
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 sm:text-base">
管理您的 ClaudeGeminiOpenAI Azure OpenAI 账户及代理配置
管理您的 ClaudeGeminiOpenAIAzure 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