mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
feat: 添加 CCR (Claude Code Router) 账户类型支持
实现通过供应商前缀语法进行 CCR 后端路由的完整支持。 用户现在可以在 Claude Code 中使用 `/model ccr,model_name` 将请求路由到 CCR 后端。 暂时没有实现`/v1/messages/count_tokens`,因为这需要在CCR后端支持。 CCR类型的账户也暂时没有考虑模型的支持情况 ## 核心实现 ### 供应商前缀路由 - 添加 modelHelper 工具用于解析模型名称中的 `ccr,` 供应商前缀 - 检测到前缀时自动路由到 CCR 账户池 - 转发到 CCR 后端前移除供应商前缀 ### 账户管理 - 创建 ccrAccountService 实现 CCR 账户的完整 CRUD 操作 - 支持账户属性:名称、API URL、API Key、代理、优先级、配额 - 实现账户状态:active、rate_limited、unauthorized、overloaded - 支持模型映射和支持模型配置 ### 请求转发 - 实现 ccrRelayService 处理 CCR 后端通信 - 支持流式和非流式请求 - 从 SSE 流中解析和捕获使用数据 - 支持 Bearer 和 x-api-key 两种认证格式 ### 统一调度 - 将 CCR 账户集成到 unifiedClaudeScheduler - 添加 \_selectCcrAccount 方法用于 CCR 特定账户选择 - 支持 CCR 账户的会话粘性 - 防止跨类型会话映射(CCR 会话仅用于 CCR 请求) ### 错误处理 - 实现全面的错误状态管理 - 处理 401(未授权)、429(速率限制)、529(过载)错误 - 成功请求后自动从错误状态恢复 - 支持可配置的速率限制持续时间 ### Web 管理界面 - 添加 CcrAccountForm 组件用于创建/编辑 CCR 账户 - 将 CCR 账户集成到 AccountsView 中,提供完整管理功能 - 支持账户切换、重置和使用统计 - 在界面中显示账户状态和错误信息 ### API 端点 - POST /admin/ccr-accounts - 创建 CCR 账户 - GET /admin/ccr-accounts - 列出所有 CCR 账户 - PUT /admin/ccr-accounts/:id - 更新 CCR 账户 - DELETE /admin/ccr-accounts/:id - 删除 CCR 账户 - PUT /admin/ccr-accounts/:id/toggle - 切换账户启用状态 - PUT /admin/ccr-accounts/:id/toggle-schedulable - 切换可调度状态 - POST /admin/ccr-accounts/:id/reset-usage - 重置每日使用量 - POST /admin/ccr-accounts/:id/reset-status - 重置错误状态 ## 技术细节 - CCR 账户使用 'ccr' 作为 accountType 标识符 - 带有 `ccr,` 前缀的请求绕过普通账户池 - 转发到 CCR 后端前清理模型名称内的`ccr,` - 从流式和非流式响应中捕获使用数据 - 支持缓存令牌跟踪(创建和读取)
This commit is contained in:
@@ -3,6 +3,7 @@ const apiKeyService = require('../services/apiKeyService')
|
|||||||
const claudeAccountService = require('../services/claudeAccountService')
|
const claudeAccountService = require('../services/claudeAccountService')
|
||||||
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
|
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
|
||||||
const bedrockAccountService = require('../services/bedrockAccountService')
|
const bedrockAccountService = require('../services/bedrockAccountService')
|
||||||
|
const ccrAccountService = require('../services/ccrAccountService')
|
||||||
const geminiAccountService = require('../services/geminiAccountService')
|
const geminiAccountService = require('../services/geminiAccountService')
|
||||||
const openaiAccountService = require('../services/openaiAccountService')
|
const openaiAccountService = require('../services/openaiAccountService')
|
||||||
const azureOpenaiAccountService = require('../services/azureOpenaiAccountService')
|
const azureOpenaiAccountService = require('../services/azureOpenaiAccountService')
|
||||||
@@ -2497,9 +2498,9 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
quotaResetTime: quotaResetTime || '00:00'
|
quotaResetTime: quotaResetTime || '00:00'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 如果是分组类型,将账户添加到分组
|
// 如果是分组类型,将账户添加到分组(CCR 归属 Claude 平台分组)
|
||||||
if (accountType === 'group' && groupId) {
|
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}`)
|
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 账户管理
|
||||||
|
|
||||||
// 获取所有Bedrock账户
|
// 获取所有Bedrock账户
|
||||||
@@ -3565,6 +3942,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
|||||||
geminiAccounts,
|
geminiAccounts,
|
||||||
bedrockAccountsResult,
|
bedrockAccountsResult,
|
||||||
openaiAccounts,
|
openaiAccounts,
|
||||||
|
ccrAccounts,
|
||||||
todayStats,
|
todayStats,
|
||||||
systemAverages,
|
systemAverages,
|
||||||
realtimeMetrics
|
realtimeMetrics
|
||||||
@@ -3575,6 +3953,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
|||||||
claudeConsoleAccountService.getAllAccounts(),
|
claudeConsoleAccountService.getAllAccounts(),
|
||||||
geminiAccountService.getAllAccounts(),
|
geminiAccountService.getAllAccounts(),
|
||||||
bedrockAccountService.getAllAccounts(),
|
bedrockAccountService.getAllAccounts(),
|
||||||
|
ccrAccountService.getAllAccounts(),
|
||||||
redis.getAllOpenAIAccounts(),
|
redis.getAllOpenAIAccounts(),
|
||||||
redis.getTodayStats(),
|
redis.getTodayStats(),
|
||||||
redis.getSystemAverages(),
|
redis.getSystemAverages(),
|
||||||
@@ -3746,6 +4125,29 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
|||||||
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
(acc) => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
|
||||||
).length
|
).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 = {
|
const dashboard = {
|
||||||
overview: {
|
overview: {
|
||||||
totalApiKeys: apiKeys.length,
|
totalApiKeys: apiKeys.length,
|
||||||
@@ -3756,31 +4158,36 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
|||||||
claudeConsoleAccounts.length +
|
claudeConsoleAccounts.length +
|
||||||
geminiAccounts.length +
|
geminiAccounts.length +
|
||||||
bedrockAccounts.length +
|
bedrockAccounts.length +
|
||||||
openaiAccounts.length,
|
openaiAccounts.length +
|
||||||
|
ccrAccounts.length,
|
||||||
normalAccounts:
|
normalAccounts:
|
||||||
normalClaudeAccounts +
|
normalClaudeAccounts +
|
||||||
normalClaudeConsoleAccounts +
|
normalClaudeConsoleAccounts +
|
||||||
normalGeminiAccounts +
|
normalGeminiAccounts +
|
||||||
normalBedrockAccounts +
|
normalBedrockAccounts +
|
||||||
normalOpenAIAccounts,
|
normalOpenAIAccounts +
|
||||||
|
normalCcrAccounts,
|
||||||
abnormalAccounts:
|
abnormalAccounts:
|
||||||
abnormalClaudeAccounts +
|
abnormalClaudeAccounts +
|
||||||
abnormalClaudeConsoleAccounts +
|
abnormalClaudeConsoleAccounts +
|
||||||
abnormalGeminiAccounts +
|
abnormalGeminiAccounts +
|
||||||
abnormalBedrockAccounts +
|
abnormalBedrockAccounts +
|
||||||
abnormalOpenAIAccounts,
|
abnormalOpenAIAccounts +
|
||||||
|
abnormalCcrAccounts,
|
||||||
pausedAccounts:
|
pausedAccounts:
|
||||||
pausedClaudeAccounts +
|
pausedClaudeAccounts +
|
||||||
pausedClaudeConsoleAccounts +
|
pausedClaudeConsoleAccounts +
|
||||||
pausedGeminiAccounts +
|
pausedGeminiAccounts +
|
||||||
pausedBedrockAccounts +
|
pausedBedrockAccounts +
|
||||||
pausedOpenAIAccounts,
|
pausedOpenAIAccounts +
|
||||||
|
pausedCcrAccounts,
|
||||||
rateLimitedAccounts:
|
rateLimitedAccounts:
|
||||||
rateLimitedClaudeAccounts +
|
rateLimitedClaudeAccounts +
|
||||||
rateLimitedClaudeConsoleAccounts +
|
rateLimitedClaudeConsoleAccounts +
|
||||||
rateLimitedGeminiAccounts +
|
rateLimitedGeminiAccounts +
|
||||||
rateLimitedBedrockAccounts +
|
rateLimitedBedrockAccounts +
|
||||||
rateLimitedOpenAIAccounts,
|
rateLimitedOpenAIAccounts +
|
||||||
|
rateLimitedCcrAccounts,
|
||||||
// 各平台详细统计
|
// 各平台详细统计
|
||||||
accountsByPlatform: {
|
accountsByPlatform: {
|
||||||
claude: {
|
claude: {
|
||||||
@@ -3817,6 +4224,13 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
|||||||
abnormal: abnormalOpenAIAccounts,
|
abnormal: abnormalOpenAIAccounts,
|
||||||
paused: pausedOpenAIAccounts,
|
paused: pausedOpenAIAccounts,
|
||||||
rateLimited: rateLimitedOpenAIAccounts
|
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 +
|
normalClaudeConsoleAccounts +
|
||||||
normalGeminiAccounts +
|
normalGeminiAccounts +
|
||||||
normalBedrockAccounts +
|
normalBedrockAccounts +
|
||||||
normalOpenAIAccounts,
|
normalOpenAIAccounts +
|
||||||
|
normalCcrAccounts,
|
||||||
totalClaudeAccounts: claudeAccounts.length + claudeConsoleAccounts.length,
|
totalClaudeAccounts: claudeAccounts.length + claudeConsoleAccounts.length,
|
||||||
activeClaudeAccounts: normalClaudeAccounts + normalClaudeConsoleAccounts,
|
activeClaudeAccounts: normalClaudeAccounts + normalClaudeConsoleAccounts,
|
||||||
rateLimitedClaudeAccounts: rateLimitedClaudeAccounts + rateLimitedClaudeConsoleAccounts,
|
rateLimitedClaudeAccounts: rateLimitedClaudeAccounts + rateLimitedClaudeConsoleAccounts,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const express = require('express')
|
|||||||
const claudeRelayService = require('../services/claudeRelayService')
|
const claudeRelayService = require('../services/claudeRelayService')
|
||||||
const claudeConsoleRelayService = require('../services/claudeConsoleRelayService')
|
const claudeConsoleRelayService = require('../services/claudeConsoleRelayService')
|
||||||
const bedrockRelayService = require('../services/bedrockRelayService')
|
const bedrockRelayService = require('../services/bedrockRelayService')
|
||||||
|
const ccrRelayService = require('../services/ccrRelayService')
|
||||||
const bedrockAccountService = require('../services/bedrockAccountService')
|
const bedrockAccountService = require('../services/bedrockAccountService')
|
||||||
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler')
|
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler')
|
||||||
const apiKeyService = require('../services/apiKeyService')
|
const apiKeyService = require('../services/apiKeyService')
|
||||||
@@ -9,6 +10,7 @@ const pricingService = require('../services/pricingService')
|
|||||||
const { authenticateApiKey } = require('../middleware/auth')
|
const { authenticateApiKey } = require('../middleware/auth')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
const redis = require('../models/redis')
|
const redis = require('../models/redis')
|
||||||
|
const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper')
|
||||||
const sessionHelper = require('../utils/sessionHelper')
|
const sessionHelper = require('../utils/sessionHelper')
|
||||||
|
|
||||||
const router = express.Router()
|
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
|
const isStream = req.body.stream === true
|
||||||
|
|
||||||
@@ -354,6 +373,110 @@ async function handleMessagesRequest(req, res) {
|
|||||||
}
|
}
|
||||||
return undefined
|
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数据,记录警告但不进行估算
|
// 流式请求完成后 - 如果没有捕获到usage数据,记录警告但不进行估算
|
||||||
@@ -447,6 +570,17 @@ async function handleMessagesRequest(req, res) {
|
|||||||
accountId
|
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', {
|
logger.info('📡 Claude API response received', {
|
||||||
@@ -483,7 +617,10 @@ async function handleMessagesRequest(req, res) {
|
|||||||
const outputTokens = jsonData.usage.output_tokens || 0
|
const outputTokens = jsonData.usage.output_tokens || 0
|
||||||
const cacheCreateTokens = jsonData.usage.cache_creation_input_tokens || 0
|
const cacheCreateTokens = jsonData.usage.cache_creation_input_tokens || 0
|
||||||
const cacheReadTokens = jsonData.usage.cache_read_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)
|
// 记录真实的token使用量(包含模型信息和所有4种token以及账户ID)
|
||||||
const { accountId: responseAccountId } = response
|
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}`)
|
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会话
|
// 生成会话哈希用于sticky会话
|
||||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
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路径
|
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 {
|
} else {
|
||||||
// Bedrock不支持count_tokens
|
// Bedrock不支持count_tokens
|
||||||
return res.status(501).json({
|
return res.status(501).json({
|
||||||
|
|||||||
@@ -483,6 +483,10 @@ class ApiKeyService {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
key.tags = []
|
key.tags = []
|
||||||
}
|
}
|
||||||
|
// 不暴露已弃用字段
|
||||||
|
if (Object.prototype.hasOwnProperty.call(key, 'ccrAccountId')) {
|
||||||
|
delete key.ccrAccountId
|
||||||
|
}
|
||||||
delete key.apiKey // 不返回哈希后的key
|
delete key.apiKey // 不返回哈希后的key
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -846,8 +850,11 @@ class ApiKeyService {
|
|||||||
return // 不是 Opus 模型,直接返回
|
return // 不是 Opus 模型,直接返回
|
||||||
}
|
}
|
||||||
|
|
||||||
// 判断是否为 claude 或 claude-console 账户
|
// 判断是否为 claude、claude-console 或 ccr 账户
|
||||||
if (!accountType || (accountType !== 'claude' && accountType !== 'claude-console')) {
|
if (
|
||||||
|
!accountType ||
|
||||||
|
(accountType !== 'claude' && accountType !== 'claude-console' && accountType !== 'ccr')
|
||||||
|
) {
|
||||||
logger.debug(`⚠️ Skipping Opus cost recording for non-Claude account type: ${accountType}`)
|
logger.debug(`⚠️ Skipping Opus cost recording for non-Claude account type: ${accountType}`)
|
||||||
return // 不是 claude 账户,直接返回
|
return // 不是 claude 账户,直接返回
|
||||||
}
|
}
|
||||||
|
|||||||
903
src/services/ccrAccountService.js
Normal file
903
src/services/ccrAccountService.js
Normal file
@@ -0,0 +1,903 @@
|
|||||||
|
const { v4: uuidv4 } = require('uuid')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const ProxyHelper = require('../utils/proxyHelper')
|
||||||
|
const redis = require('../models/redis')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
const config = require('../../config/config')
|
||||||
|
const LRUCache = require('../utils/lruCache')
|
||||||
|
|
||||||
|
class CcrAccountService {
|
||||||
|
constructor() {
|
||||||
|
// 加密相关常量
|
||||||
|
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
|
||||||
|
this.ENCRYPTION_SALT = 'ccr-account-salt'
|
||||||
|
|
||||||
|
// Redis键前缀
|
||||||
|
this.ACCOUNT_KEY_PREFIX = 'ccr_account:'
|
||||||
|
this.SHARED_ACCOUNTS_KEY = 'shared_ccr_accounts'
|
||||||
|
|
||||||
|
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
|
||||||
|
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 密集型操作
|
||||||
|
this._encryptionKeyCache = null
|
||||||
|
|
||||||
|
// 🔄 解密结果缓存,提高解密性能
|
||||||
|
this._decryptCache = new LRUCache(500)
|
||||||
|
|
||||||
|
// 🧹 定期清理缓存(每10分钟)
|
||||||
|
setInterval(
|
||||||
|
() => {
|
||||||
|
this._decryptCache.cleanup()
|
||||||
|
logger.info('🧹 CCR account decrypt cache cleanup completed', this._decryptCache.getStats())
|
||||||
|
},
|
||||||
|
10 * 60 * 1000
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🏢 创建CCR账户
|
||||||
|
async createAccount(options = {}) {
|
||||||
|
const {
|
||||||
|
name = 'CCR Account',
|
||||||
|
description = '',
|
||||||
|
apiUrl = '',
|
||||||
|
apiKey = '',
|
||||||
|
priority = 50, // 默认优先级50(1-100)
|
||||||
|
supportedModels = [], // 支持的模型列表或映射表,空数组/对象表示支持所有
|
||||||
|
userAgent = 'claude-relay-service/1.0.0',
|
||||||
|
rateLimitDuration = 60, // 限流时间(分钟)
|
||||||
|
proxy = null,
|
||||||
|
isActive = true,
|
||||||
|
accountType = 'shared', // 'dedicated' or 'shared'
|
||||||
|
schedulable = true, // 是否可被调度
|
||||||
|
dailyQuota = 0, // 每日额度限制(美元),0表示不限制
|
||||||
|
quotaResetTime = '00:00' // 额度重置时间(HH:mm格式)
|
||||||
|
} = options
|
||||||
|
|
||||||
|
// 验证必填字段
|
||||||
|
if (!apiUrl || !apiKey) {
|
||||||
|
throw new Error('API URL and API Key are required for CCR account')
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountId = uuidv4()
|
||||||
|
|
||||||
|
// 处理 supportedModels,确保向后兼容
|
||||||
|
const processedModels = this._processModelMapping(supportedModels)
|
||||||
|
|
||||||
|
const accountData = {
|
||||||
|
id: accountId,
|
||||||
|
platform: 'ccr',
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
apiUrl,
|
||||||
|
apiKey: this._encryptSensitiveData(apiKey),
|
||||||
|
priority: priority.toString(),
|
||||||
|
supportedModels: JSON.stringify(processedModels),
|
||||||
|
userAgent,
|
||||||
|
rateLimitDuration: rateLimitDuration.toString(),
|
||||||
|
proxy: proxy ? JSON.stringify(proxy) : '',
|
||||||
|
isActive: isActive.toString(),
|
||||||
|
accountType,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
lastUsedAt: '',
|
||||||
|
status: 'active',
|
||||||
|
errorMessage: '',
|
||||||
|
// 限流相关
|
||||||
|
rateLimitedAt: '',
|
||||||
|
rateLimitStatus: '',
|
||||||
|
// 调度控制
|
||||||
|
schedulable: schedulable.toString(),
|
||||||
|
// 额度管理相关
|
||||||
|
dailyQuota: dailyQuota.toString(), // 每日额度限制(美元)
|
||||||
|
dailyUsage: '0', // 当日使用金额(美元)
|
||||||
|
// 使用与统计一致的时区日期,避免边界问题
|
||||||
|
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
|
||||||
|
quotaResetTime, // 额度重置时间
|
||||||
|
quotaStoppedAt: '' // 因额度停用的时间
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
logger.debug(
|
||||||
|
`[DEBUG] Saving CCR account data to Redis with key: ${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||||
|
)
|
||||||
|
logger.debug(`[DEBUG] CCR Account data to save: ${JSON.stringify(accountData, null, 2)}`)
|
||||||
|
|
||||||
|
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, accountData)
|
||||||
|
|
||||||
|
// 如果是共享账户,添加到共享账户集合
|
||||||
|
if (accountType === 'shared') {
|
||||||
|
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`🏢 Created CCR account: ${name} (${accountId})`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: accountId,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
apiUrl,
|
||||||
|
priority,
|
||||||
|
supportedModels,
|
||||||
|
userAgent,
|
||||||
|
rateLimitDuration,
|
||||||
|
isActive,
|
||||||
|
proxy,
|
||||||
|
accountType,
|
||||||
|
status: 'active',
|
||||||
|
createdAt: accountData.createdAt,
|
||||||
|
dailyQuota,
|
||||||
|
dailyUsage: 0,
|
||||||
|
lastResetDate: accountData.lastResetDate,
|
||||||
|
quotaResetTime,
|
||||||
|
quotaStoppedAt: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📋 获取所有CCR账户
|
||||||
|
async getAllAccounts() {
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
|
||||||
|
const accounts = []
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const accountData = await client.hgetall(key)
|
||||||
|
if (accountData && Object.keys(accountData).length > 0) {
|
||||||
|
// 获取限流状态信息
|
||||||
|
const rateLimitInfo = this._getRateLimitInfo(accountData)
|
||||||
|
|
||||||
|
accounts.push({
|
||||||
|
id: accountData.id,
|
||||||
|
platform: accountData.platform,
|
||||||
|
name: accountData.name,
|
||||||
|
description: accountData.description,
|
||||||
|
apiUrl: accountData.apiUrl,
|
||||||
|
priority: parseInt(accountData.priority) || 50,
|
||||||
|
supportedModels: JSON.parse(accountData.supportedModels || '[]'),
|
||||||
|
userAgent: accountData.userAgent,
|
||||||
|
rateLimitDuration: Number.isNaN(parseInt(accountData.rateLimitDuration))
|
||||||
|
? 60
|
||||||
|
: parseInt(accountData.rateLimitDuration),
|
||||||
|
isActive: accountData.isActive === 'true',
|
||||||
|
proxy: accountData.proxy ? JSON.parse(accountData.proxy) : null,
|
||||||
|
accountType: accountData.accountType || 'shared',
|
||||||
|
createdAt: accountData.createdAt,
|
||||||
|
lastUsedAt: accountData.lastUsedAt,
|
||||||
|
status: accountData.status || 'active',
|
||||||
|
errorMessage: accountData.errorMessage,
|
||||||
|
rateLimitInfo,
|
||||||
|
schedulable: accountData.schedulable !== 'false', // 默认为true,只有明确设置为false才不可调度
|
||||||
|
// 额度管理相关
|
||||||
|
dailyQuota: parseFloat(accountData.dailyQuota || '0'),
|
||||||
|
dailyUsage: parseFloat(accountData.dailyUsage || '0'),
|
||||||
|
lastResetDate: accountData.lastResetDate || '',
|
||||||
|
quotaResetTime: accountData.quotaResetTime || '00:00',
|
||||||
|
quotaStoppedAt: accountData.quotaStoppedAt || null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return accounts
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get CCR accounts:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔍 获取单个账户(内部使用,包含敏感信息)
|
||||||
|
async getAccount(accountId) {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
logger.debug(`[DEBUG] Getting CCR account data for ID: ${accountId}`)
|
||||||
|
const accountData = await client.hgetall(`${this.ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||||
|
|
||||||
|
if (!accountData || Object.keys(accountData).length === 0) {
|
||||||
|
logger.debug(`[DEBUG] No CCR account data found for ID: ${accountId}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`[DEBUG] Raw CCR account data keys: ${Object.keys(accountData).join(', ')}`)
|
||||||
|
logger.debug(`[DEBUG] Raw supportedModels value: ${accountData.supportedModels}`)
|
||||||
|
|
||||||
|
// 解密敏感字段(只解密apiKey,apiUrl不加密)
|
||||||
|
const decryptedKey = this._decryptSensitiveData(accountData.apiKey)
|
||||||
|
logger.debug(
|
||||||
|
`[DEBUG] URL exists: ${!!accountData.apiUrl}, Decrypted key exists: ${!!decryptedKey}`
|
||||||
|
)
|
||||||
|
|
||||||
|
accountData.apiKey = decryptedKey
|
||||||
|
|
||||||
|
// 解析JSON字段
|
||||||
|
const parsedModels = JSON.parse(accountData.supportedModels || '[]')
|
||||||
|
logger.debug(`[DEBUG] Parsed supportedModels: ${JSON.stringify(parsedModels)}`)
|
||||||
|
|
||||||
|
accountData.supportedModels = parsedModels
|
||||||
|
accountData.priority = parseInt(accountData.priority) || 50
|
||||||
|
{
|
||||||
|
const _parsedDuration = parseInt(accountData.rateLimitDuration)
|
||||||
|
accountData.rateLimitDuration = Number.isNaN(_parsedDuration) ? 60 : _parsedDuration
|
||||||
|
}
|
||||||
|
accountData.isActive = accountData.isActive === 'true'
|
||||||
|
accountData.schedulable = accountData.schedulable !== 'false' // 默认为true
|
||||||
|
|
||||||
|
if (accountData.proxy) {
|
||||||
|
accountData.proxy = JSON.parse(accountData.proxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`[DEBUG] Final CCR account data - name: ${accountData.name}, hasApiUrl: ${!!accountData.apiUrl}, hasApiKey: ${!!accountData.apiKey}, supportedModels: ${JSON.stringify(accountData.supportedModels)}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return accountData
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📝 更新账户
|
||||||
|
async updateAccount(accountId, updates) {
|
||||||
|
try {
|
||||||
|
const existingAccount = await this.getAccount(accountId)
|
||||||
|
if (!existingAccount) {
|
||||||
|
throw new Error('CCR Account not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const updatedData = {}
|
||||||
|
|
||||||
|
// 处理各个字段的更新
|
||||||
|
logger.debug(
|
||||||
|
`[DEBUG] CCR update request received with fields: ${Object.keys(updates).join(', ')}`
|
||||||
|
)
|
||||||
|
logger.debug(`[DEBUG] CCR Updates content: ${JSON.stringify(updates, null, 2)}`)
|
||||||
|
|
||||||
|
if (updates.name !== undefined) {
|
||||||
|
updatedData.name = updates.name
|
||||||
|
}
|
||||||
|
if (updates.description !== undefined) {
|
||||||
|
updatedData.description = updates.description
|
||||||
|
}
|
||||||
|
if (updates.apiUrl !== undefined) {
|
||||||
|
updatedData.apiUrl = updates.apiUrl
|
||||||
|
}
|
||||||
|
if (updates.apiKey !== undefined) {
|
||||||
|
updatedData.apiKey = this._encryptSensitiveData(updates.apiKey)
|
||||||
|
}
|
||||||
|
if (updates.priority !== undefined) {
|
||||||
|
updatedData.priority = updates.priority.toString()
|
||||||
|
}
|
||||||
|
if (updates.supportedModels !== undefined) {
|
||||||
|
logger.debug(`[DEBUG] Updating supportedModels: ${JSON.stringify(updates.supportedModels)}`)
|
||||||
|
// 处理 supportedModels,确保向后兼容
|
||||||
|
const processedModels = this._processModelMapping(updates.supportedModels)
|
||||||
|
updatedData.supportedModels = JSON.stringify(processedModels)
|
||||||
|
}
|
||||||
|
if (updates.userAgent !== undefined) {
|
||||||
|
updatedData.userAgent = updates.userAgent
|
||||||
|
}
|
||||||
|
if (updates.rateLimitDuration !== undefined) {
|
||||||
|
updatedData.rateLimitDuration = updates.rateLimitDuration.toString()
|
||||||
|
}
|
||||||
|
if (updates.proxy !== undefined) {
|
||||||
|
updatedData.proxy = updates.proxy ? JSON.stringify(updates.proxy) : ''
|
||||||
|
}
|
||||||
|
if (updates.isActive !== undefined) {
|
||||||
|
updatedData.isActive = updates.isActive.toString()
|
||||||
|
}
|
||||||
|
if (updates.schedulable !== undefined) {
|
||||||
|
updatedData.schedulable = updates.schedulable.toString()
|
||||||
|
}
|
||||||
|
if (updates.dailyQuota !== undefined) {
|
||||||
|
updatedData.dailyQuota = updates.dailyQuota.toString()
|
||||||
|
}
|
||||||
|
if (updates.quotaResetTime !== undefined) {
|
||||||
|
updatedData.quotaResetTime = updates.quotaResetTime
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updatedData)
|
||||||
|
|
||||||
|
// 处理共享账户集合变更
|
||||||
|
if (updates.accountType !== undefined) {
|
||||||
|
updatedData.accountType = updates.accountType
|
||||||
|
if (updates.accountType === 'shared') {
|
||||||
|
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||||
|
} else {
|
||||||
|
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`📝 Updated CCR account: ${accountId}`)
|
||||||
|
return await this.getAccount(accountId)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to update CCR account ${accountId}:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🗑️ 删除账户
|
||||||
|
async deleteAccount(accountId) {
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
|
||||||
|
// 从共享账户集合中移除
|
||||||
|
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||||
|
|
||||||
|
// 删除账户数据
|
||||||
|
const result = await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||||
|
|
||||||
|
if (result === 0) {
|
||||||
|
throw new Error('CCR Account not found or already deleted')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`🗑️ Deleted CCR account: ${accountId}`)
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to delete CCR account ${accountId}:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚫 标记账户为限流状态
|
||||||
|
async markAccountRateLimited(accountId) {
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const account = await this.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
throw new Error('CCR Account not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果限流时间设置为 0,表示不启用限流机制,直接返回
|
||||||
|
if (account.rateLimitDuration === 0) {
|
||||||
|
logger.info(
|
||||||
|
`ℹ️ CCR account ${account.name} (${accountId}) has rate limiting disabled, skipping rate limit`
|
||||||
|
)
|
||||||
|
return { success: true, skipped: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
|
||||||
|
status: 'rate_limited',
|
||||||
|
rateLimitedAt: now,
|
||||||
|
rateLimitStatus: 'active',
|
||||||
|
errorMessage: 'Rate limited by upstream service'
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.warn(`⏱️ Marked CCR account as rate limited: ${account.name} (${accountId})`)
|
||||||
|
return { success: true, rateLimitedAt: now }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to mark CCR account as rate limited: ${accountId}`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 移除账户限流状态
|
||||||
|
async removeAccountRateLimit(accountId) {
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||||
|
|
||||||
|
// 获取账户当前状态和额度信息
|
||||||
|
const [, quotaStoppedAt] = await client.hmget(accountKey, 'status', 'quotaStoppedAt')
|
||||||
|
|
||||||
|
// 删除限流相关字段
|
||||||
|
await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus')
|
||||||
|
|
||||||
|
// 根据不同情况决定是否恢复账户
|
||||||
|
let newStatus = 'active'
|
||||||
|
let errorMessage = ''
|
||||||
|
|
||||||
|
// 如果因额度问题停用,不要自动激活
|
||||||
|
if (quotaStoppedAt) {
|
||||||
|
newStatus = 'quota_exceeded'
|
||||||
|
errorMessage = 'Account stopped due to quota exceeded'
|
||||||
|
logger.info(
|
||||||
|
`ℹ️ CCR account ${accountId} rate limit removed but remains stopped due to quota exceeded`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logger.success(`✅ Removed rate limit for CCR account: ${accountId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.hmset(accountKey, {
|
||||||
|
status: newStatus,
|
||||||
|
errorMessage
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true, newStatus }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to remove rate limit for CCR account: ${accountId}`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔍 检查账户是否被限流
|
||||||
|
async isAccountRateLimited(accountId) {
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||||
|
const [rateLimitedAt, rateLimitDuration] = await client.hmget(
|
||||||
|
accountKey,
|
||||||
|
'rateLimitedAt',
|
||||||
|
'rateLimitDuration'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rateLimitedAt) {
|
||||||
|
const limitTime = new Date(rateLimitedAt)
|
||||||
|
const duration = parseInt(rateLimitDuration) || 60
|
||||||
|
const now = new Date()
|
||||||
|
const expireTime = new Date(limitTime.getTime() + duration * 60 * 1000)
|
||||||
|
|
||||||
|
if (now < expireTime) {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
// 限流时间已过,自动移除限流状态
|
||||||
|
await this.removeAccountRateLimit(accountId)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to check rate limit status for CCR account: ${accountId}`, error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 标记账户为过载状态
|
||||||
|
async markAccountOverloaded(accountId) {
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const account = await this.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
throw new Error('CCR Account not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
|
||||||
|
status: 'overloaded',
|
||||||
|
overloadedAt: now,
|
||||||
|
errorMessage: 'Account overloaded'
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.warn(`🔥 Marked CCR account as overloaded: ${account.name} (${accountId})`)
|
||||||
|
return { success: true, overloadedAt: now }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to mark CCR account as overloaded: ${accountId}`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 移除账户过载状态
|
||||||
|
async removeAccountOverload(accountId) {
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||||
|
|
||||||
|
// 删除过载相关字段
|
||||||
|
await client.hdel(accountKey, 'overloadedAt')
|
||||||
|
|
||||||
|
await client.hmset(accountKey, {
|
||||||
|
status: 'active',
|
||||||
|
errorMessage: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.success(`✅ Removed overload status for CCR account: ${accountId}`)
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to remove overload status for CCR account: ${accountId}`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔍 检查账户是否过载
|
||||||
|
async isAccountOverloaded(accountId) {
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||||
|
const status = await client.hget(accountKey, 'status')
|
||||||
|
return status === 'overloaded'
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to check overload status for CCR account: ${accountId}`, error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚫 标记账户为未授权状态
|
||||||
|
async markAccountUnauthorized(accountId) {
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const account = await this.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
throw new Error('CCR Account not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
|
||||||
|
status: 'unauthorized',
|
||||||
|
errorMessage: 'API key invalid or unauthorized'
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.warn(`🚫 Marked CCR account as unauthorized: ${account.name} (${accountId})`)
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to mark CCR account as unauthorized: ${accountId}`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔄 处理模型映射
|
||||||
|
_processModelMapping(supportedModels) {
|
||||||
|
// 如果是空值,返回空对象(支持所有模型)
|
||||||
|
if (!supportedModels || (Array.isArray(supportedModels) && supportedModels.length === 0)) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已经是对象格式(新的映射表格式),直接返回
|
||||||
|
if (typeof supportedModels === 'object' && !Array.isArray(supportedModels)) {
|
||||||
|
return supportedModels
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是数组格式(旧格式),转换为映射表
|
||||||
|
if (Array.isArray(supportedModels)) {
|
||||||
|
const mapping = {}
|
||||||
|
supportedModels.forEach((model) => {
|
||||||
|
if (model && typeof model === 'string') {
|
||||||
|
mapping[model] = model // 默认映射:原模型名 -> 原模型名
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return mapping
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔍 检查模型是否被支持
|
||||||
|
isModelSupported(modelMapping, requestedModel) {
|
||||||
|
// 如果映射表为空,支持所有模型
|
||||||
|
if (!modelMapping || Object.keys(modelMapping).length === 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 检查请求的模型是否在映射表的键中
|
||||||
|
return Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔄 获取映射后的模型名称
|
||||||
|
getMappedModel(modelMapping, requestedModel) {
|
||||||
|
// 如果映射表为空,返回原模型
|
||||||
|
if (!modelMapping || Object.keys(modelMapping).length === 0) {
|
||||||
|
return requestedModel
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回映射后的模型名,如果不存在映射则返回原模型名
|
||||||
|
return modelMapping[requestedModel] || requestedModel
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔐 加密敏感数据
|
||||||
|
_encryptSensitiveData(data) {
|
||||||
|
if (!data) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const key = this._generateEncryptionKey()
|
||||||
|
const iv = crypto.randomBytes(16)
|
||||||
|
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
||||||
|
let encrypted = cipher.update(data, 'utf8', 'hex')
|
||||||
|
encrypted += cipher.final('hex')
|
||||||
|
return `${iv.toString('hex')}:${encrypted}`
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ CCR encryption error:', error)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔓 解密敏感数据
|
||||||
|
_decryptSensitiveData(encryptedData) {
|
||||||
|
if (!encryptedData) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🎯 检查缓存
|
||||||
|
const cacheKey = crypto.createHash('sha256').update(encryptedData).digest('hex')
|
||||||
|
const cached = this._decryptCache.get(cacheKey)
|
||||||
|
if (cached !== undefined) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parts = encryptedData.split(':')
|
||||||
|
if (parts.length === 2) {
|
||||||
|
const key = this._generateEncryptionKey()
|
||||||
|
const iv = Buffer.from(parts[0], 'hex')
|
||||||
|
const encrypted = parts[1]
|
||||||
|
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
||||||
|
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
|
||||||
|
decrypted += decipher.final('utf8')
|
||||||
|
|
||||||
|
// 💾 存入缓存(5分钟过期)
|
||||||
|
this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000)
|
||||||
|
|
||||||
|
return decrypted
|
||||||
|
} else {
|
||||||
|
logger.error('❌ Invalid CCR encrypted data format')
|
||||||
|
return encryptedData
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ CCR decryption error:', error)
|
||||||
|
return encryptedData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔑 生成加密密钥
|
||||||
|
_generateEncryptionKey() {
|
||||||
|
// 性能优化:缓存密钥派生结果,避免重复的 CPU 密集计算
|
||||||
|
if (!this._encryptionKeyCache) {
|
||||||
|
this._encryptionKeyCache = crypto.scryptSync(
|
||||||
|
config.security.encryptionKey,
|
||||||
|
this.ENCRYPTION_SALT,
|
||||||
|
32
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return this._encryptionKeyCache
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔍 获取限流状态信息
|
||||||
|
_getRateLimitInfo(accountData) {
|
||||||
|
const { rateLimitedAt } = accountData
|
||||||
|
const rateLimitDuration = parseInt(accountData.rateLimitDuration) || 60
|
||||||
|
|
||||||
|
if (rateLimitedAt) {
|
||||||
|
const limitTime = new Date(rateLimitedAt)
|
||||||
|
const now = new Date()
|
||||||
|
const expireTime = new Date(limitTime.getTime() + rateLimitDuration * 60 * 1000)
|
||||||
|
const remainingMs = expireTime.getTime() - now.getTime()
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRateLimited: remainingMs > 0,
|
||||||
|
rateLimitedAt,
|
||||||
|
rateLimitExpireAt: expireTime.toISOString(),
|
||||||
|
remainingTimeMs: Math.max(0, remainingMs),
|
||||||
|
remainingTimeMinutes: Math.max(0, Math.ceil(remainingMs / (60 * 1000)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRateLimited: false,
|
||||||
|
rateLimitedAt: null,
|
||||||
|
rateLimitExpireAt: null,
|
||||||
|
remainingTimeMs: 0,
|
||||||
|
remainingTimeMinutes: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔧 创建代理客户端
|
||||||
|
_createProxyAgent(proxy) {
|
||||||
|
return ProxyHelper.createProxyAgent(proxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 💰 检查配额使用情况(可选实现)
|
||||||
|
async checkQuotaUsage(accountId) {
|
||||||
|
try {
|
||||||
|
const account = await this.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyQuota = parseFloat(account.dailyQuota || '0')
|
||||||
|
// 如果未设置额度限制,则不限制
|
||||||
|
if (dailyQuota <= 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否需要重置每日使用量
|
||||||
|
const today = redis.getDateStringInTimezone()
|
||||||
|
if (account.lastResetDate !== today) {
|
||||||
|
await this.resetDailyUsage(accountId)
|
||||||
|
return false // 刚重置,不会超额
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当日使用统计
|
||||||
|
const usageStats = await this.getAccountUsageStats(accountId)
|
||||||
|
if (!usageStats) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyUsage = usageStats.dailyUsage || 0
|
||||||
|
const isExceeded = dailyUsage >= dailyQuota
|
||||||
|
|
||||||
|
if (isExceeded) {
|
||||||
|
// 标记账户因额度停用
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
|
||||||
|
status: 'quota_exceeded',
|
||||||
|
errorMessage: `Daily quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`,
|
||||||
|
quotaStoppedAt: new Date().toISOString()
|
||||||
|
})
|
||||||
|
logger.warn(
|
||||||
|
`💰 CCR account ${account.name} (${accountId}) quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 发送 Webhook 通知
|
||||||
|
try {
|
||||||
|
const webhookNotifier = require('../utils/webhookNotifier')
|
||||||
|
await webhookNotifier.sendAccountAnomalyNotification({
|
||||||
|
accountId,
|
||||||
|
accountName: account.name || accountId,
|
||||||
|
platform: 'ccr',
|
||||||
|
status: 'quota_exceeded',
|
||||||
|
errorCode: 'QUOTA_EXCEEDED',
|
||||||
|
reason: `Daily quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
})
|
||||||
|
} catch (webhookError) {
|
||||||
|
logger.warn('Failed to send webhook notification for CCR quota exceeded:', webhookError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isExceeded
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to check quota usage for CCR account ${accountId}:`, error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔄 重置每日使用量(可选实现)
|
||||||
|
async resetDailyUsage(accountId) {
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
|
||||||
|
dailyUsage: '0',
|
||||||
|
lastResetDate: redis.getDateStringInTimezone(),
|
||||||
|
quotaStoppedAt: ''
|
||||||
|
})
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to reset daily usage for CCR account: ${accountId}`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚫 检查账户是否超额
|
||||||
|
async isAccountQuotaExceeded(accountId) {
|
||||||
|
try {
|
||||||
|
const account = await this.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyQuota = parseFloat(account.dailyQuota || '0')
|
||||||
|
// 如果未设置额度限制,则不限制
|
||||||
|
if (dailyQuota <= 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当日使用统计
|
||||||
|
const usageStats = await this.getAccountUsageStats(accountId)
|
||||||
|
if (!usageStats) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyUsage = usageStats.dailyUsage || 0
|
||||||
|
const isExceeded = dailyUsage >= dailyQuota
|
||||||
|
|
||||||
|
if (isExceeded && !account.quotaStoppedAt) {
|
||||||
|
// 标记账户因额度停用
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
|
||||||
|
status: 'quota_exceeded',
|
||||||
|
errorMessage: `Daily quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`,
|
||||||
|
quotaStoppedAt: new Date().toISOString()
|
||||||
|
})
|
||||||
|
logger.warn(`💰 CCR account ${account.name} (${accountId}) quota exceeded`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return isExceeded
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to check quota for CCR account ${accountId}:`, error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔄 重置所有CCR账户的每日使用量
|
||||||
|
async resetAllDailyUsage() {
|
||||||
|
try {
|
||||||
|
const accounts = await this.getAllAccounts()
|
||||||
|
const today = redis.getDateStringInTimezone()
|
||||||
|
let resetCount = 0
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
if (account.lastResetDate !== today) {
|
||||||
|
await this.resetDailyUsage(account.id)
|
||||||
|
resetCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`✅ Reset daily usage for ${resetCount} CCR accounts`)
|
||||||
|
return { success: true, resetCount }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to reset all CCR daily usage:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📊 获取CCR账户使用统计(含每日费用)
|
||||||
|
async getAccountUsageStats(accountId) {
|
||||||
|
try {
|
||||||
|
// 使用统一的 Redis 统计
|
||||||
|
const usageStats = await redis.getAccountUsageStats(accountId)
|
||||||
|
|
||||||
|
// 叠加账户自身的额度配置
|
||||||
|
const accountData = await this.getAccount(accountId)
|
||||||
|
if (!accountData) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyQuota = parseFloat(accountData.dailyQuota || '0')
|
||||||
|
const currentDailyCost = usageStats?.daily?.cost || 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
dailyQuota,
|
||||||
|
dailyUsage: currentDailyCost,
|
||||||
|
remainingQuota: dailyQuota > 0 ? Math.max(0, dailyQuota - currentDailyCost) : null,
|
||||||
|
usagePercentage: dailyQuota > 0 ? (currentDailyCost / dailyQuota) * 100 : 0,
|
||||||
|
lastResetDate: accountData.lastResetDate,
|
||||||
|
quotaResetTime: accountData.quotaResetTime,
|
||||||
|
quotaStoppedAt: accountData.quotaStoppedAt,
|
||||||
|
isQuotaExceeded: dailyQuota > 0 && currentDailyCost >= dailyQuota,
|
||||||
|
fullUsageStats: usageStats
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get CCR account usage stats:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔄 重置CCR账户所有异常状态
|
||||||
|
async resetAccountStatus(accountId) {
|
||||||
|
try {
|
||||||
|
const accountData = await this.getAccount(accountId)
|
||||||
|
if (!accountData) {
|
||||||
|
throw new Error('Account not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||||
|
|
||||||
|
const updates = {
|
||||||
|
status: 'active',
|
||||||
|
errorMessage: '',
|
||||||
|
schedulable: 'true',
|
||||||
|
isActive: 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldsToDelete = [
|
||||||
|
'rateLimitedAt',
|
||||||
|
'rateLimitStatus',
|
||||||
|
'unauthorizedAt',
|
||||||
|
'unauthorizedCount',
|
||||||
|
'overloadedAt',
|
||||||
|
'overloadStatus',
|
||||||
|
'blockedAt',
|
||||||
|
'quotaStoppedAt'
|
||||||
|
]
|
||||||
|
|
||||||
|
await client.hset(accountKey, updates)
|
||||||
|
await client.hdel(accountKey, ...fieldsToDelete)
|
||||||
|
|
||||||
|
logger.success(`✅ Reset all error status for CCR account ${accountId}`)
|
||||||
|
|
||||||
|
// 异步发送 Webhook 通知(忽略错误)
|
||||||
|
try {
|
||||||
|
const webhookNotifier = require('../utils/webhookNotifier')
|
||||||
|
await webhookNotifier.sendAccountAnomalyNotification({
|
||||||
|
accountId,
|
||||||
|
accountName: accountData.name || accountId,
|
||||||
|
platform: 'ccr',
|
||||||
|
status: 'recovered',
|
||||||
|
errorCode: 'STATUS_RESET',
|
||||||
|
reason: 'Account status manually reset',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
})
|
||||||
|
} catch (webhookError) {
|
||||||
|
logger.warn('Failed to send webhook notification for CCR status reset:', webhookError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, accountId }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to reset CCR account status: ${accountId}`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new CcrAccountService()
|
||||||
641
src/services/ccrRelayService.js
Normal file
641
src/services/ccrRelayService.js
Normal file
@@ -0,0 +1,641 @@
|
|||||||
|
const axios = require('axios')
|
||||||
|
const ccrAccountService = require('./ccrAccountService')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
const config = require('../../config/config')
|
||||||
|
const { parseVendorPrefixedModel } = require('../utils/modelHelper')
|
||||||
|
|
||||||
|
class CcrRelayService {
|
||||||
|
constructor() {
|
||||||
|
this.defaultUserAgent = 'claude-relay-service/1.0.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚀 转发请求到CCR API
|
||||||
|
async relayRequest(
|
||||||
|
requestBody,
|
||||||
|
apiKeyData,
|
||||||
|
clientRequest,
|
||||||
|
clientResponse,
|
||||||
|
clientHeaders,
|
||||||
|
accountId,
|
||||||
|
options = {}
|
||||||
|
) {
|
||||||
|
let abortController = null
|
||||||
|
let account = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取账户信息
|
||||||
|
account = await ccrAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
throw new Error('CCR account not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`📤 Processing CCR API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId})`
|
||||||
|
)
|
||||||
|
logger.debug(`🌐 Account API URL: ${account.apiUrl}`)
|
||||||
|
logger.debug(`🔍 Account supportedModels: ${JSON.stringify(account.supportedModels)}`)
|
||||||
|
logger.debug(`🔑 Account has apiKey: ${!!account.apiKey}`)
|
||||||
|
logger.debug(`📝 Request model: ${requestBody.model}`)
|
||||||
|
|
||||||
|
// 处理模型前缀解析和映射
|
||||||
|
const { baseModel } = parseVendorPrefixedModel(requestBody.model)
|
||||||
|
logger.debug(`🔄 Parsed base model: ${baseModel} from original: ${requestBody.model}`)
|
||||||
|
|
||||||
|
let mappedModel = baseModel
|
||||||
|
if (
|
||||||
|
account.supportedModels &&
|
||||||
|
typeof account.supportedModels === 'object' &&
|
||||||
|
!Array.isArray(account.supportedModels)
|
||||||
|
) {
|
||||||
|
const newModel = ccrAccountService.getMappedModel(account.supportedModels, baseModel)
|
||||||
|
if (newModel !== baseModel) {
|
||||||
|
logger.info(`🔄 Mapping model from ${baseModel} to ${newModel}`)
|
||||||
|
mappedModel = newModel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建修改后的请求体,使用去前缀后的模型名
|
||||||
|
const modifiedRequestBody = {
|
||||||
|
...requestBody,
|
||||||
|
model: mappedModel
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建代理agent
|
||||||
|
const proxyAgent = ccrAccountService._createProxyAgent(account.proxy)
|
||||||
|
|
||||||
|
// 创建AbortController用于取消请求
|
||||||
|
abortController = new AbortController()
|
||||||
|
|
||||||
|
// 设置客户端断开监听器
|
||||||
|
const handleClientDisconnect = () => {
|
||||||
|
logger.info('🔌 Client disconnected, aborting CCR request')
|
||||||
|
if (abortController && !abortController.signal.aborted) {
|
||||||
|
abortController.abort()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听客户端断开事件
|
||||||
|
if (clientRequest) {
|
||||||
|
clientRequest.once('close', handleClientDisconnect)
|
||||||
|
}
|
||||||
|
if (clientResponse) {
|
||||||
|
clientResponse.once('close', handleClientDisconnect)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建完整的API URL
|
||||||
|
const cleanUrl = account.apiUrl.replace(/\/$/, '') // 移除末尾斜杠
|
||||||
|
let apiEndpoint
|
||||||
|
|
||||||
|
if (options.customPath) {
|
||||||
|
// 如果指定了自定义路径(如 count_tokens),使用它
|
||||||
|
const baseUrl = cleanUrl.replace(/\/v1\/messages$/, '') // 移除已有的 /v1/messages
|
||||||
|
apiEndpoint = `${baseUrl}${options.customPath}`
|
||||||
|
} else {
|
||||||
|
// 默认使用 messages 端点
|
||||||
|
apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages`
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`🎯 Final API endpoint: ${apiEndpoint}`)
|
||||||
|
logger.debug(`[DEBUG] Options passed to relayRequest: ${JSON.stringify(options)}`)
|
||||||
|
logger.debug(`[DEBUG] Client headers received: ${JSON.stringify(clientHeaders)}`)
|
||||||
|
|
||||||
|
// 过滤客户端请求头
|
||||||
|
const filteredHeaders = this._filterClientHeaders(clientHeaders)
|
||||||
|
logger.debug(`[DEBUG] Filtered client headers: ${JSON.stringify(filteredHeaders)}`)
|
||||||
|
|
||||||
|
// 决定使用的 User-Agent:优先使用账户自定义的,否则透传客户端的,最后才使用默认值
|
||||||
|
const userAgent =
|
||||||
|
account.userAgent ||
|
||||||
|
clientHeaders?.['user-agent'] ||
|
||||||
|
clientHeaders?.['User-Agent'] ||
|
||||||
|
this.defaultUserAgent
|
||||||
|
|
||||||
|
// 准备请求配置
|
||||||
|
const requestConfig = {
|
||||||
|
method: 'POST',
|
||||||
|
url: apiEndpoint,
|
||||||
|
data: modifiedRequestBody,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
'User-Agent': userAgent,
|
||||||
|
...filteredHeaders
|
||||||
|
},
|
||||||
|
httpsAgent: proxyAgent,
|
||||||
|
timeout: config.requestTimeout || 600000,
|
||||||
|
signal: abortController.signal,
|
||||||
|
validateStatus: () => true // 接受所有状态码
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 API Key 格式选择认证方式
|
||||||
|
if (account.apiKey && account.apiKey.startsWith('sk-ant-')) {
|
||||||
|
// Anthropic 官方 API Key 使用 x-api-key
|
||||||
|
requestConfig.headers['x-api-key'] = account.apiKey
|
||||||
|
logger.debug('[DEBUG] Using x-api-key authentication for sk-ant-* API key')
|
||||||
|
} else {
|
||||||
|
// 其他 API Key (包括CCR API Key) 使用 Authorization Bearer
|
||||||
|
requestConfig.headers['Authorization'] = `Bearer ${account.apiKey}`
|
||||||
|
logger.debug('[DEBUG] Using Authorization Bearer authentication')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`[DEBUG] Initial headers before beta: ${JSON.stringify(requestConfig.headers, null, 2)}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 添加beta header如果需要
|
||||||
|
if (options.betaHeader) {
|
||||||
|
logger.debug(`[DEBUG] Adding beta header: ${options.betaHeader}`)
|
||||||
|
requestConfig.headers['anthropic-beta'] = options.betaHeader
|
||||||
|
} else {
|
||||||
|
logger.debug('[DEBUG] No beta header to add')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
logger.debug(
|
||||||
|
'📤 Sending request to CCR API with headers:',
|
||||||
|
JSON.stringify(requestConfig.headers, null, 2)
|
||||||
|
)
|
||||||
|
const response = await axios(requestConfig)
|
||||||
|
|
||||||
|
// 移除监听器(请求成功完成)
|
||||||
|
if (clientRequest) {
|
||||||
|
clientRequest.removeListener('close', handleClientDisconnect)
|
||||||
|
}
|
||||||
|
if (clientResponse) {
|
||||||
|
clientResponse.removeListener('close', handleClientDisconnect)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`🔗 CCR API response: ${response.status}`)
|
||||||
|
logger.debug(`[DEBUG] Response headers: ${JSON.stringify(response.headers)}`)
|
||||||
|
logger.debug(`[DEBUG] Response data type: ${typeof response.data}`)
|
||||||
|
logger.debug(
|
||||||
|
`[DEBUG] Response data length: ${response.data ? (typeof response.data === 'string' ? response.data.length : JSON.stringify(response.data).length) : 0}`
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
`[DEBUG] Response data preview: ${typeof response.data === 'string' ? response.data.substring(0, 200) : JSON.stringify(response.data).substring(0, 200)}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 检查错误状态并相应处理
|
||||||
|
if (response.status === 401) {
|
||||||
|
logger.warn(`🚫 Unauthorized error detected for CCR account ${accountId}`)
|
||||||
|
await ccrAccountService.markAccountUnauthorized(accountId)
|
||||||
|
} else if (response.status === 429) {
|
||||||
|
logger.warn(`🚫 Rate limit detected for CCR account ${accountId}`)
|
||||||
|
// 收到429先检查是否因为超过了手动配置的每日额度
|
||||||
|
await ccrAccountService.checkQuotaUsage(accountId).catch((err) => {
|
||||||
|
logger.error('❌ Failed to check quota after 429 error:', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
await ccrAccountService.markAccountRateLimited(accountId)
|
||||||
|
} else if (response.status === 529) {
|
||||||
|
logger.warn(`🚫 Overload error detected for CCR account ${accountId}`)
|
||||||
|
await ccrAccountService.markAccountOverloaded(accountId)
|
||||||
|
} else if (response.status === 200 || response.status === 201) {
|
||||||
|
// 如果请求成功,检查并移除错误状态
|
||||||
|
const isRateLimited = await ccrAccountService.isAccountRateLimited(accountId)
|
||||||
|
if (isRateLimited) {
|
||||||
|
await ccrAccountService.removeAccountRateLimit(accountId)
|
||||||
|
}
|
||||||
|
const isOverloaded = await ccrAccountService.isAccountOverloaded(accountId)
|
||||||
|
if (isOverloaded) {
|
||||||
|
await ccrAccountService.removeAccountOverload(accountId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新最后使用时间
|
||||||
|
await this._updateLastUsedTime(accountId)
|
||||||
|
|
||||||
|
const responseBody =
|
||||||
|
typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
|
||||||
|
logger.debug(`[DEBUG] Final response body to return: ${responseBody}`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode: response.status,
|
||||||
|
headers: response.headers,
|
||||||
|
body: responseBody,
|
||||||
|
accountId
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 处理特定错误
|
||||||
|
if (error.name === 'AbortError' || error.code === 'ECONNABORTED') {
|
||||||
|
logger.info('Request aborted due to client disconnect')
|
||||||
|
throw new Error('Client disconnected')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
`❌ CCR relay request failed (Account: ${account?.name || accountId}):`,
|
||||||
|
error.message
|
||||||
|
)
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🌊 处理流式响应
|
||||||
|
async relayStreamRequestWithUsageCapture(
|
||||||
|
requestBody,
|
||||||
|
apiKeyData,
|
||||||
|
responseStream,
|
||||||
|
clientHeaders,
|
||||||
|
usageCallback,
|
||||||
|
accountId,
|
||||||
|
streamTransformer = null,
|
||||||
|
options = {}
|
||||||
|
) {
|
||||||
|
let account = null
|
||||||
|
try {
|
||||||
|
// 获取账户信息
|
||||||
|
account = await ccrAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
throw new Error('CCR account not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`📡 Processing streaming CCR API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId})`
|
||||||
|
)
|
||||||
|
logger.debug(`🌐 Account API URL: ${account.apiUrl}`)
|
||||||
|
|
||||||
|
// 处理模型前缀解析和映射
|
||||||
|
const { baseModel } = parseVendorPrefixedModel(requestBody.model)
|
||||||
|
logger.debug(`🔄 Parsed base model: ${baseModel} from original: ${requestBody.model}`)
|
||||||
|
|
||||||
|
let mappedModel = baseModel
|
||||||
|
if (
|
||||||
|
account.supportedModels &&
|
||||||
|
typeof account.supportedModels === 'object' &&
|
||||||
|
!Array.isArray(account.supportedModels)
|
||||||
|
) {
|
||||||
|
const newModel = ccrAccountService.getMappedModel(account.supportedModels, baseModel)
|
||||||
|
if (newModel !== baseModel) {
|
||||||
|
logger.info(`🔄 [Stream] Mapping model from ${baseModel} to ${newModel}`)
|
||||||
|
mappedModel = newModel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建修改后的请求体,使用去前缀后的模型名
|
||||||
|
const modifiedRequestBody = {
|
||||||
|
...requestBody,
|
||||||
|
model: mappedModel
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建代理agent
|
||||||
|
const proxyAgent = ccrAccountService._createProxyAgent(account.proxy)
|
||||||
|
|
||||||
|
// 发送流式请求
|
||||||
|
await this._makeCcrStreamRequest(
|
||||||
|
modifiedRequestBody,
|
||||||
|
account,
|
||||||
|
proxyAgent,
|
||||||
|
clientHeaders,
|
||||||
|
responseStream,
|
||||||
|
accountId,
|
||||||
|
usageCallback,
|
||||||
|
streamTransformer,
|
||||||
|
options
|
||||||
|
)
|
||||||
|
|
||||||
|
// 更新最后使用时间
|
||||||
|
await this._updateLastUsedTime(accountId)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ CCR stream relay failed (Account: ${account?.name || accountId}):`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🌊 发送流式请求到CCR API
|
||||||
|
async _makeCcrStreamRequest(
|
||||||
|
body,
|
||||||
|
account,
|
||||||
|
proxyAgent,
|
||||||
|
clientHeaders,
|
||||||
|
responseStream,
|
||||||
|
accountId,
|
||||||
|
usageCallback,
|
||||||
|
streamTransformer = null,
|
||||||
|
requestOptions = {}
|
||||||
|
) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let aborted = false
|
||||||
|
|
||||||
|
// 构建完整的API URL
|
||||||
|
const cleanUrl = account.apiUrl.replace(/\/$/, '') // 移除末尾斜杠
|
||||||
|
const apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages`
|
||||||
|
|
||||||
|
logger.debug(`🎯 Final API endpoint for stream: ${apiEndpoint}`)
|
||||||
|
|
||||||
|
// 过滤客户端请求头
|
||||||
|
const filteredHeaders = this._filterClientHeaders(clientHeaders)
|
||||||
|
logger.debug(`[DEBUG] Filtered client headers: ${JSON.stringify(filteredHeaders)}`)
|
||||||
|
|
||||||
|
// 决定使用的 User-Agent:优先使用账户自定义的,否则透传客户端的,最后才使用默认值
|
||||||
|
const userAgent =
|
||||||
|
account.userAgent ||
|
||||||
|
clientHeaders?.['user-agent'] ||
|
||||||
|
clientHeaders?.['User-Agent'] ||
|
||||||
|
this.defaultUserAgent
|
||||||
|
|
||||||
|
// 准备请求配置
|
||||||
|
const requestConfig = {
|
||||||
|
method: 'POST',
|
||||||
|
url: apiEndpoint,
|
||||||
|
data: body,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
'User-Agent': userAgent,
|
||||||
|
...filteredHeaders
|
||||||
|
},
|
||||||
|
httpsAgent: proxyAgent,
|
||||||
|
timeout: config.requestTimeout || 600000,
|
||||||
|
responseType: 'stream',
|
||||||
|
validateStatus: () => true // 接受所有状态码
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 API Key 格式选择认证方式
|
||||||
|
if (account.apiKey && account.apiKey.startsWith('sk-ant-')) {
|
||||||
|
// Anthropic 官方 API Key 使用 x-api-key
|
||||||
|
requestConfig.headers['x-api-key'] = account.apiKey
|
||||||
|
logger.debug('[DEBUG] Using x-api-key authentication for sk-ant-* API key')
|
||||||
|
} else {
|
||||||
|
// 其他 API Key (包括CCR API Key) 使用 Authorization Bearer
|
||||||
|
requestConfig.headers['Authorization'] = `Bearer ${account.apiKey}`
|
||||||
|
logger.debug('[DEBUG] Using Authorization Bearer authentication')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加beta header如果需要
|
||||||
|
if (requestOptions.betaHeader) {
|
||||||
|
requestConfig.headers['anthropic-beta'] = requestOptions.betaHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
const request = axios(requestConfig)
|
||||||
|
|
||||||
|
request
|
||||||
|
.then((response) => {
|
||||||
|
logger.debug(`🌊 CCR stream response status: ${response.status}`)
|
||||||
|
|
||||||
|
// 错误响应处理
|
||||||
|
if (response.status !== 200) {
|
||||||
|
logger.error(
|
||||||
|
`❌ CCR API returned error status: ${response.status} | Account: ${account?.name || accountId}`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
ccrAccountService.markAccountUnauthorized(accountId)
|
||||||
|
} else if (response.status === 429) {
|
||||||
|
ccrAccountService.markAccountRateLimited(accountId)
|
||||||
|
// 检查是否因为超过每日额度
|
||||||
|
ccrAccountService.checkQuotaUsage(accountId).catch((err) => {
|
||||||
|
logger.error('❌ Failed to check quota after 429 error:', err)
|
||||||
|
})
|
||||||
|
} else if (response.status === 529) {
|
||||||
|
ccrAccountService.markAccountOverloaded(accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置错误响应的状态码和响应头
|
||||||
|
if (!responseStream.headersSent) {
|
||||||
|
const errorHeaders = {
|
||||||
|
'Content-Type': response.headers['content-type'] || 'application/json',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive'
|
||||||
|
}
|
||||||
|
// 避免 Transfer-Encoding 冲突,让 Express 自动处理
|
||||||
|
delete errorHeaders['Transfer-Encoding']
|
||||||
|
delete errorHeaders['Content-Length']
|
||||||
|
responseStream.writeHead(response.status, errorHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接透传错误数据,不进行包装
|
||||||
|
response.data.on('data', (chunk) => {
|
||||||
|
if (!responseStream.destroyed) {
|
||||||
|
responseStream.write(chunk)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
response.data.on('end', () => {
|
||||||
|
if (!responseStream.destroyed) {
|
||||||
|
responseStream.end()
|
||||||
|
}
|
||||||
|
resolve() // 不抛出异常,正常完成流处理
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成功响应,检查并移除错误状态
|
||||||
|
ccrAccountService.isAccountRateLimited(accountId).then((isRateLimited) => {
|
||||||
|
if (isRateLimited) {
|
||||||
|
ccrAccountService.removeAccountRateLimit(accountId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ccrAccountService.isAccountOverloaded(accountId).then((isOverloaded) => {
|
||||||
|
if (isOverloaded) {
|
||||||
|
ccrAccountService.removeAccountOverload(accountId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 设置响应头
|
||||||
|
if (!responseStream.headersSent) {
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Headers': 'Cache-Control'
|
||||||
|
}
|
||||||
|
responseStream.writeHead(200, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理流数据和使用统计收集
|
||||||
|
let rawBuffer = ''
|
||||||
|
const collectedUsage = {}
|
||||||
|
|
||||||
|
response.data.on('data', (chunk) => {
|
||||||
|
if (aborted || responseStream.destroyed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chunkStr = chunk.toString('utf8')
|
||||||
|
rawBuffer += chunkStr
|
||||||
|
|
||||||
|
// 按行分割处理 SSE 数据
|
||||||
|
const lines = rawBuffer.split('\n')
|
||||||
|
rawBuffer = lines.pop() // 保留最后一个可能不完整的行
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.trim()) {
|
||||||
|
// 解析 SSE 数据并收集使用统计
|
||||||
|
const usageData = this._parseSSELineForUsage(line)
|
||||||
|
if (usageData) {
|
||||||
|
Object.assign(collectedUsage, usageData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用流转换器(如果提供)
|
||||||
|
let outputLine = line
|
||||||
|
if (streamTransformer && typeof streamTransformer === 'function') {
|
||||||
|
outputLine = streamTransformer(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入到响应流
|
||||||
|
if (outputLine && !responseStream.destroyed) {
|
||||||
|
responseStream.write(`${outputLine}\n`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 空行也需要传递
|
||||||
|
if (!responseStream.destroyed) {
|
||||||
|
responseStream.write('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('❌ Error processing SSE chunk:', err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
response.data.on('end', () => {
|
||||||
|
if (!responseStream.destroyed) {
|
||||||
|
responseStream.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果收集到使用统计数据,调用回调
|
||||||
|
if (usageCallback && Object.keys(collectedUsage).length > 0) {
|
||||||
|
try {
|
||||||
|
logger.debug(`📊 Collected usage data: ${JSON.stringify(collectedUsage)}`)
|
||||||
|
// 在 usage 回调中包含模型信息
|
||||||
|
usageCallback({ ...collectedUsage, accountId, model: body.model })
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('❌ Error in usage callback:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
response.data.on('error', (err) => {
|
||||||
|
logger.error('❌ Stream data error:', err)
|
||||||
|
if (!responseStream.destroyed) {
|
||||||
|
responseStream.end()
|
||||||
|
}
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 客户端断开处理
|
||||||
|
responseStream.on('close', () => {
|
||||||
|
logger.info('🔌 Client disconnected from CCR stream')
|
||||||
|
aborted = true
|
||||||
|
if (response.data && typeof response.data.destroy === 'function') {
|
||||||
|
response.data.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
responseStream.on('error', (err) => {
|
||||||
|
logger.error('❌ Response stream error:', err)
|
||||||
|
aborted = true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!responseStream.headersSent) {
|
||||||
|
responseStream.writeHead(500, { 'Content-Type': 'application/json' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorResponse = {
|
||||||
|
error: {
|
||||||
|
type: 'internal_error',
|
||||||
|
message: 'CCR API request failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!responseStream.destroyed) {
|
||||||
|
responseStream.write(`data: ${JSON.stringify(errorResponse)}\n\n`)
|
||||||
|
responseStream.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📊 解析SSE行以提取使用统计信息
|
||||||
|
_parseSSELineForUsage(line) {
|
||||||
|
try {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
const data = line.substring(6).trim()
|
||||||
|
if (data === '[DONE]') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonData = JSON.parse(data)
|
||||||
|
|
||||||
|
// 检查是否包含使用统计信息
|
||||||
|
if (jsonData.usage) {
|
||||||
|
return {
|
||||||
|
input_tokens: jsonData.usage.input_tokens || 0,
|
||||||
|
output_tokens: jsonData.usage.output_tokens || 0,
|
||||||
|
cache_creation_input_tokens: jsonData.usage.cache_creation_input_tokens || 0,
|
||||||
|
cache_read_input_tokens: jsonData.usage.cache_read_input_tokens || 0,
|
||||||
|
// 支持 ephemeral cache 字段
|
||||||
|
cache_creation_input_tokens_ephemeral_5m:
|
||||||
|
jsonData.usage.cache_creation_input_tokens_ephemeral_5m || 0,
|
||||||
|
cache_creation_input_tokens_ephemeral_1h:
|
||||||
|
jsonData.usage.cache_creation_input_tokens_ephemeral_1h || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 message_delta 事件中的使用统计
|
||||||
|
if (jsonData.type === 'message_delta' && jsonData.delta && jsonData.delta.usage) {
|
||||||
|
return {
|
||||||
|
input_tokens: jsonData.delta.usage.input_tokens || 0,
|
||||||
|
output_tokens: jsonData.delta.usage.output_tokens || 0,
|
||||||
|
cache_creation_input_tokens: jsonData.delta.usage.cache_creation_input_tokens || 0,
|
||||||
|
cache_read_input_tokens: jsonData.delta.usage.cache_read_input_tokens || 0,
|
||||||
|
cache_creation_input_tokens_ephemeral_5m:
|
||||||
|
jsonData.delta.usage.cache_creation_input_tokens_ephemeral_5m || 0,
|
||||||
|
cache_creation_input_tokens_ephemeral_1h:
|
||||||
|
jsonData.delta.usage.cache_creation_input_tokens_ephemeral_1h || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// 忽略解析错误,不是所有行都包含 JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔍 过滤客户端请求头
|
||||||
|
_filterClientHeaders(clientHeaders) {
|
||||||
|
if (!clientHeaders) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredHeaders = {}
|
||||||
|
const allowedHeaders = [
|
||||||
|
'accept-language',
|
||||||
|
'anthropic-beta',
|
||||||
|
'anthropic-dangerous-direct-browser-access'
|
||||||
|
]
|
||||||
|
|
||||||
|
// 只保留允许的头部信息
|
||||||
|
for (const [key, value] of Object.entries(clientHeaders)) {
|
||||||
|
const lowerKey = key.toLowerCase()
|
||||||
|
if (allowedHeaders.includes(lowerKey)) {
|
||||||
|
filteredHeaders[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredHeaders
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⏰ 更新账户最后使用时间
|
||||||
|
async _updateLastUsedTime(accountId) {
|
||||||
|
try {
|
||||||
|
const redis = require('../models/redis')
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
await client.hset(`ccr_account:${accountId}`, 'lastUsedAt', new Date().toISOString())
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to update last used time for CCR account ${accountId}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new CcrRelayService()
|
||||||
@@ -79,7 +79,7 @@ class ClaudeRelayService {
|
|||||||
requestedModel: requestBody.model
|
requestedModel: requestBody.model
|
||||||
})
|
})
|
||||||
|
|
||||||
// 检查模型限制
|
// 检查模型限制(restrictedModels 作为允许列表)
|
||||||
if (
|
if (
|
||||||
apiKeyData.enableModelRestriction &&
|
apiKeyData.enableModelRestriction &&
|
||||||
apiKeyData.restrictedModels &&
|
apiKeyData.restrictedModels &&
|
||||||
@@ -87,12 +87,12 @@ class ClaudeRelayService {
|
|||||||
) {
|
) {
|
||||||
const requestedModel = requestBody.model
|
const requestedModel = requestBody.model
|
||||||
logger.info(
|
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(
|
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 {
|
return {
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
@@ -844,7 +844,7 @@ class ClaudeRelayService {
|
|||||||
requestedModel: requestBody.model
|
requestedModel: requestBody.model
|
||||||
})
|
})
|
||||||
|
|
||||||
// 检查模型限制
|
// 检查模型限制(restrictedModels 作为允许列表)
|
||||||
if (
|
if (
|
||||||
apiKeyData.enableModelRestriction &&
|
apiKeyData.enableModelRestriction &&
|
||||||
apiKeyData.restrictedModels &&
|
apiKeyData.restrictedModels &&
|
||||||
@@ -852,12 +852,12 @@ class ClaudeRelayService {
|
|||||||
) {
|
) {
|
||||||
const requestedModel = requestBody.model
|
const requestedModel = requestBody.model
|
||||||
logger.info(
|
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(
|
logger.warn(
|
||||||
`🚫 Model restriction violation for key ${apiKeyData.name}: Attempted to use restricted model ${requestedModel}`
|
`🚫 Model restriction violation for key ${apiKeyData.name}: Attempted model ${requestedModel} not in allowed list`
|
||||||
)
|
)
|
||||||
|
|
||||||
// 对于流式响应,需要写入错误并结束流
|
// 对于流式响应,需要写入错误并结束流
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
const claudeAccountService = require('./claudeAccountService')
|
const claudeAccountService = require('./claudeAccountService')
|
||||||
const claudeConsoleAccountService = require('./claudeConsoleAccountService')
|
const claudeConsoleAccountService = require('./claudeConsoleAccountService')
|
||||||
const bedrockAccountService = require('./bedrockAccountService')
|
const bedrockAccountService = require('./bedrockAccountService')
|
||||||
|
const ccrAccountService = require('./ccrAccountService')
|
||||||
const accountGroupService = require('./accountGroupService')
|
const accountGroupService = require('./accountGroupService')
|
||||||
const redis = require('../models/redis')
|
const redis = require('../models/redis')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
|
const { parseVendorPrefixedModel } = require('../utils/modelHelper')
|
||||||
|
|
||||||
class UnifiedClaudeScheduler {
|
class UnifiedClaudeScheduler {
|
||||||
constructor() {
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🎯 统一调度Claude账号(官方和Console)
|
// 🎯 统一调度Claude账号(官方和Console)
|
||||||
async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
|
async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
|
||||||
try {
|
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绑定了专属账户或分组,优先使用
|
// 如果API Key绑定了专属账户或分组,优先使用
|
||||||
if (apiKeyData.claudeAccountId) {
|
if (apiKeyData.claudeAccountId) {
|
||||||
// 检查是否是分组
|
// 检查是否是分组
|
||||||
@@ -102,7 +145,12 @@ class UnifiedClaudeScheduler {
|
|||||||
logger.info(
|
logger.info(
|
||||||
`🎯 API key ${apiKeyData.name} is bound to group ${groupId}, selecting from group`
|
`🎯 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) {
|
if (sessionHash) {
|
||||||
const mappedAccount = await this._getSessionMapping(sessionHash)
|
const mappedAccount = await this._getSessionMapping(sessionHash)
|
||||||
if (mappedAccount) {
|
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(
|
const isAvailable = await this._isAccountAvailable(
|
||||||
mappedAccount.accountId,
|
mappedAccount.accountId,
|
||||||
mappedAccount.accountType,
|
mappedAccount.accountType,
|
||||||
requestedModel
|
effectiveModel
|
||||||
)
|
)
|
||||||
if (isAvailable) {
|
if (isAvailable) {
|
||||||
// 🚀 智能会话续期:剩余时间少于14天时自动续期到15天
|
// 🚀 智能会话续期:剩余时间少于14天时自动续期到15天
|
||||||
@@ -199,17 +256,22 @@ class UnifiedClaudeScheduler {
|
|||||||
)
|
)
|
||||||
await this._deleteSessionMapping(sessionHash)
|
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 (availableAccounts.length === 0) {
|
||||||
// 提供更详细的错误信息
|
// 提供更详细的错误信息
|
||||||
if (requestedModel) {
|
if (effectiveModel) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`No available Claude accounts support the requested model: ${requestedModel}`
|
`No available Claude accounts support the requested model: ${effectiveModel}`
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
throw new Error('No available Claude accounts (neither official nor console)')
|
throw new Error('No available Claude accounts (neither official nor console)')
|
||||||
@@ -249,7 +311,7 @@ class UnifiedClaudeScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 📋 获取所有可用账户(合并官方和Console)
|
// 📋 获取所有可用账户(合并官方和Console)
|
||||||
async _getAllAvailableAccounts(apiKeyData, requestedModel = null) {
|
async _getAllAvailableAccounts(apiKeyData, requestedModel = null, includeCcr = false) {
|
||||||
const availableAccounts = []
|
const availableAccounts = []
|
||||||
|
|
||||||
// 如果API Key绑定了专属账户,优先返回
|
// 如果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(
|
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
|
return availableAccounts
|
||||||
}
|
}
|
||||||
@@ -617,6 +731,52 @@ class UnifiedClaudeScheduler {
|
|||||||
}
|
}
|
||||||
// Bedrock账户暂不需要限流检查,因为AWS管理限流
|
// Bedrock账户暂不需要限流检查,因为AWS管理限流
|
||||||
return true
|
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
|
return false
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -673,6 +833,8 @@ class UnifiedClaudeScheduler {
|
|||||||
)
|
)
|
||||||
} else if (accountType === 'claude-console') {
|
} else if (accountType === 'claude-console') {
|
||||||
await claudeConsoleAccountService.markAccountRateLimited(accountId)
|
await claudeConsoleAccountService.markAccountRateLimited(accountId)
|
||||||
|
} else if (accountType === 'ccr') {
|
||||||
|
await ccrAccountService.markAccountRateLimited(accountId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除会话映射
|
// 删除会话映射
|
||||||
@@ -697,6 +859,8 @@ class UnifiedClaudeScheduler {
|
|||||||
await claudeAccountService.removeAccountRateLimit(accountId)
|
await claudeAccountService.removeAccountRateLimit(accountId)
|
||||||
} else if (accountType === 'claude-console') {
|
} else if (accountType === 'claude-console') {
|
||||||
await claudeConsoleAccountService.removeAccountRateLimit(accountId)
|
await claudeConsoleAccountService.removeAccountRateLimit(accountId)
|
||||||
|
} else if (accountType === 'ccr') {
|
||||||
|
await ccrAccountService.removeAccountRateLimit(accountId)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
@@ -716,6 +880,8 @@ class UnifiedClaudeScheduler {
|
|||||||
return await claudeAccountService.isAccountRateLimited(accountId)
|
return await claudeAccountService.isAccountRateLimited(accountId)
|
||||||
} else if (accountType === 'claude-console') {
|
} else if (accountType === 'claude-console') {
|
||||||
return await claudeConsoleAccountService.isAccountRateLimited(accountId)
|
return await claudeConsoleAccountService.isAccountRateLimited(accountId)
|
||||||
|
} else if (accountType === 'ccr') {
|
||||||
|
return await ccrAccountService.isAccountRateLimited(accountId)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
} catch (error) {
|
} 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 {
|
try {
|
||||||
// 获取分组信息
|
// 获取分组信息
|
||||||
const group = await accountGroupService.getGroup(groupId)
|
const group = await accountGroupService.getGroup(groupId)
|
||||||
@@ -808,18 +979,23 @@ class UnifiedClaudeScheduler {
|
|||||||
// 验证映射的账户是否属于这个分组
|
// 验证映射的账户是否属于这个分组
|
||||||
const memberIds = await accountGroupService.getGroupMembers(groupId)
|
const memberIds = await accountGroupService.getGroupMembers(groupId)
|
||||||
if (memberIds.includes(mappedAccount.accountId)) {
|
if (memberIds.includes(mappedAccount.accountId)) {
|
||||||
const isAvailable = await this._isAccountAvailable(
|
// 非 CCR 请求时不允许 CCR 粘性映射
|
||||||
mappedAccount.accountId,
|
if (!allowCcr && mappedAccount.accountType === 'ccr') {
|
||||||
mappedAccount.accountType,
|
await this._deleteSessionMapping(sessionHash)
|
||||||
requestedModel
|
} else {
|
||||||
)
|
const isAvailable = await this._isAccountAvailable(
|
||||||
if (isAvailable) {
|
mappedAccount.accountId,
|
||||||
// 🚀 智能会话续期:剩余时间少于14天时自动续期到15天
|
mappedAccount.accountType,
|
||||||
await redis.extendSessionAccountMappingTTL(sessionHash)
|
requestedModel
|
||||||
logger.info(
|
|
||||||
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
|
|
||||||
)
|
)
|
||||||
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)
|
account = await claudeConsoleAccountService.getAccount(memberId)
|
||||||
if (account) {
|
if (account) {
|
||||||
accountType = 'claude-console'
|
accountType = 'claude-console'
|
||||||
|
} else {
|
||||||
|
// 尝试CCR账户(仅允许在 allowCcr 为 true 时)
|
||||||
|
if (allowCcr) {
|
||||||
|
account = await ccrAccountService.getAccount(memberId)
|
||||||
|
if (account) {
|
||||||
|
accountType = 'ccr'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (group.platform === 'gemini') {
|
} else if (group.platform === 'gemini') {
|
||||||
@@ -873,7 +1057,9 @@ class UnifiedClaudeScheduler {
|
|||||||
const status =
|
const status =
|
||||||
accountType === 'claude-official'
|
accountType === 'claude-official'
|
||||||
? account.status !== 'error' && account.status !== 'blocked'
|
? account.status !== 'error' && account.status !== 'blocked'
|
||||||
: account.status === 'active'
|
: accountType === 'ccr'
|
||||||
|
? account.status === 'active'
|
||||||
|
: account.status === 'active'
|
||||||
|
|
||||||
if (isActive && status && this._isSchedulable(account.schedulable)) {
|
if (isActive && status && this._isSchedulable(account.schedulable)) {
|
||||||
// 检查模型支持
|
// 检查模型支持
|
||||||
@@ -930,6 +1116,133 @@ class UnifiedClaudeScheduler {
|
|||||||
throw error
|
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()
|
module.exports = new UnifiedClaudeScheduler()
|
||||||
|
|||||||
78
src/utils/modelHelper.js
Normal file
78
src/utils/modelHelper.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Model Helper Utility
|
||||||
|
*
|
||||||
|
* Provides utilities for parsing vendor-prefixed model names.
|
||||||
|
* Supports parsing model strings like "ccr,model_name" to extract vendor type and base model.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse vendor-prefixed model string
|
||||||
|
* @param {string} modelStr - Model string, potentially with vendor prefix (e.g., "ccr,gemini-2.5-pro")
|
||||||
|
* @returns {{vendor: string|null, baseModel: string}} - Parsed vendor and base model
|
||||||
|
*/
|
||||||
|
function parseVendorPrefixedModel(modelStr) {
|
||||||
|
if (!modelStr || typeof modelStr !== 'string') {
|
||||||
|
return { vendor: null, baseModel: modelStr || '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim whitespace and convert to lowercase for comparison
|
||||||
|
const trimmed = modelStr.trim()
|
||||||
|
const lowerTrimmed = trimmed.toLowerCase()
|
||||||
|
|
||||||
|
// Check for ccr prefix (case insensitive)
|
||||||
|
if (lowerTrimmed.startsWith('ccr,')) {
|
||||||
|
const parts = trimmed.split(',')
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
// Extract base model (everything after the first comma, rejoined in case model name contains commas)
|
||||||
|
const baseModel = parts.slice(1).join(',').trim()
|
||||||
|
return {
|
||||||
|
vendor: 'ccr',
|
||||||
|
baseModel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No recognized vendor prefix found
|
||||||
|
return {
|
||||||
|
vendor: null,
|
||||||
|
baseModel: trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a model string has a vendor prefix
|
||||||
|
* @param {string} modelStr - Model string to check
|
||||||
|
* @returns {boolean} - True if the model has a vendor prefix
|
||||||
|
*/
|
||||||
|
function hasVendorPrefix(modelStr) {
|
||||||
|
const { vendor } = parseVendorPrefixedModel(modelStr)
|
||||||
|
return vendor !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the effective model name for scheduling and processing
|
||||||
|
* This removes vendor prefixes to get the actual model name used for API calls
|
||||||
|
* @param {string} modelStr - Original model string
|
||||||
|
* @returns {string} - Effective model name without vendor prefix
|
||||||
|
*/
|
||||||
|
function getEffectiveModel(modelStr) {
|
||||||
|
const { baseModel } = parseVendorPrefixedModel(modelStr)
|
||||||
|
return baseModel
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the vendor type from a model string
|
||||||
|
* @param {string} modelStr - Model string to parse
|
||||||
|
* @returns {string|null} - Vendor type ('ccr') or null if no prefix
|
||||||
|
*/
|
||||||
|
function getVendorType(modelStr) {
|
||||||
|
const { vendor } = parseVendorPrefixedModel(modelStr)
|
||||||
|
return vendor
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parseVendorPrefixedModel,
|
||||||
|
hasVendorPrefix,
|
||||||
|
getEffectiveModel,
|
||||||
|
getVendorType
|
||||||
|
}
|
||||||
@@ -123,6 +123,15 @@
|
|||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Bedrock</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Bedrock</span>
|
||||||
</label>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -131,7 +140,8 @@
|
|||||||
!isEdit &&
|
!isEdit &&
|
||||||
form.platform !== 'claude-console' &&
|
form.platform !== 'claude-console' &&
|
||||||
form.platform !== 'bedrock' &&
|
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"
|
<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 accountsStore = useAccountsStore()
|
||||||
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
|
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
|
||||||
@@ -3439,6 +3449,17 @@ watch(setupTokenAuthCode, (newValue) => {
|
|||||||
// 如果不是 URL,保持原值(兼容直接输入授权码)
|
// 如果不是 URL,保持原值(兼容直接输入授权码)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 监听平台变化
|
||||||
|
watch(
|
||||||
|
() => form.value.platform,
|
||||||
|
(newPlatform) => {
|
||||||
|
// 当选择 CCR 平台时,通知父组件
|
||||||
|
if (!isEdit.value) {
|
||||||
|
emit('platform-changed', newPlatform)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// 监听账户类型变化
|
// 监听账户类型变化
|
||||||
watch(
|
watch(
|
||||||
() => form.value.accountType,
|
() => form.value.accountType,
|
||||||
|
|||||||
454
web/admin-spa/src/components/accounts/CcrAccountForm.vue
Normal file
454
web/admin-spa/src/components/accounts/CcrAccountForm.vue
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="show" class="modal fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4">
|
||||||
|
<div
|
||||||
|
class="modal-content custom-scrollbar mx-auto max-h-[90vh] w-full max-w-2xl overflow-y-auto p-4 sm:p-6 md:p-8"
|
||||||
|
>
|
||||||
|
<div class="mb-4 flex items-center justify-between sm:mb-6">
|
||||||
|
<div class="flex items-center gap-2 sm:gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-teal-500 to-emerald-600 sm:h-10 sm:w-10 sm:rounded-xl"
|
||||||
|
>
|
||||||
|
<i class="fas fa-code-branch text-sm text-white sm:text-base" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl">
|
||||||
|
{{ isEdit ? '编辑 CCR 账户' : '添加 CCR 账户' }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="p-1 text-gray-400 transition-colors hover:text-gray-600"
|
||||||
|
@click="$emit('close')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times text-lg sm:text-xl" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- 基本信息 -->
|
||||||
|
<div>
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>账户名称 *</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.name"
|
||||||
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||||
|
:class="{ 'border-red-500': errors.name }"
|
||||||
|
placeholder="为账户设置一个易识别的名称"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<p v-if="errors.name" class="mt-1 text-xs text-red-500">{{ errors.name }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>描述 (可选)</label
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
v-model="form.description"
|
||||||
|
class="form-input w-full resize-none border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||||
|
placeholder="账户用途说明..."
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>API URL *</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.apiUrl"
|
||||||
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||||
|
:class="{ 'border-red-500': errors.apiUrl }"
|
||||||
|
placeholder="例如:https://api.example.com/v1/messages"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<p v-if="errors.apiUrl" class="mt-1 text-xs text-red-500">{{ errors.apiUrl }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>API Key {{ isEdit ? '(留空不更新)' : '*' }}</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.apiKey"
|
||||||
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||||
|
:class="{ 'border-red-500': errors.apiKey }"
|
||||||
|
:placeholder="isEdit ? '留空表示不更新' : '必填'"
|
||||||
|
:required="!isEdit"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<p v-if="errors.apiKey" class="mt-1 text-xs text-red-500">{{ errors.apiKey }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>优先级</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model.number="form.priority"
|
||||||
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||||
|
max="100"
|
||||||
|
min="1"
|
||||||
|
placeholder="默认50,数字越小优先级越高"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
建议范围:1-100,数字越小优先级越高
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>自定义 User-Agent (可选)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.userAgent"
|
||||||
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||||
|
placeholder="留空则透传客户端 User-Agent"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 限流设置 -->
|
||||||
|
<div>
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>限流机制</label
|
||||||
|
>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="inline-flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="enableRateLimit"
|
||||||
|
class="mr-2 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300"
|
||||||
|
>启用限流机制(429 时暂停调度)</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-if="enableRateLimit">
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>限流时间 (分钟)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model.number="form.rateLimitDuration"
|
||||||
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||||
|
min="1"
|
||||||
|
placeholder="默认60分钟"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
账号被限流后暂停调度的时间(分钟)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 额度管理 -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>每日额度限制 ($)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model.number="form.dailyQuota"
|
||||||
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||||
|
min="0"
|
||||||
|
placeholder="0 表示不限制"
|
||||||
|
step="0.01"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
设置每日使用额度,0 表示不限制
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>额度重置时间</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.quotaResetTime"
|
||||||
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||||
|
placeholder="00:00"
|
||||||
|
type="time"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">每日自动重置额度的时间</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 模型映射表(可选) -->
|
||||||
|
<div>
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>模型映射表 (可选)</label
|
||||||
|
>
|
||||||
|
<div class="mb-3 rounded-lg bg-blue-50 p-3 dark:bg-blue-900/30">
|
||||||
|
<p class="text-xs text-blue-700 dark:text-blue-400">
|
||||||
|
<i class="fas fa-info-circle mr-1" />
|
||||||
|
留空表示支持所有模型且不修改请求。配置映射后,左侧模型会被识别为支持的模型,右侧是实际发送的模型。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(mapping, index) in modelMappings"
|
||||||
|
:key="index"
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="mapping.from"
|
||||||
|
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
|
placeholder="原始模型名称"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<i class="fas fa-arrow-right text-gray-400 dark:text-gray-500" />
|
||||||
|
<input
|
||||||
|
v-model="mapping.to"
|
||||||
|
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
|
placeholder="映射后的模型名称"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||||
|
type="button"
|
||||||
|
@click="removeModelMapping(index)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-gray-600 dark:text-gray-400 dark:hover:border-gray-500 dark:hover:text-gray-300"
|
||||||
|
type="button"
|
||||||
|
@click="addModelMapping"
|
||||||
|
>
|
||||||
|
<i class="fas fa-plus mr-2" /> 添加模型映射
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 代理配置 -->
|
||||||
|
<div>
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>代理设置 (可选)</label
|
||||||
|
>
|
||||||
|
<ProxyConfig v-model="form.proxy" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作区 -->
|
||||||
|
<div class="mt-2 flex gap-3">
|
||||||
|
<button
|
||||||
|
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
|
type="button"
|
||||||
|
@click="$emit('close')"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
||||||
|
:disabled="loading"
|
||||||
|
type="button"
|
||||||
|
@click="submit"
|
||||||
|
>
|
||||||
|
<div v-if="loading" class="loading-spinner mr-2" />
|
||||||
|
{{ loading ? (isEdit ? '保存中...' : '创建中...') : isEdit ? '保存' : '创建' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
|
import { apiClient } from '@/config/api'
|
||||||
|
import { showToast } from '@/utils/toast'
|
||||||
|
import ProxyConfig from '@/components/accounts/ProxyConfig.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
account: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'success'])
|
||||||
|
|
||||||
|
const show = ref(true)
|
||||||
|
const isEdit = computed(() => !!props.account)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
apiUrl: '',
|
||||||
|
apiKey: '',
|
||||||
|
priority: 50,
|
||||||
|
userAgent: '',
|
||||||
|
rateLimitDuration: 60,
|
||||||
|
dailyQuota: 0,
|
||||||
|
quotaResetTime: '00:00',
|
||||||
|
proxy: null,
|
||||||
|
supportedModels: {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const enableRateLimit = ref(true)
|
||||||
|
const errors = ref({})
|
||||||
|
|
||||||
|
const modelMappings = ref([]) // [{from,to}]
|
||||||
|
|
||||||
|
const buildSupportedModels = () => {
|
||||||
|
const map = {}
|
||||||
|
for (const m of modelMappings.value) {
|
||||||
|
const from = (m.from || '').trim()
|
||||||
|
const to = (m.to || '').trim()
|
||||||
|
if (from && to) map[from] = to
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
const addModelMapping = () => {
|
||||||
|
modelMappings.value.push({ from: '', to: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeModelMapping = (index) => {
|
||||||
|
modelMappings.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
const e = {}
|
||||||
|
if (!form.value.name || form.value.name.trim().length === 0) e.name = '名称不能为空'
|
||||||
|
if (!form.value.apiUrl || form.value.apiUrl.trim().length === 0) e.apiUrl = 'API URL 不能为空'
|
||||||
|
if (!isEdit.value && (!form.value.apiKey || form.value.apiKey.trim().length === 0))
|
||||||
|
e.apiKey = 'API Key 不能为空'
|
||||||
|
errors.value = e
|
||||||
|
return Object.keys(e).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!validate()) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
if (isEdit.value) {
|
||||||
|
// 更新
|
||||||
|
const updates = {
|
||||||
|
name: form.value.name,
|
||||||
|
description: form.value.description,
|
||||||
|
apiUrl: form.value.apiUrl,
|
||||||
|
priority: form.value.priority,
|
||||||
|
userAgent: form.value.userAgent,
|
||||||
|
rateLimitDuration: enableRateLimit.value ? Number(form.value.rateLimitDuration || 60) : 0,
|
||||||
|
dailyQuota: Number(form.value.dailyQuota || 0),
|
||||||
|
quotaResetTime: form.value.quotaResetTime || '00:00',
|
||||||
|
proxy: form.value.proxy || null,
|
||||||
|
supportedModels: buildSupportedModels()
|
||||||
|
}
|
||||||
|
if (form.value.apiKey && form.value.apiKey.trim().length > 0) {
|
||||||
|
updates.apiKey = form.value.apiKey
|
||||||
|
}
|
||||||
|
const res = await apiClient.put(`/admin/ccr-accounts/${props.account.id}`, updates)
|
||||||
|
if (res.success) {
|
||||||
|
showToast('保存成功', 'success')
|
||||||
|
emit('success')
|
||||||
|
} else {
|
||||||
|
showToast(res.message || '保存失败', 'error')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 创建
|
||||||
|
const payload = {
|
||||||
|
name: form.value.name,
|
||||||
|
description: form.value.description,
|
||||||
|
apiUrl: form.value.apiUrl,
|
||||||
|
apiKey: form.value.apiKey,
|
||||||
|
priority: Number(form.value.priority || 50),
|
||||||
|
supportedModels: buildSupportedModels(),
|
||||||
|
userAgent: form.value.userAgent,
|
||||||
|
rateLimitDuration: enableRateLimit.value ? Number(form.value.rateLimitDuration || 60) : 0,
|
||||||
|
proxy: form.value.proxy,
|
||||||
|
accountType: 'shared',
|
||||||
|
dailyQuota: Number(form.value.dailyQuota || 0),
|
||||||
|
quotaResetTime: form.value.quotaResetTime || '00:00'
|
||||||
|
}
|
||||||
|
const res = await apiClient.post('/admin/ccr-accounts', payload)
|
||||||
|
if (res.success) {
|
||||||
|
showToast('创建成功', 'success')
|
||||||
|
emit('success')
|
||||||
|
} else {
|
||||||
|
showToast(res.message || '创建失败', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || '请求失败', 'error')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const populateFromAccount = () => {
|
||||||
|
if (!props.account) return
|
||||||
|
const a = props.account
|
||||||
|
form.value.name = a.name || ''
|
||||||
|
form.value.description = a.description || ''
|
||||||
|
form.value.apiUrl = a.apiUrl || ''
|
||||||
|
form.value.priority = Number(a.priority || 50)
|
||||||
|
form.value.userAgent = a.userAgent || ''
|
||||||
|
form.value.rateLimitDuration = Number(a.rateLimitDuration || 60)
|
||||||
|
form.value.dailyQuota = Number(a.dailyQuota || 0)
|
||||||
|
form.value.quotaResetTime = a.quotaResetTime || '00:00'
|
||||||
|
form.value.proxy = a.proxy || null
|
||||||
|
enableRateLimit.value = form.value.rateLimitDuration > 0
|
||||||
|
|
||||||
|
// supportedModels 对象转为数组
|
||||||
|
modelMappings.value = []
|
||||||
|
const mapping = a.supportedModels || {}
|
||||||
|
if (mapping && typeof mapping === 'object') {
|
||||||
|
for (const k of Object.keys(mapping)) {
|
||||||
|
modelMappings.value.push({ from: k, to: mapping[k] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (isEdit.value) populateFromAccount()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.account,
|
||||||
|
() => {
|
||||||
|
if (isEdit.value) populateFromAccount()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-content {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border-radius: 16px;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .modal-content {
|
||||||
|
background: rgba(17, 24, 39, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
border-top: 2px solid #14b8a6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
账户管理
|
账户管理
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 sm:text-base">
|
<p class="text-sm text-gray-600 dark:text-gray-400 sm:text-base">
|
||||||
管理您的 Claude、Gemini、OpenAI 和 Azure OpenAI 账户及代理配置
|
管理您的 Claude、Gemini、OpenAI、Azure OpenAI 与 CCR 账户及代理配置
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
@@ -363,6 +363,15 @@
|
|||||||
{{ getClaudeAuthType(account) }}
|
{{ getClaudeAuthType(account) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
<div
|
||||||
v-else
|
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"
|
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 === 'bedrock' ||
|
||||||
account.platform === 'gemini' ||
|
account.platform === 'gemini' ||
|
||||||
account.platform === 'openai' ||
|
account.platform === 'openai' ||
|
||||||
account.platform === 'azure_openai'
|
account.platform === 'azure_openai' ||
|
||||||
|
account.platform === 'ccr'
|
||||||
"
|
"
|
||||||
class="flex items-center gap-2"
|
class="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
@@ -723,7 +733,9 @@
|
|||||||
? 'bg-gradient-to-br from-blue-500 to-cyan-600'
|
? 'bg-gradient-to-br from-blue-500 to-cyan-600'
|
||||||
: account.platform === 'openai'
|
: account.platform === 'openai'
|
||||||
? 'bg-gradient-to-br from-gray-600 to-gray-700'
|
? '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
|
<i
|
||||||
@@ -737,7 +749,9 @@
|
|||||||
? 'fab fa-microsoft'
|
? 'fab fa-microsoft'
|
||||||
: account.platform === 'openai'
|
: account.platform === 'openai'
|
||||||
? 'fas fa-openai'
|
? 'fas fa-openai'
|
||||||
: 'fas fa-robot'
|
: account.platform === 'ccr'
|
||||||
|
? 'fas fa-code-branch'
|
||||||
|
: 'fas fa-robot'
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -932,14 +946,26 @@
|
|||||||
|
|
||||||
<!-- 添加账户模态框 -->
|
<!-- 添加账户模态框 -->
|
||||||
<AccountForm
|
<AccountForm
|
||||||
v-if="showCreateAccountModal"
|
v-if="showCreateAccountModal && (!newAccountPlatform || newAccountPlatform !== 'ccr')"
|
||||||
@close="showCreateAccountModal = false"
|
@close="closeCreateAccountModal"
|
||||||
|
@platform-changed="newAccountPlatform = $event"
|
||||||
|
@success="handleCreateSuccess"
|
||||||
|
/>
|
||||||
|
<CcrAccountForm
|
||||||
|
v-else-if="showCreateAccountModal && newAccountPlatform === 'ccr'"
|
||||||
|
@close="closeCreateAccountModal"
|
||||||
@success="handleCreateSuccess"
|
@success="handleCreateSuccess"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 编辑账户模态框 -->
|
<!-- 编辑账户模态框 -->
|
||||||
|
<CcrAccountForm
|
||||||
|
v-if="showEditAccountModal && editingAccount && editingAccount.platform === 'ccr'"
|
||||||
|
:account="editingAccount"
|
||||||
|
@close="showEditAccountModal = false"
|
||||||
|
@success="handleEditSuccess"
|
||||||
|
/>
|
||||||
<AccountForm
|
<AccountForm
|
||||||
v-if="showEditAccountModal"
|
v-else-if="showEditAccountModal"
|
||||||
:account="editingAccount"
|
:account="editingAccount"
|
||||||
@close="showEditAccountModal = false"
|
@close="showEditAccountModal = false"
|
||||||
@success="handleEditSuccess"
|
@success="handleEditSuccess"
|
||||||
@@ -964,6 +990,7 @@ import { showToast } from '@/utils/toast'
|
|||||||
import { apiClient } from '@/config/api'
|
import { apiClient } from '@/config/api'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import AccountForm from '@/components/accounts/AccountForm.vue'
|
import AccountForm from '@/components/accounts/AccountForm.vue'
|
||||||
|
import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue'
|
||||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||||
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
||||||
|
|
||||||
@@ -1003,7 +1030,8 @@ const platformOptions = ref([
|
|||||||
{ value: 'gemini', label: 'Gemini', icon: 'fa-google' },
|
{ value: 'gemini', label: 'Gemini', icon: 'fa-google' },
|
||||||
{ value: 'openai', label: 'OpenAi', icon: 'fa-openai' },
|
{ value: 'openai', label: 'OpenAi', icon: 'fa-openai' },
|
||||||
{ value: 'azure_openai', label: 'Azure OpenAI', icon: 'fab fa-microsoft' },
|
{ 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(() => {
|
const groupOptions = computed(() => {
|
||||||
@@ -1028,6 +1056,7 @@ const groupOptions = computed(() => {
|
|||||||
|
|
||||||
// 模态框状态
|
// 模态框状态
|
||||||
const showCreateAccountModal = ref(false)
|
const showCreateAccountModal = ref(false)
|
||||||
|
const newAccountPlatform = ref(null) // 跟踪新建账户选择的平台
|
||||||
const showEditAccountModal = ref(false)
|
const showEditAccountModal = ref(false)
|
||||||
const editingAccount = ref(null)
|
const editingAccount = ref(null)
|
||||||
|
|
||||||
@@ -1108,7 +1137,8 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
apiClient.get('/admin/bedrock-accounts', { params }),
|
apiClient.get('/admin/bedrock-accounts', { params }),
|
||||||
apiClient.get('/admin/gemini-accounts', { params }),
|
apiClient.get('/admin/gemini-accounts', { params }),
|
||||||
apiClient.get('/admin/openai-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 {
|
} else {
|
||||||
// 只请求指定平台,其他平台设为null占位
|
// 只请求指定平台,其他平台设为null占位
|
||||||
@@ -1173,6 +1203,17 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
apiClient.get('/admin/azure-openai-accounts', { params })
|
apiClient.get('/admin/azure-openai-accounts', { params })
|
||||||
)
|
)
|
||||||
break
|
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:
|
default:
|
||||||
// 默认情况下返回空数组
|
// 默认情况下返回空数组
|
||||||
requests.push(
|
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: [] }),
|
Promise.resolve({ success: true, data: [] }),
|
||||||
|
Promise.resolve({ success: true, data: [] }),
|
||||||
Promise.resolve({ success: true, data: [] })
|
Promise.resolve({ success: true, data: [] })
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
@@ -1193,8 +1235,15 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
// 后端账户API已经包含分组信息,不需要单独加载分组成员关系
|
// 后端账户API已经包含分组信息,不需要单独加载分组成员关系
|
||||||
// await loadGroupMembers(forceReload)
|
// await loadGroupMembers(forceReload)
|
||||||
|
|
||||||
const [claudeData, claudeConsoleData, bedrockData, geminiData, openaiData, azureOpenaiData] =
|
const [
|
||||||
await Promise.all(requests)
|
claudeData,
|
||||||
|
claudeConsoleData,
|
||||||
|
bedrockData,
|
||||||
|
geminiData,
|
||||||
|
openaiData,
|
||||||
|
azureOpenaiData,
|
||||||
|
ccrData
|
||||||
|
] = await Promise.all(requests)
|
||||||
|
|
||||||
const allAccounts = []
|
const allAccounts = []
|
||||||
|
|
||||||
@@ -1262,6 +1311,15 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
allAccounts.push(...azureOpenaiAccounts)
|
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
|
let filteredAccounts = allAccounts
|
||||||
if (groupFilter.value !== 'all') {
|
if (groupFilter.value !== 'all') {
|
||||||
@@ -1467,9 +1525,16 @@ const formatRateLimitTime = (minutes) => {
|
|||||||
|
|
||||||
// 打开创建账户模态框
|
// 打开创建账户模态框
|
||||||
const openCreateAccountModal = () => {
|
const openCreateAccountModal = () => {
|
||||||
|
newAccountPlatform.value = null // 重置选择的平台
|
||||||
showCreateAccountModal.value = true
|
showCreateAccountModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 关闭创建账户模态框
|
||||||
|
const closeCreateAccountModal = () => {
|
||||||
|
showCreateAccountModal.value = false
|
||||||
|
newAccountPlatform.value = null
|
||||||
|
}
|
||||||
|
|
||||||
// 编辑账户
|
// 编辑账户
|
||||||
const editAccount = (account) => {
|
const editAccount = (account) => {
|
||||||
editingAccount.value = account
|
editingAccount.value = account
|
||||||
@@ -1515,6 +1580,8 @@ const deleteAccount = async (account) => {
|
|||||||
endpoint = `/admin/openai-accounts/${account.id}`
|
endpoint = `/admin/openai-accounts/${account.id}`
|
||||||
} else if (account.platform === 'azure_openai') {
|
} else if (account.platform === 'azure_openai') {
|
||||||
endpoint = `/admin/azure-openai-accounts/${account.id}`
|
endpoint = `/admin/azure-openai-accounts/${account.id}`
|
||||||
|
} else if (account.platform === 'ccr') {
|
||||||
|
endpoint = `/admin/ccr-accounts/${account.id}`
|
||||||
} else {
|
} else {
|
||||||
endpoint = `/admin/gemini-accounts/${account.id}`
|
endpoint = `/admin/gemini-accounts/${account.id}`
|
||||||
}
|
}
|
||||||
@@ -1563,6 +1630,8 @@ const resetAccountStatus = async (account) => {
|
|||||||
endpoint = `/admin/claude-accounts/${account.id}/reset-status`
|
endpoint = `/admin/claude-accounts/${account.id}/reset-status`
|
||||||
} else if (account.platform === 'claude-console') {
|
} else if (account.platform === 'claude-console') {
|
||||||
endpoint = `/admin/claude-console-accounts/${account.id}/reset-status`
|
endpoint = `/admin/claude-console-accounts/${account.id}/reset-status`
|
||||||
|
} else if (account.platform === 'ccr') {
|
||||||
|
endpoint = `/admin/ccr-accounts/${account.id}/reset-status`
|
||||||
} else {
|
} else {
|
||||||
showToast('不支持的账户类型', 'error')
|
showToast('不支持的账户类型', 'error')
|
||||||
account.isResetting = false
|
account.isResetting = false
|
||||||
@@ -1605,6 +1674,8 @@ const toggleSchedulable = async (account) => {
|
|||||||
endpoint = `/admin/openai-accounts/${account.id}/toggle-schedulable`
|
endpoint = `/admin/openai-accounts/${account.id}/toggle-schedulable`
|
||||||
} else if (account.platform === 'azure_openai') {
|
} else if (account.platform === 'azure_openai') {
|
||||||
endpoint = `/admin/azure-openai-accounts/${account.id}/toggle-schedulable`
|
endpoint = `/admin/azure-openai-accounts/${account.id}/toggle-schedulable`
|
||||||
|
} else if (account.platform === 'ccr') {
|
||||||
|
endpoint = `/admin/ccr-accounts/${account.id}/toggle-schedulable`
|
||||||
} else {
|
} else {
|
||||||
showToast('该账户类型暂不支持调度控制', 'warning')
|
showToast('该账户类型暂不支持调度控制', 'warning')
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user