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

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