diff --git a/VERSION b/VERSION index cc868806..b176d896 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.121 +1.1.122 diff --git a/scripts/test-group-scheduling.js b/scripts/test-group-scheduling.js index 4312ec65..e22a20e1 100644 --- a/scripts/test-group-scheduling.js +++ b/scripts/test-group-scheduling.js @@ -436,8 +436,9 @@ async function test8_groupMemberManagement() { const account = testData.accounts.find((a) => a.type === 'claude') // 获取账户所属分组 - const accountGroup = await accountGroupService.getAccountGroup(account.id) - if (accountGroup && accountGroup.id === claudeGroup.id) { + const accountGroups = await accountGroupService.getAccountGroup(account.id) + const hasTargetGroup = accountGroups.some((group) => group.id === claudeGroup.id) + if (hasTargetGroup) { log('✅ 账户分组查询验证通过', 'success') } else { throw new Error('账户分组查询结果不正确') diff --git a/scripts/test-multi-group.js b/scripts/test-multi-group.js new file mode 100644 index 00000000..484bc714 --- /dev/null +++ b/scripts/test-multi-group.js @@ -0,0 +1,379 @@ +/** + * 多分组功能测试脚本 + * 测试一个账户可以属于多个分组的功能 + */ + +require('dotenv').config() +const redis = require('../src/models/redis') +const accountGroupService = require('../src/services/accountGroupService') +const claudeAccountService = require('../src/services/claudeAccountService') + +// 测试配置 +const TEST_PREFIX = 'multi_group_test_' +const CLEANUP_ON_FINISH = true + +// 测试数据存储 +const testData = { + groups: [], + accounts: [] +} + +// 颜色输出 +const colors = { + green: '\x1b[32m', + red: '\x1b[31m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + reset: '\x1b[0m' +} + +function log(message, type = 'info') { + const color = + { + success: colors.green, + error: colors.red, + warning: colors.yellow, + info: colors.blue + }[type] || colors.reset + + console.log(`${color}${message}${colors.reset}`) +} + +async function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +// 清理测试数据 +async function cleanup() { + log('\n🧹 清理测试数据...', 'info') + + // 删除测试账户 + for (const account of testData.accounts) { + try { + await claudeAccountService.deleteAccount(account.id) + log(`✅ 删除测试账户: ${account.name}`, 'success') + } catch (error) { + log(`❌ 删除账户失败: ${error.message}`, 'error') + } + } + + // 删除测试分组 + for (const group of testData.groups) { + try { + // 先移除所有成员 + const members = await accountGroupService.getGroupMembers(group.id) + for (const memberId of members) { + await accountGroupService.removeAccountFromGroup(memberId, group.id) + } + + await accountGroupService.deleteGroup(group.id) + log(`✅ 删除测试分组: ${group.name}`, 'success') + } catch (error) { + log(`❌ 删除分组失败: ${error.message}`, 'error') + } + } +} + +// 测试1: 创建测试数据 +async function test1_createTestData() { + log('\n📝 测试1: 创建测试数据', 'info') + + try { + // 创建3个测试分组 + const group1 = await accountGroupService.createGroup({ + name: `${TEST_PREFIX}高优先级组`, + platform: 'claude', + description: '高优先级账户分组' + }) + testData.groups.push(group1) + log(`✅ 创建分组1: ${group1.name}`, 'success') + + const group2 = await accountGroupService.createGroup({ + name: `${TEST_PREFIX}备用组`, + platform: 'claude', + description: '备用账户分组' + }) + testData.groups.push(group2) + log(`✅ 创建分组2: ${group2.name}`, 'success') + + const group3 = await accountGroupService.createGroup({ + name: `${TEST_PREFIX}专用组`, + platform: 'claude', + description: '专用账户分组' + }) + testData.groups.push(group3) + log(`✅ 创建分组3: ${group3.name}`, 'success') + + // 创建测试账户 + const account1 = await claudeAccountService.createAccount({ + name: `${TEST_PREFIX}测试账户1`, + email: 'test1@example.com', + refreshToken: 'test_refresh_token_1', + accountType: 'group' + }) + testData.accounts.push(account1) + log(`✅ 创建测试账户1: ${account1.name}`, 'success') + + const account2 = await claudeAccountService.createAccount({ + name: `${TEST_PREFIX}测试账户2`, + email: 'test2@example.com', + refreshToken: 'test_refresh_token_2', + accountType: 'group' + }) + testData.accounts.push(account2) + log(`✅ 创建测试账户2: ${account2.name}`, 'success') + + log(`✅ 测试数据创建完成: 3个分组, 2个账户`, 'success') + } catch (error) { + log(`❌ 测试1失败: ${error.message}`, 'error') + throw error + } +} + +// 测试2: 账户加入多个分组 +async function test2_addAccountToMultipleGroups() { + log('\n📝 测试2: 账户加入多个分组', 'info') + + try { + const [group1, group2, group3] = testData.groups + const [account1, account2] = testData.accounts + + // 账户1加入分组1和分组2 + await accountGroupService.addAccountToGroup(account1.id, group1.id, 'claude') + log(`✅ 账户1加入分组1: ${group1.name}`, 'success') + + await accountGroupService.addAccountToGroup(account1.id, group2.id, 'claude') + log(`✅ 账户1加入分组2: ${group2.name}`, 'success') + + // 账户2加入分组2和分组3 + await accountGroupService.addAccountToGroup(account2.id, group2.id, 'claude') + log(`✅ 账户2加入分组2: ${group2.name}`, 'success') + + await accountGroupService.addAccountToGroup(account2.id, group3.id, 'claude') + log(`✅ 账户2加入分组3: ${group3.name}`, 'success') + + log(`✅ 多分组关系建立完成`, 'success') + } catch (error) { + log(`❌ 测试2失败: ${error.message}`, 'error') + throw error + } +} + +// 测试3: 验证多分组关系 +async function test3_verifyMultiGroupRelationships() { + log('\n📝 测试3: 验证多分组关系', 'info') + + try { + const [group1, group2, group3] = testData.groups + const [account1, account2] = testData.accounts + + // 验证账户1的分组关系 + const account1Groups = await accountGroupService.getAccountGroup(account1.id) + log(`📊 账户1所属分组数量: ${account1Groups.length}`, 'info') + + const account1GroupNames = account1Groups.map((g) => g.name).sort() + const expectedAccount1Groups = [group1.name, group2.name].sort() + + if (JSON.stringify(account1GroupNames) === JSON.stringify(expectedAccount1Groups)) { + log(`✅ 账户1分组关系正确: [${account1GroupNames.join(', ')}]`, 'success') + } else { + throw new Error( + `账户1分组关系错误,期望: [${expectedAccount1Groups.join(', ')}], 实际: [${account1GroupNames.join(', ')}]` + ) + } + + // 验证账户2的分组关系 + const account2Groups = await accountGroupService.getAccountGroup(account2.id) + log(`📊 账户2所属分组数量: ${account2Groups.length}`, 'info') + + const account2GroupNames = account2Groups.map((g) => g.name).sort() + const expectedAccount2Groups = [group2.name, group3.name].sort() + + if (JSON.stringify(account2GroupNames) === JSON.stringify(expectedAccount2Groups)) { + log(`✅ 账户2分组关系正确: [${account2GroupNames.join(', ')}]`, 'success') + } else { + throw new Error( + `账户2分组关系错误,期望: [${expectedAccount2Groups.join(', ')}], 实际: [${account2GroupNames.join(', ')}]` + ) + } + + log(`✅ 多分组关系验证通过`, 'success') + } catch (error) { + log(`❌ 测试3失败: ${error.message}`, 'error') + throw error + } +} + +// 测试4: 验证分组成员关系 +async function test4_verifyGroupMemberships() { + log('\n📝 测试4: 验证分组成员关系', 'info') + + try { + const [group1, group2, group3] = testData.groups + const [account1, account2] = testData.accounts + + // 验证分组1的成员 + const group1Members = await accountGroupService.getGroupMembers(group1.id) + if (group1Members.includes(account1.id) && group1Members.length === 1) { + log(`✅ 分组1成员正确: [${account1.name}]`, 'success') + } else { + throw new Error(`分组1成员错误,期望: [${account1.id}], 实际: [${group1Members.join(', ')}]`) + } + + // 验证分组2的成员(应该包含两个账户) + const group2Members = await accountGroupService.getGroupMembers(group2.id) + const expectedGroup2Members = [account1.id, account2.id].sort() + const actualGroup2Members = group2Members.sort() + + if (JSON.stringify(actualGroup2Members) === JSON.stringify(expectedGroup2Members)) { + log(`✅ 分组2成员正确: [${account1.name}, ${account2.name}]`, 'success') + } else { + throw new Error( + `分组2成员错误,期望: [${expectedGroup2Members.join(', ')}], 实际: [${actualGroup2Members.join(', ')}]` + ) + } + + // 验证分组3的成员 + const group3Members = await accountGroupService.getGroupMembers(group3.id) + if (group3Members.includes(account2.id) && group3Members.length === 1) { + log(`✅ 分组3成员正确: [${account2.name}]`, 'success') + } else { + throw new Error(`分组3成员错误,期望: [${account2.id}], 实际: [${group3Members.join(', ')}]`) + } + + log(`✅ 分组成员关系验证通过`, 'success') + } catch (error) { + log(`❌ 测试4失败: ${error.message}`, 'error') + throw error + } +} + +// 测试5: 从部分分组中移除账户 +async function test5_removeFromPartialGroups() { + log('\n📝 测试5: 从部分分组中移除账户', 'info') + + try { + const [group1, group2] = testData.groups + const [account1] = testData.accounts + + // 将账户1从分组1中移除(但仍在分组2中) + await accountGroupService.removeAccountFromGroup(account1.id, group1.id) + log(`✅ 从分组1中移除账户1`, 'success') + + // 验证账户1现在只属于分组2 + const account1Groups = await accountGroupService.getAccountGroup(account1.id) + if (account1Groups.length === 1 && account1Groups[0].id === group2.id) { + log(`✅ 账户1现在只属于分组2: ${account1Groups[0].name}`, 'success') + } else { + const groupNames = account1Groups.map((g) => g.name) + throw new Error(`账户1分组状态错误,期望只在分组2中,实际: [${groupNames.join(', ')}]`) + } + + // 验证分组1现在为空 + const group1Members = await accountGroupService.getGroupMembers(group1.id) + if (group1Members.length === 0) { + log(`✅ 分组1现在为空`, 'success') + } else { + throw new Error(`分组1应该为空,但还有成员: [${group1Members.join(', ')}]`) + } + + // 验证分组2仍有两个成员 + const group2Members = await accountGroupService.getGroupMembers(group2.id) + if (group2Members.length === 2) { + log(`✅ 分组2仍有两个成员`, 'success') + } else { + throw new Error(`分组2应该有2个成员,实际: ${group2Members.length}个`) + } + + log(`✅ 部分移除测试通过`, 'success') + } catch (error) { + log(`❌ 测试5失败: ${error.message}`, 'error') + throw error + } +} + +// 测试6: 账户完全移除时的分组清理 +async function test6_accountDeletionGroupCleanup() { + log('\n📝 测试6: 账户删除时的分组清理', 'info') + + try { + const [, group2, group3] = testData.groups // 跳过第一个元素 + const [account1, account2] = testData.accounts + + // 记录删除前的状态 + const beforeGroup2Members = await accountGroupService.getGroupMembers(group2.id) + const beforeGroup3Members = await accountGroupService.getGroupMembers(group3.id) + + log(`📊 删除前分组2成员数: ${beforeGroup2Members.length}`, 'info') + log(`📊 删除前分组3成员数: ${beforeGroup3Members.length}`, 'info') + + // 删除账户2(这应该会触发从所有分组中移除的逻辑) + await claudeAccountService.deleteAccount(account2.id) + log(`✅ 删除账户2: ${account2.name}`, 'success') + + // 从测试数据中移除,避免cleanup时重复删除 + testData.accounts = testData.accounts.filter((acc) => acc.id !== account2.id) + + // 等待一下确保删除操作完成 + await sleep(500) + + // 验证分组2现在只有账户1 + const afterGroup2Members = await accountGroupService.getGroupMembers(group2.id) + if (afterGroup2Members.length === 1 && afterGroup2Members[0] === account1.id) { + log(`✅ 分组2现在只有账户1`, 'success') + } else { + throw new Error(`分组2成员状态错误,期望只有账户1,实际: [${afterGroup2Members.join(', ')}]`) + } + + // 验证分组3现在为空 + const afterGroup3Members = await accountGroupService.getGroupMembers(group3.id) + if (afterGroup3Members.length === 0) { + log(`✅ 分组3现在为空`, 'success') + } else { + throw new Error(`分组3应该为空,但还有成员: [${afterGroup3Members.join(', ')}]`) + } + + log(`✅ 账户删除的分组清理测试通过`, 'success') + } catch (error) { + log(`❌ 测试6失败: ${error.message}`, 'error') + throw error + } +} + +// 主测试函数 +async function runTests() { + log('\n🚀 开始多分组功能测试\n', 'info') + + try { + // 连接Redis + await redis.connect() + log('✅ Redis连接成功', 'success') + + // 执行测试 + await test1_createTestData() + await test2_addAccountToMultipleGroups() + await test3_verifyMultiGroupRelationships() + await test4_verifyGroupMemberships() + await test5_removeFromPartialGroups() + await test6_accountDeletionGroupCleanup() + + log('\n🎉 所有测试通过!多分组功能工作正常', 'success') + } catch (error) { + log(`\n❌ 测试失败: ${error.message}`, 'error') + console.error(error) + } finally { + // 清理测试数据 + if (CLEANUP_ON_FINISH) { + await cleanup() + } else { + log('\n⚠️ 测试数据未清理,请手动清理', 'warning') + } + + // 关闭Redis连接 + await redis.disconnect() + process.exit(0) + } +} + +// 运行测试 +runTests() diff --git a/src/app.js b/src/app.js index ad9c0196..fc1d4a93 100644 --- a/src/app.js +++ b/src/app.js @@ -509,7 +509,8 @@ class Application { const [expiredKeys, errorAccounts] = await Promise.all([ apiKeyService.cleanupExpiredKeys(), - claudeAccountService.cleanupErrorAccounts() + claudeAccountService.cleanupErrorAccounts(), + claudeAccountService.cleanupTempErrorAccounts() // 新增:清理临时错误账户 ]) await redis.cleanup() diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 788248bb..aadcf0d9 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -2,7 +2,7 @@ const apiKeyService = require('../services/apiKeyService') const userService = require('../services/userService') const logger = require('../utils/logger') const redis = require('../models/redis') -const { RateLimiterRedis } = require('rate-limiter-flexible') +// const { RateLimiterRedis } = require('rate-limiter-flexible') // 暂时未使用 const config = require('../../config/config') // 🔑 API Key验证中间件(优化版) @@ -183,11 +183,18 @@ const authenticateApiKey = async (req, res, next) => { // 检查时间窗口限流 const rateLimitWindow = validation.keyData.rateLimitWindow || 0 const rateLimitRequests = validation.keyData.rateLimitRequests || 0 + const rateLimitCost = validation.keyData.rateLimitCost || 0 // 新增:费用限制 - if (rateLimitWindow > 0 && (rateLimitRequests > 0 || validation.keyData.tokenLimit > 0)) { + // 兼容性检查:如果tokenLimit仍有值,使用tokenLimit;否则使用rateLimitCost + const hasRateLimits = + rateLimitWindow > 0 && + (rateLimitRequests > 0 || validation.keyData.tokenLimit > 0 || rateLimitCost > 0) + + if (hasRateLimits) { const windowStartKey = `rate_limit:window_start:${validation.keyData.id}` const requestCountKey = `rate_limit:requests:${validation.keyData.id}` const tokenCountKey = `rate_limit:tokens:${validation.keyData.id}` + const costCountKey = `rate_limit:cost:${validation.keyData.id}` // 新增:费用计数器 const now = Date.now() const windowDuration = rateLimitWindow * 60 * 1000 // 转换为毫秒 @@ -200,6 +207,7 @@ const authenticateApiKey = async (req, res, next) => { await redis.getClient().set(windowStartKey, now, 'PX', windowDuration) await redis.getClient().set(requestCountKey, 0, 'PX', windowDuration) await redis.getClient().set(tokenCountKey, 0, 'PX', windowDuration) + await redis.getClient().set(costCountKey, 0, 'PX', windowDuration) // 新增:重置费用 windowStart = now } else { windowStart = parseInt(windowStart) @@ -210,6 +218,7 @@ const authenticateApiKey = async (req, res, next) => { await redis.getClient().set(windowStartKey, now, 'PX', windowDuration) await redis.getClient().set(requestCountKey, 0, 'PX', windowDuration) await redis.getClient().set(tokenCountKey, 0, 'PX', windowDuration) + await redis.getClient().set(costCountKey, 0, 'PX', windowDuration) // 新增:重置费用 windowStart = now } } @@ -217,6 +226,7 @@ const authenticateApiKey = async (req, res, next) => { // 获取当前计数 const currentRequests = parseInt((await redis.getClient().get(requestCountKey)) || '0') const currentTokens = parseInt((await redis.getClient().get(tokenCountKey)) || '0') + const currentCost = parseFloat((await redis.getClient().get(costCountKey)) || '0') // 新增:当前费用 // 检查请求次数限制 if (rateLimitRequests > 0 && currentRequests >= rateLimitRequests) { @@ -237,24 +247,46 @@ const authenticateApiKey = async (req, res, next) => { }) } - // 检查Token使用量限制 + // 兼容性检查:优先使用Token限制(历史数据),否则使用费用限制 const tokenLimit = parseInt(validation.keyData.tokenLimit) - if (tokenLimit > 0 && currentTokens >= tokenLimit) { - const resetTime = new Date(windowStart + windowDuration) - const remainingMinutes = Math.ceil((resetTime - now) / 60000) + if (tokenLimit > 0) { + // 使用Token限制(向后兼容) + if (currentTokens >= tokenLimit) { + const resetTime = new Date(windowStart + windowDuration) + const remainingMinutes = Math.ceil((resetTime - now) / 60000) - logger.security( - `🚦 Rate limit exceeded (tokens) for key: ${validation.keyData.id} (${validation.keyData.name}), tokens: ${currentTokens}/${tokenLimit}` - ) + logger.security( + `🚦 Rate limit exceeded (tokens) for key: ${validation.keyData.id} (${validation.keyData.name}), tokens: ${currentTokens}/${tokenLimit}` + ) - return res.status(429).json({ - error: 'Rate limit exceeded', - message: `已达到 Token 使用限制 (${tokenLimit} tokens),将在 ${remainingMinutes} 分钟后重置`, - currentTokens, - tokenLimit, - resetAt: resetTime.toISOString(), - remainingMinutes - }) + return res.status(429).json({ + error: 'Rate limit exceeded', + message: `已达到 Token 使用限制 (${tokenLimit} tokens),将在 ${remainingMinutes} 分钟后重置`, + currentTokens, + tokenLimit, + resetAt: resetTime.toISOString(), + remainingMinutes + }) + } + } else if (rateLimitCost > 0) { + // 使用费用限制(新功能) + if (currentCost >= rateLimitCost) { + const resetTime = new Date(windowStart + windowDuration) + const remainingMinutes = Math.ceil((resetTime - now) / 60000) + + logger.security( + `💰 Rate limit exceeded (cost) for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${currentCost.toFixed(2)}/$${rateLimitCost}` + ) + + return res.status(429).json({ + error: 'Rate limit exceeded', + message: `已达到费用限制 ($${rateLimitCost}),将在 ${remainingMinutes} 分钟后重置`, + currentCost, + costLimit: rateLimitCost, + resetAt: resetTime.toISOString(), + remainingMinutes + }) + } } // 增加请求计数 @@ -266,10 +298,13 @@ const authenticateApiKey = async (req, res, next) => { windowDuration, requestCountKey, tokenCountKey, + costCountKey, // 新增:费用计数器 currentRequests: currentRequests + 1, currentTokens, + currentCost, // 新增:当前费用 rateLimitRequests, - tokenLimit + tokenLimit, + rateLimitCost // 新增:费用限制 } } @@ -298,6 +333,46 @@ const authenticateApiKey = async (req, res, next) => { ) } + // 检查 Opus 周费用限制(仅对 Opus 模型生效) + const weeklyOpusCostLimit = validation.keyData.weeklyOpusCostLimit || 0 + if (weeklyOpusCostLimit > 0) { + // 从请求中获取模型信息 + const requestBody = req.body || {} + const model = requestBody.model || '' + + // 判断是否为 Opus 模型 + if (model && model.toLowerCase().includes('claude-opus')) { + const weeklyOpusCost = validation.keyData.weeklyOpusCost || 0 + + if (weeklyOpusCost >= weeklyOpusCostLimit) { + logger.security( + `💰 Weekly Opus cost limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}` + ) + + // 计算下周一的重置时间 + const now = new Date() + const dayOfWeek = now.getDay() + const daysUntilMonday = dayOfWeek === 0 ? 1 : (8 - dayOfWeek) % 7 || 7 + const resetDate = new Date(now) + resetDate.setDate(now.getDate() + daysUntilMonday) + resetDate.setHours(0, 0, 0, 0) + + return res.status(429).json({ + error: 'Weekly Opus cost limit exceeded', + message: `已达到 Opus 模型周费用限制 ($${weeklyOpusCostLimit})`, + currentCost: weeklyOpusCost, + costLimit: weeklyOpusCostLimit, + resetAt: resetDate.toISOString() // 下周一重置 + }) + } + + // 记录当前 Opus 费用使用情况 + logger.api( + `💰 Opus weekly cost usage for key: ${validation.keyData.id} (${validation.keyData.name}), current: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}` + ) + } + } + // 将验证信息添加到请求对象(只包含必要信息) req.apiKey = { id: validation.keyData.id, @@ -312,6 +387,7 @@ const authenticateApiKey = async (req, res, next) => { concurrencyLimit: validation.keyData.concurrencyLimit, rateLimitWindow: validation.keyData.rateLimitWindow, rateLimitRequests: validation.keyData.rateLimitRequests, + rateLimitCost: validation.keyData.rateLimitCost, // 新增:费用限制 enableModelRestriction: validation.keyData.enableModelRestriction, restrictedModels: validation.keyData.restrictedModels, enableClientRestriction: validation.keyData.enableClientRestriction, @@ -942,35 +1018,41 @@ const errorHandler = (error, req, res, _next) => { } // 🌐 全局速率限制中间件(延迟初始化) -let rateLimiter = null +// const rateLimiter = null // 暂时未使用 -const getRateLimiter = () => { - if (!rateLimiter) { - try { - const client = redis.getClient() - if (!client) { - logger.warn('⚠️ Redis client not available for rate limiter') - return null - } +// 暂时注释掉未使用的函数 +// const getRateLimiter = () => { +// if (!rateLimiter) { +// try { +// const client = redis.getClient() +// if (!client) { +// logger.warn('⚠️ Redis client not available for rate limiter') +// return null +// } +// +// rateLimiter = new RateLimiterRedis({ +// storeClient: client, +// keyPrefix: 'global_rate_limit', +// points: 1000, // 请求数量 +// duration: 900, // 15分钟 (900秒) +// blockDuration: 900 // 阻塞时间15分钟 +// }) +// +// logger.info('✅ Rate limiter initialized successfully') +// } catch (error) { +// logger.warn('⚠️ Rate limiter initialization failed, using fallback', { error: error.message }) +// return null +// } +// } +// return rateLimiter +// } - rateLimiter = new RateLimiterRedis({ - storeClient: client, - keyPrefix: 'global_rate_limit', - points: 1000, // 请求数量 - duration: 900, // 15分钟 (900秒) - blockDuration: 900 // 阻塞时间15分钟 - }) +const globalRateLimit = async (req, res, next) => + // 已禁用全局IP限流 - 直接跳过所有请求 + next() - logger.info('✅ Rate limiter initialized successfully') - } catch (error) { - logger.warn('⚠️ Rate limiter initialization failed, using fallback', { error: error.message }) - return null - } - } - return rateLimiter -} - -const globalRateLimit = async (req, res, next) => { +// 以下代码已被禁用 +/* // 跳过健康检查和内部请求 if (req.path === '/health' || req.path === '/api/health') { return next() @@ -1006,7 +1088,7 @@ const globalRateLimit = async (req, res, next) => { retryAfter: Math.round(msBeforeNext / 1000) }) } -} + */ // 📊 请求大小限制中间件 const requestSizeLimit = (req, res, next) => { diff --git a/src/models/redis.js b/src/models/redis.js index 9f82b391..72b47ebf 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -29,6 +29,25 @@ function getHourInTimezone(date = new Date()) { return tzDate.getUTCHours() } +// 获取配置时区的 ISO 周(YYYY-Wxx 格式,周一到周日) +function getWeekStringInTimezone(date = new Date()) { + const tzDate = getDateInTimezone(date) + + // 获取年份 + const year = tzDate.getUTCFullYear() + + // 计算 ISO 周数(周一为第一天) + const dateObj = new Date(tzDate) + const dayOfWeek = dateObj.getUTCDay() || 7 // 将周日(0)转换为7 + const firstThursday = new Date(dateObj) + firstThursday.setUTCDate(dateObj.getUTCDate() + 4 - dayOfWeek) // 找到这周的周四 + + const yearStart = new Date(firstThursday.getUTCFullYear(), 0, 1) + const weekNumber = Math.ceil(((firstThursday - yearStart) / 86400000 + 1) / 7) + + return `${year}-W${String(weekNumber).padStart(2, '0')}` +} + class RedisClient { constructor() { this.client = null @@ -193,7 +212,8 @@ class RedisClient { cacheReadTokens = 0, model = 'unknown', ephemeral5mTokens = 0, // 新增:5分钟缓存 tokens - ephemeral1hTokens = 0 // 新增:1小时缓存 tokens + ephemeral1hTokens = 0, // 新增:1小时缓存 tokens + isLongContextRequest = false // 新增:是否为 1M 上下文请求(超过200k) ) { const key = `usage:${keyId}` const now = new Date() @@ -250,6 +270,12 @@ class RedisClient { // 详细缓存类型统计(新增) pipeline.hincrby(key, 'totalEphemeral5mTokens', ephemeral5mTokens) pipeline.hincrby(key, 'totalEphemeral1hTokens', ephemeral1hTokens) + // 1M 上下文请求统计(新增) + if (isLongContextRequest) { + pipeline.hincrby(key, 'totalLongContextInputTokens', finalInputTokens) + pipeline.hincrby(key, 'totalLongContextOutputTokens', finalOutputTokens) + pipeline.hincrby(key, 'totalLongContextRequests', 1) + } // 请求计数 pipeline.hincrby(key, 'totalRequests', 1) @@ -264,6 +290,12 @@ class RedisClient { // 详细缓存类型统计 pipeline.hincrby(daily, 'ephemeral5mTokens', ephemeral5mTokens) pipeline.hincrby(daily, 'ephemeral1hTokens', ephemeral1hTokens) + // 1M 上下文请求统计 + if (isLongContextRequest) { + pipeline.hincrby(daily, 'longContextInputTokens', finalInputTokens) + pipeline.hincrby(daily, 'longContextOutputTokens', finalOutputTokens) + pipeline.hincrby(daily, 'longContextRequests', 1) + } // 每月统计 pipeline.hincrby(monthly, 'tokens', coreTokens) @@ -376,7 +408,8 @@ class RedisClient { outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, - model = 'unknown' + model = 'unknown', + isLongContextRequest = false ) { const now = new Date() const today = getDateStringInTimezone(now) @@ -407,7 +440,8 @@ class RedisClient { finalInputTokens + finalOutputTokens + finalCacheCreateTokens + finalCacheReadTokens const coreTokens = finalInputTokens + finalOutputTokens - await Promise.all([ + // 构建统计操作数组 + const operations = [ // 账户总体统计 this.client.hincrby(accountKey, 'totalTokens', coreTokens), this.client.hincrby(accountKey, 'totalInputTokens', finalInputTokens), @@ -444,6 +478,26 @@ class RedisClient { this.client.hincrby(accountHourly, 'allTokens', actualTotalTokens), this.client.hincrby(accountHourly, 'requests', 1), + // 添加模型级别的数据到hourly键中,以支持会话窗口的统计 + this.client.hincrby(accountHourly, `model:${normalizedModel}:inputTokens`, finalInputTokens), + this.client.hincrby( + accountHourly, + `model:${normalizedModel}:outputTokens`, + finalOutputTokens + ), + this.client.hincrby( + accountHourly, + `model:${normalizedModel}:cacheCreateTokens`, + finalCacheCreateTokens + ), + this.client.hincrby( + accountHourly, + `model:${normalizedModel}:cacheReadTokens`, + finalCacheReadTokens + ), + this.client.hincrby(accountHourly, `model:${normalizedModel}:allTokens`, actualTotalTokens), + this.client.hincrby(accountHourly, `model:${normalizedModel}:requests`, 1), + // 账户按模型统计 - 每日 this.client.hincrby(accountModelDaily, 'inputTokens', finalInputTokens), this.client.hincrby(accountModelDaily, 'outputTokens', finalOutputTokens), @@ -475,7 +529,21 @@ class RedisClient { this.client.expire(accountModelDaily, 86400 * 32), // 32天过期 this.client.expire(accountModelMonthly, 86400 * 365), // 1年过期 this.client.expire(accountModelHourly, 86400 * 7) // 7天过期 - ]) + ] + + // 如果是 1M 上下文请求,添加额外的统计 + if (isLongContextRequest) { + operations.push( + this.client.hincrby(accountKey, 'totalLongContextInputTokens', finalInputTokens), + this.client.hincrby(accountKey, 'totalLongContextOutputTokens', finalOutputTokens), + this.client.hincrby(accountKey, 'totalLongContextRequests', 1), + this.client.hincrby(accountDaily, 'longContextInputTokens', finalInputTokens), + this.client.hincrby(accountDaily, 'longContextOutputTokens', finalOutputTokens), + this.client.hincrby(accountDaily, 'longContextRequests', 1) + ) + } + + await Promise.all(operations) } async getUsageStats(keyId) { @@ -632,6 +700,39 @@ class RedisClient { } } + // 💰 获取本周 Opus 费用 + async getWeeklyOpusCost(keyId) { + const currentWeek = getWeekStringInTimezone() + const costKey = `usage:opus:weekly:${keyId}:${currentWeek}` + const cost = await this.client.get(costKey) + const result = parseFloat(cost || 0) + logger.debug( + `💰 Getting weekly Opus cost for ${keyId}, week: ${currentWeek}, key: ${costKey}, value: ${cost}, result: ${result}` + ) + return result + } + + // 💰 增加本周 Opus 费用 + async incrementWeeklyOpusCost(keyId, amount) { + const currentWeek = getWeekStringInTimezone() + const weeklyKey = `usage:opus:weekly:${keyId}:${currentWeek}` + const totalKey = `usage:opus:total:${keyId}` + + logger.debug( + `💰 Incrementing weekly Opus cost for ${keyId}, week: ${currentWeek}, amount: $${amount}` + ) + + // 使用 pipeline 批量执行,提高性能 + const pipeline = this.client.pipeline() + pipeline.incrbyfloat(weeklyKey, amount) + pipeline.incrbyfloat(totalKey, amount) + // 设置周费用键的过期时间为 2 周 + pipeline.expire(weeklyKey, 14 * 24 * 3600) + + const results = await pipeline.exec() + logger.debug(`💰 Opus cost incremented successfully, new weekly total: $${results[0][1]}`) + } + // 📊 获取账户使用统计 async getAccountUsageStats(accountId) { const accountKey = `account_usage:${accountId}` @@ -1276,7 +1377,7 @@ class RedisClient { const luaScript = ` local key = KEYS[1] local current = tonumber(redis.call('get', key) or "0") - + if current <= 0 then redis.call('del', key) return 0 @@ -1337,6 +1438,159 @@ class RedisClient { const client = this.getClientSafe() return await client.keys(pattern) } + + // 📊 获取账户会话窗口内的使用统计(包含模型细分) + async getAccountSessionWindowUsage(accountId, windowStart, windowEnd) { + try { + if (!windowStart || !windowEnd) { + return { + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreateTokens: 0, + totalCacheReadTokens: 0, + totalAllTokens: 0, + totalRequests: 0, + modelUsage: {} + } + } + + const startDate = new Date(windowStart) + const endDate = new Date(windowEnd) + + // 添加日志以调试时间窗口 + logger.debug(`📊 Getting session window usage for account ${accountId}`) + logger.debug(` Window: ${windowStart} to ${windowEnd}`) + logger.debug(` Start UTC: ${startDate.toISOString()}, End UTC: ${endDate.toISOString()}`) + + // 获取窗口内所有可能的小时键 + // 重要:需要使用配置的时区来构建键名,因为数据存储时使用的是配置时区 + const hourlyKeys = [] + const currentHour = new Date(startDate) + currentHour.setMinutes(0) + currentHour.setSeconds(0) + currentHour.setMilliseconds(0) + + while (currentHour <= endDate) { + // 使用时区转换函数来获取正确的日期和小时 + const tzDateStr = getDateStringInTimezone(currentHour) + const tzHour = String(getHourInTimezone(currentHour)).padStart(2, '0') + const key = `account_usage:hourly:${accountId}:${tzDateStr}:${tzHour}` + + logger.debug(` Adding hourly key: ${key}`) + hourlyKeys.push(key) + currentHour.setHours(currentHour.getHours() + 1) + } + + // 批量获取所有小时的数据 + const pipeline = this.client.pipeline() + for (const key of hourlyKeys) { + pipeline.hgetall(key) + } + const results = await pipeline.exec() + + // 聚合所有数据 + let totalInputTokens = 0 + let totalOutputTokens = 0 + let totalCacheCreateTokens = 0 + let totalCacheReadTokens = 0 + let totalAllTokens = 0 + let totalRequests = 0 + const modelUsage = {} + + logger.debug(` Processing ${results.length} hourly results`) + + for (const [error, data] of results) { + if (error || !data || Object.keys(data).length === 0) { + continue + } + + // 处理总计数据 + const hourInputTokens = parseInt(data.inputTokens || 0) + const hourOutputTokens = parseInt(data.outputTokens || 0) + const hourCacheCreateTokens = parseInt(data.cacheCreateTokens || 0) + const hourCacheReadTokens = parseInt(data.cacheReadTokens || 0) + const hourAllTokens = parseInt(data.allTokens || 0) + const hourRequests = parseInt(data.requests || 0) + + totalInputTokens += hourInputTokens + totalOutputTokens += hourOutputTokens + totalCacheCreateTokens += hourCacheCreateTokens + totalCacheReadTokens += hourCacheReadTokens + totalAllTokens += hourAllTokens + totalRequests += hourRequests + + if (hourAllTokens > 0) { + logger.debug(` Hour data: allTokens=${hourAllTokens}, requests=${hourRequests}`) + } + + // 处理每个模型的数据 + for (const [key, value] of Object.entries(data)) { + // 查找模型相关的键(格式: model:{modelName}:{metric}) + if (key.startsWith('model:')) { + const parts = key.split(':') + if (parts.length >= 3) { + const modelName = parts[1] + const metric = parts.slice(2).join(':') + + if (!modelUsage[modelName]) { + modelUsage[modelName] = { + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0, + requests: 0 + } + } + + if (metric === 'inputTokens') { + modelUsage[modelName].inputTokens += parseInt(value || 0) + } else if (metric === 'outputTokens') { + modelUsage[modelName].outputTokens += parseInt(value || 0) + } else if (metric === 'cacheCreateTokens') { + modelUsage[modelName].cacheCreateTokens += parseInt(value || 0) + } else if (metric === 'cacheReadTokens') { + modelUsage[modelName].cacheReadTokens += parseInt(value || 0) + } else if (metric === 'allTokens') { + modelUsage[modelName].allTokens += parseInt(value || 0) + } else if (metric === 'requests') { + modelUsage[modelName].requests += parseInt(value || 0) + } + } + } + } + } + + logger.debug(`📊 Session window usage summary:`) + logger.debug(` Total allTokens: ${totalAllTokens}`) + logger.debug(` Total requests: ${totalRequests}`) + logger.debug(` Input: ${totalInputTokens}, Output: ${totalOutputTokens}`) + logger.debug( + ` Cache Create: ${totalCacheCreateTokens}, Cache Read: ${totalCacheReadTokens}` + ) + + return { + totalInputTokens, + totalOutputTokens, + totalCacheCreateTokens, + totalCacheReadTokens, + totalAllTokens, + totalRequests, + modelUsage + } + } catch (error) { + logger.error(`❌ Failed to get session window usage for account ${accountId}:`, error) + return { + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreateTokens: 0, + totalCacheReadTokens: 0, + totalAllTokens: 0, + totalRequests: 0, + modelUsage: {} + } + } + } } const redisClient = new RedisClient() @@ -1345,5 +1599,6 @@ const redisClient = new RedisClient() redisClient.getDateInTimezone = getDateInTimezone redisClient.getDateStringInTimezone = getDateStringInTimezone redisClient.getHourInTimezone = getHourInTimezone +redisClient.getWeekStringInTimezone = getWeekStringInTimezone module.exports = redisClient diff --git a/src/routes/admin.js b/src/routes/admin.js index e00a6ee7..28469d7b 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -397,11 +397,13 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { concurrencyLimit, rateLimitWindow, rateLimitRequests, + rateLimitCost, enableModelRestriction, restrictedModels, enableClientRestriction, allowedClients, dailyCostLimit, + weeklyOpusCostLimit, tags } = req.body @@ -494,11 +496,13 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { concurrencyLimit, rateLimitWindow, rateLimitRequests, + rateLimitCost, enableModelRestriction, restrictedModels, enableClientRestriction, allowedClients, dailyCostLimit, + weeklyOpusCostLimit, tags }) @@ -532,6 +536,7 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { enableClientRestriction, allowedClients, dailyCostLimit, + weeklyOpusCostLimit, tags } = req.body @@ -575,6 +580,7 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { enableClientRestriction, allowedClients, dailyCostLimit, + weeklyOpusCostLimit, tags }) @@ -685,6 +691,9 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => { if (updates.dailyCostLimit !== undefined) { finalUpdates.dailyCostLimit = updates.dailyCostLimit } + if (updates.weeklyOpusCostLimit !== undefined) { + finalUpdates.weeklyOpusCostLimit = updates.weeklyOpusCostLimit + } if (updates.permissions !== undefined) { finalUpdates.permissions = updates.permissions } @@ -795,6 +804,7 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { concurrencyLimit, rateLimitWindow, rateLimitRequests, + rateLimitCost, isActive, claudeAccountId, claudeConsoleAccountId, @@ -808,6 +818,7 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { allowedClients, expiresAt, dailyCostLimit, + weeklyOpusCostLimit, tags } = req.body @@ -844,6 +855,14 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { updates.rateLimitRequests = Number(rateLimitRequests) } + if (rateLimitCost !== undefined && rateLimitCost !== null && rateLimitCost !== '') { + const cost = Number(rateLimitCost) + if (isNaN(cost) || cost < 0) { + return res.status(400).json({ error: 'Rate limit cost must be a non-negative number' }) + } + updates.rateLimitCost = cost + } + if (claudeAccountId !== undefined) { // 空字符串表示解绑,null或空字符串都设置为空字符串 updates.claudeAccountId = claudeAccountId || '' @@ -935,6 +954,22 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { updates.dailyCostLimit = costLimit } + // 处理 Opus 周费用限制 + if ( + weeklyOpusCostLimit !== undefined && + weeklyOpusCostLimit !== null && + weeklyOpusCostLimit !== '' + ) { + const costLimit = Number(weeklyOpusCostLimit) + // 明确验证非负数(0 表示禁用,负数无意义) + if (isNaN(costLimit) || costLimit < 0) { + return res + .status(400) + .json({ error: 'Weekly Opus cost limit must be a non-negative number' }) + } + updates.weeklyOpusCostLimit = costLimit + } + // 处理标签 if (tags !== undefined) { if (!Array.isArray(tags)) { @@ -1475,11 +1510,14 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => { if (groupId && groupId !== 'all') { if (groupId === 'ungrouped') { // 筛选未分组账户 - accounts = accounts.filter((account) => !account.groupInfo) + accounts = accounts.filter( + (account) => !account.groupInfos || account.groupInfos.length === 0 + ) } else { // 筛选特定分组的账户 accounts = accounts.filter( - (account) => account.groupInfo && account.groupInfo.id === groupId + (account) => + account.groupInfos && account.groupInfos.some((group) => group.id === groupId) ) } } @@ -1489,23 +1527,89 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => { accounts.map(async (account) => { try { const usageStats = await redis.getAccountUsageStats(account.id) + const groupInfos = await accountGroupService.getAccountGroup(account.id) + + // 获取会话窗口使用统计(仅对有活跃窗口的账户) + let sessionWindowUsage = null + if (account.sessionWindow && account.sessionWindow.hasActiveWindow) { + const windowUsage = await redis.getAccountSessionWindowUsage( + account.id, + account.sessionWindow.windowStart, + account.sessionWindow.windowEnd + ) + + // 计算会话窗口的总费用 + let totalCost = 0 + const modelCosts = {} + + for (const [modelName, usage] of Object.entries(windowUsage.modelUsage)) { + const usageData = { + input_tokens: usage.inputTokens, + output_tokens: usage.outputTokens, + cache_creation_input_tokens: usage.cacheCreateTokens, + cache_read_input_tokens: usage.cacheReadTokens + } + + logger.debug(`💰 Calculating cost for model ${modelName}:`, JSON.stringify(usageData)) + const costResult = CostCalculator.calculateCost(usageData, modelName) + logger.debug(`💰 Cost result for ${modelName}: total=${costResult.costs.total}`) + + modelCosts[modelName] = { + ...usage, + cost: costResult.costs.total + } + totalCost += costResult.costs.total + } + + sessionWindowUsage = { + totalTokens: windowUsage.totalAllTokens, + totalRequests: windowUsage.totalRequests, + totalCost, + modelUsage: modelCosts + } + } + return { ...account, + // 转换schedulable为布尔值 + schedulable: account.schedulable === 'true' || account.schedulable === true, + groupInfos, usage: { daily: usageStats.daily, total: usageStats.total, - averages: usageStats.averages + averages: usageStats.averages, + sessionWindow: sessionWindowUsage } } } catch (statsError) { logger.warn(`⚠️ Failed to get usage stats for account ${account.id}:`, statsError.message) // 如果获取统计失败,返回空统计 - return { - ...account, - usage: { - daily: { tokens: 0, requests: 0, allTokens: 0 }, - total: { tokens: 0, requests: 0, allTokens: 0 }, - averages: { rpm: 0, tpm: 0 } + try { + const groupInfos = await accountGroupService.getAccountGroup(account.id) + return { + ...account, + groupInfos, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 }, + sessionWindow: null + } + } + } catch (groupError) { + logger.warn( + `⚠️ Failed to get group info for 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 }, + sessionWindow: null + } } } } @@ -1533,7 +1637,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => { accountType, platform = 'claude', priority, - groupId + groupId, + autoStopOnWarning } = req.body if (!name) { @@ -1570,7 +1675,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => { proxy, accountType: accountType || 'shared', // 默认为共享类型 platform, - priority: priority || 50 // 默认优先级为50 + priority: priority || 50, // 默认优先级为50 + autoStopOnWarning: autoStopOnWarning === true // 默认为false }) // 如果是分组类型,将账户添加到分组 @@ -1622,10 +1728,10 @@ router.put('/claude-accounts/:accountId', authenticateAdmin, async (req, res) => // 处理分组的变更 if (updates.accountType !== undefined) { - // 如果之前是分组类型,需要从原分组中移除 + // 如果之前是分组类型,需要从所有分组中移除 if (currentAccount.accountType === 'group') { - const oldGroup = await accountGroupService.getAccountGroup(accountId) - if (oldGroup) { + const oldGroups = await accountGroupService.getAccountGroup(accountId) + for (const oldGroup of oldGroups) { await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id) } } @@ -1657,8 +1763,8 @@ router.delete('/claude-accounts/:accountId', authenticateAdmin, async (req, res) // 获取账户信息以检查是否在分组中 const account = await claudeAccountService.getAccount(accountId) if (account && account.accountType === 'group') { - const group = await accountGroupService.getAccountGroup(accountId) - if (group) { + const groups = await accountGroupService.getAccountGroup(accountId) + for (const group of groups) { await accountGroupService.removeAccountFromGroup(accountId, group.id) } } @@ -1807,11 +1913,14 @@ router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => { if (groupId && groupId !== 'all') { if (groupId === 'ungrouped') { // 筛选未分组账户 - accounts = accounts.filter((account) => !account.groupInfo) + accounts = accounts.filter( + (account) => !account.groupInfos || account.groupInfos.length === 0 + ) } else { // 筛选特定分组的账户 accounts = accounts.filter( - (account) => account.groupInfo && account.groupInfo.id === groupId + (account) => + account.groupInfos && account.groupInfos.some((group) => group.id === groupId) ) } } @@ -1821,8 +1930,13 @@ router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => { accounts.map(async (account) => { try { const usageStats = await redis.getAccountUsageStats(account.id) + const groupInfos = await accountGroupService.getAccountGroup(account.id) + return { ...account, + // 转换schedulable为布尔值 + schedulable: account.schedulable === 'true' || account.schedulable === true, + groupInfos, usage: { daily: usageStats.daily, total: usageStats.total, @@ -1834,12 +1948,32 @@ router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => { `⚠️ Failed to get usage stats for Claude Console account ${account.id}:`, statsError.message ) - return { - ...account, - usage: { - daily: { tokens: 0, requests: 0, allTokens: 0 }, - total: { tokens: 0, requests: 0, allTokens: 0 }, - averages: { rpm: 0, tpm: 0 } + try { + const groupInfos = await accountGroupService.getAccountGroup(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 Claude Console 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 } + } } } } @@ -1953,10 +2087,10 @@ router.put('/claude-console-accounts/:accountId', authenticateAdmin, async (req, // 处理分组的变更 if (updates.accountType !== undefined) { - // 如果之前是分组类型,需要从原分组中移除 + // 如果之前是分组类型,需要从所有分组中移除 if (currentAccount.accountType === 'group') { - const oldGroup = await accountGroupService.getAccountGroup(accountId) - if (oldGroup) { + const oldGroups = await accountGroupService.getAccountGroup(accountId) + for (const oldGroup of oldGroups) { await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id) } } @@ -1987,8 +2121,8 @@ router.delete('/claude-console-accounts/:accountId', authenticateAdmin, async (r // 获取账户信息以检查是否在分组中 const account = await claudeConsoleAccountService.getAccount(accountId) if (account && account.accountType === 'group') { - const group = await accountGroupService.getAccountGroup(accountId) - if (group) { + const groups = await accountGroupService.getAccountGroup(accountId) + for (const group of groups) { await accountGroupService.removeAccountFromGroup(accountId, group.id) } } @@ -2097,11 +2231,14 @@ router.get('/bedrock-accounts', authenticateAdmin, async (req, res) => { if (groupId && groupId !== 'all') { if (groupId === 'ungrouped') { // 筛选未分组账户 - accounts = accounts.filter((account) => !account.groupInfo) + accounts = accounts.filter( + (account) => !account.groupInfos || account.groupInfos.length === 0 + ) } else { // 筛选特定分组的账户 accounts = accounts.filter( - (account) => account.groupInfo && account.groupInfo.id === groupId + (account) => + account.groupInfos && account.groupInfos.some((group) => group.id === groupId) ) } } @@ -2111,8 +2248,11 @@ router.get('/bedrock-accounts', authenticateAdmin, async (req, res) => { accounts.map(async (account) => { try { const usageStats = await redis.getAccountUsageStats(account.id) + const groupInfos = await accountGroupService.getAccountGroup(account.id) + return { ...account, + groupInfos, usage: { daily: usageStats.daily, total: usageStats.total, @@ -2124,12 +2264,30 @@ router.get('/bedrock-accounts', authenticateAdmin, async (req, res) => { `⚠️ Failed to get usage stats for Bedrock account ${account.id}:`, statsError.message ) - return { - ...account, - usage: { - daily: { tokens: 0, requests: 0, allTokens: 0 }, - total: { tokens: 0, requests: 0, allTokens: 0 }, - averages: { rpm: 0, tpm: 0 } + try { + const groupInfos = await accountGroupService.getAccountGroup(account.id) + return { + ...account, + 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 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 } + } } } } @@ -2520,11 +2678,14 @@ router.get('/gemini-accounts', authenticateAdmin, async (req, res) => { if (groupId && groupId !== 'all') { if (groupId === 'ungrouped') { // 筛选未分组账户 - accounts = accounts.filter((account) => !account.groupInfo) + accounts = accounts.filter( + (account) => !account.groupInfos || account.groupInfos.length === 0 + ) } else { // 筛选特定分组的账户 accounts = accounts.filter( - (account) => account.groupInfo && account.groupInfo.id === groupId + (account) => + account.groupInfos && account.groupInfos.some((group) => group.id === groupId) ) } } @@ -2534,8 +2695,11 @@ router.get('/gemini-accounts', authenticateAdmin, async (req, res) => { accounts.map(async (account) => { try { const usageStats = await redis.getAccountUsageStats(account.id) + const groupInfos = await accountGroupService.getAccountGroup(account.id) + return { ...account, + groupInfos, usage: { daily: usageStats.daily, total: usageStats.total, @@ -2548,12 +2712,30 @@ router.get('/gemini-accounts', authenticateAdmin, async (req, res) => { statsError.message ) // 如果获取统计失败,返回空统计 - return { - ...account, - usage: { - daily: { tokens: 0, requests: 0, allTokens: 0 }, - total: { tokens: 0, requests: 0, allTokens: 0 }, - averages: { rpm: 0, tpm: 0 } + try { + const groupInfos = await accountGroupService.getAccountGroup(account.id) + return { + ...account, + 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 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 } + } } } } @@ -2633,10 +2815,10 @@ router.put('/gemini-accounts/:accountId', authenticateAdmin, async (req, res) => // 处理分组的变更 if (updates.accountType !== undefined) { - // 如果之前是分组类型,需要从原分组中移除 + // 如果之前是分组类型,需要从所有分组中移除 if (currentAccount.accountType === 'group') { - const oldGroup = await accountGroupService.getAccountGroup(accountId) - if (oldGroup) { + const oldGroups = await accountGroupService.getAccountGroup(accountId) + for (const oldGroup of oldGroups) { await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id) } } @@ -2664,8 +2846,8 @@ router.delete('/gemini-accounts/:accountId', authenticateAdmin, async (req, res) // 获取账户信息以检查是否在分组中 const account = await geminiAccountService.getAccount(accountId) if (account && account.accountType === 'group') { - const group = await accountGroupService.getAccountGroup(accountId) - if (group) { + const groups = await accountGroupService.getAccountGroup(accountId) + for (const group of groups) { await accountGroupService.removeAccountFromGroup(accountId, group.id) } } @@ -5003,11 +5185,14 @@ router.get('/openai-accounts', authenticateAdmin, async (req, res) => { if (groupId && groupId !== 'all') { if (groupId === 'ungrouped') { // 筛选未分组账户 - accounts = accounts.filter((account) => !account.groupInfo) + accounts = accounts.filter( + (account) => !account.groupInfos || account.groupInfos.length === 0 + ) } else { // 筛选特定分组的账户 accounts = accounts.filter( - (account) => account.groupInfo && account.groupInfo.id === groupId + (account) => + account.groupInfos && account.groupInfos.some((group) => group.id === groupId) ) } } diff --git a/src/routes/api.js b/src/routes/api.js index bad90a41..73b771b6 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -5,6 +5,7 @@ const bedrockRelayService = require('../services/bedrockRelayService') const bedrockAccountService = require('../services/bedrockAccountService') const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler') const apiKeyService = require('../services/apiKeyService') +const pricingService = require('../services/pricingService') const { authenticateApiKey } = require('../middleware/auth') const logger = require('../utils/logger') const redis = require('../models/redis') @@ -131,14 +132,16 @@ async function handleMessagesRequest(req, res) { } apiKeyService - .recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId) + .recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'claude') .catch((error) => { logger.error('❌ Failed to record stream usage:', error) }) - // 更新时间窗口内的token计数 + // 更新时间窗口内的token计数和费用 if (req.rateLimitInfo) { const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + // 更新Token计数(向后兼容) redis .getClient() .incrby(req.rateLimitInfo.tokenCountKey, totalTokens) @@ -146,6 +149,22 @@ async function handleMessagesRequest(req, res) { 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 @@ -216,14 +235,22 @@ async function handleMessagesRequest(req, res) { } apiKeyService - .recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId) + .recordUsageWithDetails( + req.apiKey.id, + usageObject, + model, + usageAccountId, + 'claude-console' + ) .catch((error) => { logger.error('❌ Failed to record stream usage:', error) }) - // 更新时间窗口内的token计数 + // 更新时间窗口内的token计数和费用 if (req.rateLimitInfo) { const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + // 更新Token计数(向后兼容) redis .getClient() .incrby(req.rateLimitInfo.tokenCountKey, totalTokens) @@ -231,6 +258,22 @@ async function handleMessagesRequest(req, res) { 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 @@ -271,9 +314,11 @@ async function handleMessagesRequest(req, res) { logger.error('❌ Failed to record Bedrock stream usage:', error) }) - // 更新时间窗口内的token计数 + // 更新时间窗口内的token计数和费用 if (req.rateLimitInfo) { const totalTokens = inputTokens + outputTokens + + // 更新Token计数(向后兼容) redis .getClient() .incrby(req.rateLimitInfo.tokenCountKey, totalTokens) @@ -281,6 +326,20 @@ async function handleMessagesRequest(req, res) { 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(result.usage, result.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 @@ -438,11 +497,24 @@ async function handleMessagesRequest(req, res) { responseAccountId ) - // 更新时间窗口内的token计数 + // 更新时间窗口内的token计数和费用 if (req.rateLimitInfo) { const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + // 更新Token计数(向后兼容) await redis.getClient().incrby(req.rateLimitInfo.tokenCountKey, totalTokens) logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`) + + // 计算并更新费用计数(新功能) + if (req.rateLimitInfo.costCountKey) { + const costInfo = pricingService.calculateCost(jsonData.usage, model) + if (costInfo.totalCost > 0) { + await redis + .getClient() + .incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost) + logger.api(`💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}`) + } + } } usageRecorded = true diff --git a/src/routes/apiStats.js b/src/routes/apiStats.js index 2b8eca6f..3233b1f4 100644 --- a/src/routes/apiStats.js +++ b/src/routes/apiStats.js @@ -278,21 +278,24 @@ router.post('/api/user-stats', async (req, res) => { // 获取当前使用量 let currentWindowRequests = 0 let currentWindowTokens = 0 + let currentWindowCost = 0 // 新增:当前窗口费用 let currentDailyCost = 0 let windowStartTime = null let windowEndTime = null let windowRemainingSeconds = null try { - // 获取当前时间窗口的请求次数和Token使用量 + // 获取当前时间窗口的请求次数、Token使用量和费用 if (fullKeyData.rateLimitWindow > 0) { const client = redis.getClientSafe() const requestCountKey = `rate_limit:requests:${keyId}` const tokenCountKey = `rate_limit:tokens:${keyId}` + const costCountKey = `rate_limit:cost:${keyId}` // 新增:费用计数key const windowStartKey = `rate_limit:window_start:${keyId}` currentWindowRequests = parseInt((await client.get(requestCountKey)) || '0') currentWindowTokens = parseInt((await client.get(tokenCountKey)) || '0') + currentWindowCost = parseFloat((await client.get(costCountKey)) || '0') // 新增:获取当前窗口费用 // 获取窗口开始时间和计算剩余时间 const windowStart = await client.get(windowStartKey) @@ -313,6 +316,7 @@ router.post('/api/user-stats', async (req, res) => { // 重置计数为0,因为窗口已过期 currentWindowRequests = 0 currentWindowTokens = 0 + currentWindowCost = 0 // 新增:重置窗口费用 } } } @@ -356,10 +360,12 @@ router.post('/api/user-stats', async (req, res) => { concurrencyLimit: fullKeyData.concurrencyLimit || 0, rateLimitWindow: fullKeyData.rateLimitWindow || 0, rateLimitRequests: fullKeyData.rateLimitRequests || 0, + rateLimitCost: parseFloat(fullKeyData.rateLimitCost) || 0, // 新增:费用限制 dailyCostLimit: fullKeyData.dailyCostLimit || 0, // 当前使用量 currentWindowRequests, currentWindowTokens, + currentWindowCost, // 新增:当前窗口费用 currentDailyCost, // 时间窗口信息 windowStartTime, diff --git a/src/services/accountGroupService.js b/src/services/accountGroupService.js index 078ba5b6..d2061266 100644 --- a/src/services/accountGroupService.js +++ b/src/services/accountGroupService.js @@ -328,25 +328,32 @@ class AccountGroupService { } /** - * 根据账户ID获取其所属的分组 + * 根据账户ID获取其所属的所有分组 * @param {string} accountId - 账户ID - * @returns {Object|null} 分组信息 + * @returns {Array} 分组信息数组 */ async getAccountGroup(accountId) { try { const client = redis.getClientSafe() const allGroupIds = await client.smembers(this.GROUPS_KEY) + const memberGroups = [] for (const groupId of allGroupIds) { const isMember = await client.sismember(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId) if (isMember) { - return await this.getGroup(groupId) + const group = await this.getGroup(groupId) + if (group) { + memberGroups.push(group) + } } } - return null + // 按创建时间倒序排序 + memberGroups.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + + return memberGroups } catch (error) { - logger.error('❌ 获取账户所属分组失败:', error) + logger.error('❌ 获取账户所属分组列表失败:', error) throw error } } diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index 61c87971..197f8e78 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -14,7 +14,7 @@ class ApiKeyService { const { name = 'Unnamed Key', description = '', - tokenLimit = config.limits.defaultTokenLimit, + tokenLimit = 0, // 默认为0,不再使用token限制 expiresAt = null, claudeAccountId = null, claudeConsoleAccountId = null, @@ -27,11 +27,13 @@ class ApiKeyService { concurrencyLimit = 0, rateLimitWindow = null, rateLimitRequests = null, + rateLimitCost = null, // 新增:速率限制费用字段 enableModelRestriction = false, restrictedModels = [], enableClientRestriction = false, allowedClients = [], dailyCostLimit = 0, + weeklyOpusCostLimit = 0, tags = [] } = options @@ -49,6 +51,7 @@ class ApiKeyService { concurrencyLimit: String(concurrencyLimit ?? 0), rateLimitWindow: String(rateLimitWindow ?? 0), rateLimitRequests: String(rateLimitRequests ?? 0), + rateLimitCost: String(rateLimitCost ?? 0), // 新增:速率限制费用字段 isActive: String(isActive), claudeAccountId: claudeAccountId || '', claudeConsoleAccountId: claudeConsoleAccountId || '', @@ -62,6 +65,7 @@ class ApiKeyService { enableClientRestriction: String(enableClientRestriction || false), allowedClients: JSON.stringify(allowedClients || []), dailyCostLimit: String(dailyCostLimit || 0), + weeklyOpusCostLimit: String(weeklyOpusCostLimit || 0), tags: JSON.stringify(tags || []), createdAt: new Date().toISOString(), lastUsedAt: '', @@ -85,6 +89,7 @@ class ApiKeyService { concurrencyLimit: parseInt(keyData.concurrencyLimit), rateLimitWindow: parseInt(keyData.rateLimitWindow || 0), rateLimitRequests: parseInt(keyData.rateLimitRequests || 0), + rateLimitCost: parseFloat(keyData.rateLimitCost || 0), // 新增:速率限制费用字段 isActive: keyData.isActive === 'true', claudeAccountId: keyData.claudeAccountId, claudeConsoleAccountId: keyData.claudeConsoleAccountId, @@ -98,6 +103,7 @@ class ApiKeyService { enableClientRestriction: keyData.enableClientRestriction === 'true', allowedClients: JSON.parse(keyData.allowedClients || '[]'), dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), + weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0), tags: JSON.parse(keyData.tags || '[]'), createdAt: keyData.createdAt, expiresAt: keyData.expiresAt, @@ -200,12 +206,15 @@ class ApiKeyService { concurrencyLimit: parseInt(keyData.concurrencyLimit || 0), rateLimitWindow: parseInt(keyData.rateLimitWindow || 0), rateLimitRequests: parseInt(keyData.rateLimitRequests || 0), + rateLimitCost: parseFloat(keyData.rateLimitCost || 0), // 新增:速率限制费用字段 enableModelRestriction: keyData.enableModelRestriction === 'true', restrictedModels, enableClientRestriction: keyData.enableClientRestriction === 'true', allowedClients, dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), + weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0), dailyCost: dailyCost || 0, + weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0, tags, usage } @@ -242,22 +251,27 @@ class ApiKeyService { key.concurrencyLimit = parseInt(key.concurrencyLimit || 0) key.rateLimitWindow = parseInt(key.rateLimitWindow || 0) key.rateLimitRequests = parseInt(key.rateLimitRequests || 0) + key.rateLimitCost = parseFloat(key.rateLimitCost || 0) // 新增:速率限制费用字段 key.currentConcurrency = await redis.getConcurrency(key.id) key.isActive = key.isActive === 'true' key.enableModelRestriction = key.enableModelRestriction === 'true' key.enableClientRestriction = key.enableClientRestriction === 'true' key.permissions = key.permissions || 'all' // 兼容旧数据 key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0) + key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0) key.dailyCost = (await redis.getDailyCost(key.id)) || 0 + key.weeklyOpusCost = (await redis.getWeeklyOpusCost(key.id)) || 0 - // 获取当前时间窗口的请求次数和Token使用量 + // 获取当前时间窗口的请求次数、Token使用量和费用 if (key.rateLimitWindow > 0) { const requestCountKey = `rate_limit:requests:${key.id}` const tokenCountKey = `rate_limit:tokens:${key.id}` + const costCountKey = `rate_limit:cost:${key.id}` // 新增:费用计数器 const windowStartKey = `rate_limit:window_start:${key.id}` key.currentWindowRequests = parseInt((await client.get(requestCountKey)) || '0') key.currentWindowTokens = parseInt((await client.get(tokenCountKey)) || '0') + key.currentWindowCost = parseFloat((await client.get(costCountKey)) || '0') // 新增:当前窗口费用 // 获取窗口开始时间和计算剩余时间 const windowStart = await client.get(windowStartKey) @@ -280,6 +294,7 @@ class ApiKeyService { // 重置计数为0,因为窗口已过期 key.currentWindowRequests = 0 key.currentWindowTokens = 0 + key.currentWindowCost = 0 // 新增:重置费用 } } else { // 窗口还未开始(没有任何请求) @@ -290,6 +305,7 @@ class ApiKeyService { } else { key.currentWindowRequests = 0 key.currentWindowTokens = 0 + key.currentWindowCost = 0 // 新增:重置费用 key.windowStartTime = null key.windowEndTime = null key.windowRemainingSeconds = null @@ -336,6 +352,7 @@ class ApiKeyService { 'concurrencyLimit', 'rateLimitWindow', 'rateLimitRequests', + 'rateLimitCost', // 新增:速率限制费用字段 'isActive', 'claudeAccountId', 'claudeConsoleAccountId', @@ -350,6 +367,7 @@ class ApiKeyService { 'enableClientRestriction', 'allowedClients', 'dailyCostLimit', + 'weeklyOpusCostLimit', 'tags' ] const updatedData = { ...keyData } @@ -441,6 +459,13 @@ class ApiKeyService { model ) + // 检查是否为 1M 上下文请求 + let isLongContextRequest = false + if (model && model.includes('[1m]')) { + const totalInputTokens = inputTokens + cacheCreateTokens + cacheReadTokens + isLongContextRequest = totalInputTokens > 200000 + } + // 记录API Key级别的使用统计 await redis.incrementTokenUsage( keyId, @@ -449,7 +474,10 @@ class ApiKeyService { outputTokens, cacheCreateTokens, cacheReadTokens, - model + model, + 0, // ephemeral5mTokens - 暂时为0,后续处理 + 0, // ephemeral1hTokens - 暂时为0,后续处理 + isLongContextRequest ) // 记录费用统计 @@ -478,7 +506,8 @@ class ApiKeyService { outputTokens, cacheCreateTokens, cacheReadTokens, - model + model, + isLongContextRequest ) logger.database( `📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})` @@ -505,8 +534,38 @@ class ApiKeyService { } } + // 📊 记录 Opus 模型费用(仅限 claude 和 claude-console 账户) + async recordOpusCost(keyId, cost, model, accountType) { + try { + // 判断是否为 Opus 模型 + if (!model || !model.toLowerCase().includes('claude-opus')) { + return // 不是 Opus 模型,直接返回 + } + + // 判断是否为 claude 或 claude-console 账户 + if (!accountType || (accountType !== 'claude' && accountType !== 'claude-console')) { + logger.debug(`⚠️ Skipping Opus cost recording for non-Claude account type: ${accountType}`) + return // 不是 claude 账户,直接返回 + } + + // 记录 Opus 周费用 + await redis.incrementWeeklyOpusCost(keyId, cost) + logger.database( + `💰 Recorded Opus weekly cost for ${keyId}: $${cost.toFixed(6)}, model: ${model}, account type: ${accountType}` + ) + } catch (error) { + logger.error('❌ Failed to record Opus cost:', error) + } + } + // 📊 记录使用情况(新版本,支持详细的缓存类型) - async recordUsageWithDetails(keyId, usageObject, model = 'unknown', accountId = null) { + async recordUsageWithDetails( + keyId, + usageObject, + model = 'unknown', + accountId = null, + accountType = null + ) { try { // 提取 token 数量 const inputTokens = usageObject.input_tokens || 0 @@ -550,7 +609,8 @@ class ApiKeyService { cacheReadTokens, model, ephemeral5mTokens, // 传递5分钟缓存 tokens - ephemeral1hTokens // 传递1小时缓存 tokens + ephemeral1hTokens, // 传递1小时缓存 tokens + costInfo.isLongContextRequest || false // 传递 1M 上下文请求标记 ) // 记录费用统计 @@ -560,6 +620,9 @@ class ApiKeyService { `💰 Recorded cost for ${keyId}: $${costInfo.totalCost.toFixed(6)}, model: ${model}` ) + // 记录 Opus 周费用(如果适用) + await this.recordOpusCost(keyId, costInfo.totalCost, model, accountType) + // 记录详细的缓存费用(如果有) if (costInfo.ephemeral5mCost > 0 || costInfo.ephemeral1hCost > 0) { logger.database( @@ -586,7 +649,8 @@ class ApiKeyService { outputTokens, cacheCreateTokens, cacheReadTokens, - model + model, + costInfo.isLongContextRequest || false ) logger.database( `📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})` diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index ffd390bd..17bb3465 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -57,7 +57,8 @@ class ClaudeAccountService { platform = 'claude', priority = 50, // 调度优先级 (1-100,数字越小优先级越高) schedulable = true, // 是否可被调度 - subscriptionInfo = null // 手动设置的订阅信息 + subscriptionInfo = null, // 手动设置的订阅信息 + autoStopOnWarning = false // 5小时使用量接近限制时自动停止调度 } = options const accountId = uuidv4() @@ -88,6 +89,7 @@ class ClaudeAccountService { status: 'active', // 有OAuth数据的账户直接设为active errorMessage: '', schedulable: schedulable.toString(), // 是否可被调度 + autoStopOnWarning: autoStopOnWarning.toString(), // 5小时使用量接近限制时自动停止调度 // 优先使用手动设置的订阅信息,否则使用OAuth数据中的,否则默认为空 subscriptionInfo: subscriptionInfo ? JSON.stringify(subscriptionInfo) @@ -118,6 +120,7 @@ class ClaudeAccountService { status: 'created', // created, active, expired, error errorMessage: '', schedulable: schedulable.toString(), // 是否可被调度 + autoStopOnWarning: autoStopOnWarning.toString(), // 5小时使用量接近限制时自动停止调度 // 手动设置的订阅信息 subscriptionInfo: subscriptionInfo ? JSON.stringify(subscriptionInfo) : '' } @@ -158,7 +161,8 @@ class ClaudeAccountService { status: accountData.status, createdAt: accountData.createdAt, expiresAt: accountData.expiresAt, - scopes: claudeAiOauth ? claudeAiOauth.scopes : [] + scopes: claudeAiOauth ? claudeAiOauth.scopes : [], + autoStopOnWarning } } @@ -479,7 +483,11 @@ class ClaudeAccountService { lastRequestTime: null }, // 添加调度状态 - schedulable: account.schedulable !== 'false' // 默认为true,兼容历史数据 + schedulable: account.schedulable !== 'false', // 默认为true,兼容历史数据 + // 添加自动停止调度设置 + autoStopOnWarning: account.autoStopOnWarning === 'true', // 默认为false + // 添加停止原因 + stoppedReason: account.stoppedReason || null } }) ) @@ -609,6 +617,13 @@ class ClaudeAccountService { // 🗑️ 删除Claude账户 async deleteAccount(accountId) { try { + // 首先从所有分组中移除此账户 + const accountGroupService = require('./accountGroupService') + const groups = await accountGroupService.getAccountGroup(accountId) + for (const group of groups) { + await accountGroupService.removeAccountFromGroup(accountId, group.id) + } + const result = await redis.deleteClaudeAccount(accountId) if (result === 0) { @@ -630,7 +645,10 @@ class ClaudeAccountService { const accounts = await redis.getAllClaudeAccounts() let activeAccounts = accounts.filter( - (account) => account.isActive === 'true' && account.status !== 'error' + (account) => + account.isActive === 'true' && + account.status !== 'error' && + account.schedulable !== 'false' ) // 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号 @@ -717,7 +735,12 @@ class ClaudeAccountService { // 如果API Key绑定了专属账户,优先使用 if (apiKeyData.claudeAccountId) { const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId) - if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') { + if ( + boundAccount && + boundAccount.isActive === 'true' && + boundAccount.status !== 'error' && + boundAccount.schedulable !== 'false' + ) { logger.info( `🎯 Using bound dedicated account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}` ) @@ -736,6 +759,7 @@ class ClaudeAccountService { (account) => account.isActive === 'true' && account.status !== 'error' && + account.schedulable !== 'false' && (account.accountType === 'shared' || !account.accountType) // 兼容旧数据 ) @@ -1268,6 +1292,42 @@ class ClaudeAccountService { accountData.sessionWindowEnd = windowEnd.toISOString() accountData.lastRequestTime = now.toISOString() + // 清除会话窗口状态,因为进入了新窗口 + if (accountData.sessionWindowStatus) { + delete accountData.sessionWindowStatus + delete accountData.sessionWindowStatusUpdatedAt + } + + // 如果账户因为5小时限制被自动停止,现在恢复调度 + if ( + accountData.autoStoppedAt && + accountData.schedulable === 'false' && + accountData.stoppedReason === '5小时使用量接近限制,自动停止调度' + ) { + logger.info( + `✅ Auto-resuming scheduling for account ${accountData.name} (${accountId}) - new session window started` + ) + accountData.schedulable = 'true' + delete accountData.stoppedReason + delete accountData.autoStoppedAt + + // 发送Webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name || 'Claude Account', + platform: 'claude', + status: 'resumed', + errorCode: 'CLAUDE_5H_LIMIT_RESUMED', + reason: '进入新的5小时窗口,已自动恢复调度', + timestamp: new Date().toISOString() + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification:', webhookError) + } + } + logger.info( `🕐 Created new session window for account ${accountData.name} (${accountId}): ${windowStart.toISOString()} - ${windowEnd.toISOString()} (from current time)` ) @@ -1313,7 +1373,8 @@ class ClaudeAccountService { windowEnd: null, progress: 0, remainingTime: null, - lastRequestTime: accountData.lastRequestTime || null + lastRequestTime: accountData.lastRequestTime || null, + sessionWindowStatus: accountData.sessionWindowStatus || null } } @@ -1330,7 +1391,8 @@ class ClaudeAccountService { windowEnd: accountData.sessionWindowEnd, progress: 100, remainingTime: 0, - lastRequestTime: accountData.lastRequestTime || null + lastRequestTime: accountData.lastRequestTime || null, + sessionWindowStatus: accountData.sessionWindowStatus || null } } @@ -1348,7 +1410,8 @@ class ClaudeAccountService { windowEnd: accountData.sessionWindowEnd, progress, remainingTime, - lastRequestTime: accountData.lastRequestTime || null + lastRequestTime: accountData.lastRequestTime || null, + sessionWindowStatus: accountData.sessionWindowStatus || null } } catch (error) { logger.error(`❌ Failed to get session window info for account ${accountId}:`, error) @@ -1734,6 +1797,209 @@ class ClaudeAccountService { throw error } } + + // 🧹 清理临时错误账户 + async cleanupTempErrorAccounts() { + try { + const accounts = await redis.getAllClaudeAccounts() + let cleanedCount = 0 + const TEMP_ERROR_RECOVERY_MINUTES = 60 // 临时错误状态恢复时间(分钟) + + for (const account of accounts) { + if (account.status === 'temp_error' && account.tempErrorAt) { + const tempErrorAt = new Date(account.tempErrorAt) + const now = new Date() + const minutesSinceTempError = (now - tempErrorAt) / (1000 * 60) + + // 如果临时错误状态超过指定时间,尝试重新激活 + if (minutesSinceTempError > TEMP_ERROR_RECOVERY_MINUTES) { + account.status = 'active' // 恢复为 active 状态 + account.schedulable = 'true' // 恢复为可调度 + delete account.errorMessage + delete account.tempErrorAt + await redis.setClaudeAccount(account.id, account) + // 同时清除500错误计数 + await this.clearInternalErrors(account.id) + cleanedCount++ + logger.success(`🧹 Reset temp_error status for account ${account.name} (${account.id})`) + } + } + } + + if (cleanedCount > 0) { + logger.success(`🧹 Reset ${cleanedCount} temp_error accounts`) + } + + return cleanedCount + } catch (error) { + logger.error('❌ Failed to cleanup temp_error accounts:', error) + return 0 + } + } + + // 记录5xx服务器错误 + async recordServerError(accountId, statusCode) { + try { + const key = `claude_account:${accountId}:5xx_errors` + + // 增加错误计数,设置5分钟过期时间 + await redis.client.incr(key) + await redis.client.expire(key, 300) // 5分钟 + + logger.info(`📝 Recorded ${statusCode} error for account ${accountId}`) + } catch (error) { + logger.error(`❌ Failed to record ${statusCode} error for account ${accountId}:`, error) + } + } + + // 记录500内部错误(保留以便向后兼容) + async recordInternalError(accountId) { + return this.recordServerError(accountId, 500) + } + + // 获取5xx错误计数 + async getServerErrorCount(accountId) { + try { + const key = `claude_account:${accountId}:5xx_errors` + + const count = await redis.client.get(key) + return parseInt(count) || 0 + } catch (error) { + logger.error(`❌ Failed to get 5xx error count for account ${accountId}:`, error) + return 0 + } + } + + // 获取500错误计数(保留以便向后兼容) + async getInternalErrorCount(accountId) { + return this.getServerErrorCount(accountId) + } + + // 清除500错误计数 + async clearInternalErrors(accountId) { + try { + const key = `claude_account:${accountId}:5xx_errors` + + await redis.client.del(key) + logger.info(`✅ Cleared 5xx error count for account ${accountId}`) + } catch (error) { + logger.error(`❌ Failed to clear 5xx errors for account ${accountId}:`, error) + } + } + + // 标记账号为临时错误状态 + async markAccountTempError(accountId, sessionHash = null) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + // 更新账户状态 + const updatedAccountData = { ...accountData } + updatedAccountData.status = 'temp_error' // 新增的临时错误状态 + updatedAccountData.schedulable = 'false' // 设置为不可调度 + updatedAccountData.errorMessage = 'Account temporarily disabled due to consecutive 500 errors' + updatedAccountData.tempErrorAt = new Date().toISOString() + + // 保存更新后的账户数据 + await redis.setClaudeAccount(accountId, updatedAccountData) + + // 如果有sessionHash,删除粘性会话映射 + if (sessionHash) { + await redis.client.del(`sticky_session:${sessionHash}`) + logger.info(`🗑️ Deleted sticky session mapping for hash: ${sessionHash}`) + } + + logger.warn( + `⚠️ Account ${accountData.name} (${accountId}) marked as temp_error and disabled for scheduling` + ) + + // 发送Webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name, + platform: 'claude-oauth', + status: 'temp_error', + errorCode: 'CLAUDE_OAUTH_TEMP_ERROR', + reason: 'Account temporarily disabled due to consecutive 500 errors' + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification:', webhookError) + } + + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark account ${accountId} as temp_error:`, error) + throw error + } + } + + // 更新会话窗口状态(allowed, allowed_warning, rejected) + async updateSessionWindowStatus(accountId, status) { + try { + // 参数验证 + if (!accountId || !status) { + logger.warn( + `Invalid parameters for updateSessionWindowStatus: accountId=${accountId}, status=${status}` + ) + return + } + + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + logger.warn(`Account not found: ${accountId}`) + return + } + + // 验证状态值是否有效 + const validStatuses = ['allowed', 'allowed_warning', 'rejected'] + if (!validStatuses.includes(status)) { + logger.warn(`Invalid session window status: ${status} for account ${accountId}`) + return + } + + // 更新会话窗口状态 + accountData.sessionWindowStatus = status + accountData.sessionWindowStatusUpdatedAt = new Date().toISOString() + + // 如果状态是 allowed_warning 且账户设置了自动停止调度 + if (status === 'allowed_warning' && accountData.autoStopOnWarning === 'true') { + logger.warn( + `⚠️ Account ${accountData.name} (${accountId}) approaching 5h limit, auto-stopping scheduling` + ) + accountData.schedulable = 'false' + accountData.stoppedReason = '5小时使用量接近限制,自动停止调度' + accountData.autoStoppedAt = new Date().toISOString() + + // 发送Webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name || 'Claude Account', + platform: 'claude', + status: 'warning', + errorCode: 'CLAUDE_5H_LIMIT_WARNING', + reason: '5小时使用量接近限制,已自动停止调度', + timestamp: new Date().toISOString() + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification:', webhookError) + } + } + + await redis.setClaudeAccount(accountId, accountData) + + logger.info( + `📊 Updated session window status for account ${accountData.name} (${accountId}): ${status}` + ) + } catch (error) { + logger.error(`❌ Failed to update session window status for account ${accountId}:`, error) + } + } } module.exports = new ClaudeAccountService() diff --git a/src/services/claudeConsoleAccountService.js b/src/services/claudeConsoleAccountService.js index c2044895..7bde2c29 100644 --- a/src/services/claudeConsoleAccountService.js +++ b/src/services/claudeConsoleAccountService.js @@ -453,6 +453,144 @@ class ClaudeConsoleAccountService { } } + // 🚫 标记账号为未授权状态(401错误) + async markAccountUnauthorized(accountId) { + try { + const client = redis.getClientSafe() + const account = await this.getAccount(accountId) + + if (!account) { + throw new Error('Account not found') + } + + const updates = { + schedulable: 'false', + status: 'unauthorized', + errorMessage: 'API Key无效或已过期(401错误)', + unauthorizedAt: new Date().toISOString(), + unauthorizedCount: String((parseInt(account.unauthorizedCount || '0') || 0) + 1) + } + + await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) + + // 发送Webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || 'Claude Console Account', + platform: 'claude-console', + status: 'error', + errorCode: 'CLAUDE_CONSOLE_UNAUTHORIZED', + reason: 'API Key无效或已过期(401错误),账户已停止调度', + timestamp: new Date().toISOString() + }) + } catch (webhookError) { + logger.error('Failed to send unauthorized webhook notification:', webhookError) + } + + logger.warn( + `🚫 Claude Console account marked as unauthorized: ${account.name} (${accountId})` + ) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark Claude Console account as unauthorized: ${accountId}`, error) + throw error + } + } + + // 🚫 标记账号为过载状态(529错误) + async markAccountOverloaded(accountId) { + try { + const client = redis.getClientSafe() + const account = await this.getAccount(accountId) + + if (!account) { + throw new Error('Account not found') + } + + const updates = { + overloadedAt: new Date().toISOString(), + overloadStatus: 'overloaded', + errorMessage: '服务过载(529错误)' + } + + await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) + + // 发送Webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || 'Claude Console Account', + platform: 'claude-console', + status: 'error', + errorCode: 'CLAUDE_CONSOLE_OVERLOADED', + reason: '服务过载(529错误)。账户将暂时停止调度', + timestamp: new Date().toISOString() + }) + } catch (webhookError) { + logger.error('Failed to send overload webhook notification:', webhookError) + } + + logger.warn(`🚫 Claude Console account marked as overloaded: ${account.name} (${accountId})`) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark Claude Console account as overloaded: ${accountId}`, error) + throw error + } + } + + // ✅ 移除账号的过载状态 + async removeAccountOverload(accountId) { + try { + const client = redis.getClientSafe() + + await client.hdel(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, 'overloadedAt', 'overloadStatus') + + logger.success(`✅ Overload status removed for Claude Console account: ${accountId}`) + return { success: true } + } catch (error) { + logger.error( + `❌ Failed to remove overload status for Claude Console account: ${accountId}`, + error + ) + throw error + } + } + + // 🔍 检查账号是否处于过载状态 + async isAccountOverloaded(accountId) { + try { + const account = await this.getAccount(accountId) + if (!account) { + return false + } + + if (account.overloadStatus === 'overloaded' && account.overloadedAt) { + const overloadedAt = new Date(account.overloadedAt) + const now = new Date() + const minutesSinceOverload = (now - overloadedAt) / (1000 * 60) + + // 过载状态持续10分钟后自动恢复 + if (minutesSinceOverload >= 10) { + await this.removeAccountOverload(accountId) + return false + } + + return true + } + + return false + } catch (error) { + logger.error( + `❌ Failed to check overload status for Claude Console account: ${accountId}`, + error + ) + return false + } + } + // 🚫 标记账号为封锁状态(模型不支持等原因) async blockAccount(accountId, reason) { try { diff --git a/src/services/claudeConsoleRelayService.js b/src/services/claudeConsoleRelayService.js index dafb7f98..27920a47 100644 --- a/src/services/claudeConsoleRelayService.js +++ b/src/services/claudeConsoleRelayService.js @@ -175,16 +175,26 @@ class ClaudeConsoleRelayService { `[DEBUG] Response data preview: ${typeof response.data === 'string' ? response.data.substring(0, 200) : JSON.stringify(response.data).substring(0, 200)}` ) - // 检查是否为限流错误 - if (response.status === 429) { + // 检查错误状态并相应处理 + if (response.status === 401) { + logger.warn(`🚫 Unauthorized error detected for Claude Console account ${accountId}`) + await claudeConsoleAccountService.markAccountUnauthorized(accountId) + } else if (response.status === 429) { logger.warn(`🚫 Rate limit detected for Claude Console account ${accountId}`) await claudeConsoleAccountService.markAccountRateLimited(accountId) + } else if (response.status === 529) { + logger.warn(`🚫 Overload error detected for Claude Console account ${accountId}`) + await claudeConsoleAccountService.markAccountOverloaded(accountId) } else if (response.status === 200 || response.status === 201) { - // 如果请求成功,检查并移除限流状态 + // 如果请求成功,检查并移除错误状态 const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(accountId) if (isRateLimited) { await claudeConsoleAccountService.removeAccountRateLimit(accountId) } + const isOverloaded = await claudeConsoleAccountService.isAccountOverloaded(accountId) + if (isOverloaded) { + await claudeConsoleAccountService.removeAccountOverload(accountId) + } } // 更新最后使用时间 @@ -363,8 +373,12 @@ class ClaudeConsoleRelayService { if (response.status !== 200) { logger.error(`❌ Claude Console API returned error status: ${response.status}`) - if (response.status === 429) { + if (response.status === 401) { + claudeConsoleAccountService.markAccountUnauthorized(accountId) + } else if (response.status === 429) { claudeConsoleAccountService.markAccountRateLimited(accountId) + } else if (response.status === 529) { + claudeConsoleAccountService.markAccountOverloaded(accountId) } // 设置错误响应的状态码和响应头 @@ -396,12 +410,17 @@ class ClaudeConsoleRelayService { return } - // 成功响应,检查并移除限流状态 + // 成功响应,检查并移除错误状态 claudeConsoleAccountService.isAccountRateLimited(accountId).then((isRateLimited) => { if (isRateLimited) { claudeConsoleAccountService.removeAccountRateLimit(accountId) } }) + claudeConsoleAccountService.isAccountOverloaded(accountId).then((isOverloaded) => { + if (isOverloaded) { + claudeConsoleAccountService.removeAccountOverload(accountId) + } + }) // 设置响应头 if (!responseStream.headersSent) { @@ -564,9 +583,15 @@ class ClaudeConsoleRelayService { logger.error('❌ Claude Console Claude stream request error:', error.message) - // 检查是否是429错误 - if (error.response && error.response.status === 429) { - claudeConsoleAccountService.markAccountRateLimited(accountId) + // 检查错误状态 + if (error.response) { + if (error.response.status === 401) { + claudeConsoleAccountService.markAccountUnauthorized(accountId) + } else if (error.response.status === 429) { + claudeConsoleAccountService.markAccountRateLimited(accountId) + } else if (error.response.status === 529) { + claudeConsoleAccountService.markAccountOverloaded(accountId) + } } // 发送错误响应 diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index 49a9192a..57e42438 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -180,15 +180,15 @@ class ClaudeRelayService { // 记录401错误 await this.recordUnauthorizedError(accountId) - // 检查是否需要标记为异常(连续3次401) + // 检查是否需要标记为异常(遇到1次401就停止调度) const errorCount = await this.getUnauthorizedErrorCount(accountId) logger.info( `🔐 Account ${accountId} has ${errorCount} consecutive 401 errors in the last 5 minutes` ) - if (errorCount >= 3) { + if (errorCount >= 1) { logger.error( - `❌ Account ${accountId} exceeded 401 error threshold (${errorCount} errors), marking as unauthorized` + `❌ Account ${accountId} encountered 401 error (${errorCount} errors), marking as unauthorized` ) await unifiedClaudeScheduler.markAccountUnauthorized( accountId, @@ -197,6 +197,23 @@ class ClaudeRelayService { ) } } + // 检查是否为5xx状态码 + else if (response.statusCode >= 500 && response.statusCode < 600) { + logger.warn(`🔥 Server error (${response.statusCode}) detected for account ${accountId}`) + // 记录5xx错误 + await claudeAccountService.recordServerError(accountId, response.statusCode) + // 检查是否需要标记为临时错误状态(连续3次500) + const errorCount = await claudeAccountService.getServerErrorCount(accountId) + logger.info( + `🔥 Account ${accountId} has ${errorCount} consecutive 5xx errors in the last 5 minutes` + ) + if (errorCount >= 3) { + logger.error( + `❌ Account ${accountId} exceeded 5xx error threshold (${errorCount} errors), marking as temp_error` + ) + await claudeAccountService.markAccountTempError(accountId, sessionHash) + } + } // 检查是否为429状态码 else if (response.statusCode === 429) { isRateLimited = true @@ -247,8 +264,30 @@ class ClaudeRelayService { ) } } else if (response.statusCode === 200 || response.statusCode === 201) { - // 请求成功,清除401错误计数 + // 提取5小时会话窗口状态 + // 使用大小写不敏感的方式获取响应头 + const get5hStatus = (headers) => { + if (!headers) { + return null + } + // HTTP头部名称不区分大小写,需要处理不同情况 + return ( + headers['anthropic-ratelimit-unified-5h-status'] || + headers['Anthropic-Ratelimit-Unified-5h-Status'] || + headers['ANTHROPIC-RATELIMIT-UNIFIED-5H-STATUS'] + ) + } + + const sessionWindowStatus = get5hStatus(response.headers) + if (sessionWindowStatus) { + logger.info(`📊 Session window status for account ${accountId}: ${sessionWindowStatus}`) + // 保存会话窗口状态到账户数据 + await claudeAccountService.updateSessionWindowStatus(accountId, sessionWindowStatus) + } + + // 请求成功,清除401和500错误计数 await this.clearUnauthorizedErrors(accountId) + await claudeAccountService.clearInternalErrors(accountId) // 如果请求成功,检查并移除限流状态 const isRateLimited = await unifiedClaudeScheduler.isAccountRateLimited( accountId, @@ -436,7 +475,10 @@ class ClaudeRelayService { const modelConfig = pricingData[model] if (!modelConfig) { - logger.debug(`🔍 Model ${model} not found in pricing file, skipping max_tokens validation`) + // 如果找不到模型配置,直接透传客户端参数,不进行任何干预 + logger.info( + `📝 Model ${model} not found in pricing file, passing through client parameters without modification` + ) return } @@ -883,6 +925,34 @@ class ClaudeRelayService { // 错误响应处理 if (res.statusCode !== 200) { + // 将错误处理逻辑封装在一个异步函数中 + const handleErrorResponse = async () => { + // 增加对5xx错误的处理 + if (res.statusCode >= 500 && res.statusCode < 600) { + logger.warn( + `🔥 [Stream] Server error (${res.statusCode}) detected for account ${accountId}` + ) + // 记录5xx错误 + await claudeAccountService.recordServerError(accountId, res.statusCode) + // 检查是否需要标记为临时错误状态(连续3次500) + const errorCount = await claudeAccountService.getServerErrorCount(accountId) + logger.info( + `🔥 [Stream] Account ${accountId} has ${errorCount} consecutive 5xx errors in the last 5 minutes` + ) + if (errorCount >= 3) { + logger.error( + `❌ [Stream] Account ${accountId} exceeded 5xx error threshold (${errorCount} errors), marking as temp_error` + ) + await claudeAccountService.markAccountTempError(accountId, sessionHash) + } + } + } + + // 调用异步错误处理函数 + handleErrorResponse().catch((err) => { + logger.error('❌ Error in stream error handler:', err) + }) + logger.error(`❌ Claude API returned error status: ${res.statusCode}`) let errorData = '' @@ -1143,6 +1213,27 @@ class ClaudeRelayService { usageCallback(finalUsage) } + // 提取5小时会话窗口状态 + // 使用大小写不敏感的方式获取响应头 + const get5hStatus = (headers) => { + if (!headers) { + return null + } + // HTTP头部名称不区分大小写,需要处理不同情况 + return ( + headers['anthropic-ratelimit-unified-5h-status'] || + headers['Anthropic-Ratelimit-Unified-5h-Status'] || + headers['ANTHROPIC-RATELIMIT-UNIFIED-5H-STATUS'] + ) + } + + const sessionWindowStatus = get5hStatus(res.headers) + if (sessionWindowStatus) { + logger.info(`📊 Session window status for account ${accountId}: ${sessionWindowStatus}`) + // 保存会话窗口状态到账户数据 + await claudeAccountService.updateSessionWindowStatus(accountId, sessionWindowStatus) + } + // 处理限流状态 if (rateLimitDetected || res.statusCode === 429) { // 提取限流重置时间戳 @@ -1162,6 +1253,9 @@ class ClaudeRelayService { rateLimitResetTimestamp ) } else if (res.statusCode === 200) { + // 请求成功,清除401和500错误计数 + await this.clearUnauthorizedErrors(accountId) + await claudeAccountService.clearInternalErrors(accountId) // 如果请求成功,检查并移除限流状态 const isRateLimited = await unifiedClaudeScheduler.isAccountRateLimited( accountId, diff --git a/src/services/pricingService.js b/src/services/pricingService.js index 5ded4c0a..606465d5 100644 --- a/src/services/pricingService.js +++ b/src/services/pricingService.js @@ -45,6 +45,7 @@ class PricingService { 'claude-sonnet-3-5': 0.000006, 'claude-sonnet-3-7': 0.000006, 'claude-sonnet-4': 0.000006, + 'claude-sonnet-4-20250514': 0.000006, // Haiku 系列: $1.6/MTok 'claude-3-5-haiku': 0.0000016, @@ -55,6 +56,17 @@ class PricingService { 'claude-haiku-3': 0.0000016, 'claude-haiku-3-5': 0.0000016 } + + // 硬编码的 1M 上下文模型价格(美元/token) + // 当总输入 tokens 超过 200k 时使用这些价格 + this.longContextPricing = { + // claude-sonnet-4-20250514[1m] 模型的 1M 上下文价格 + 'claude-sonnet-4-20250514[1m]': { + input: 0.000006, // $6/MTok + output: 0.0000225 // $22.50/MTok + } + // 未来可以添加更多 1M 模型的价格 + } } // 初始化价格服务 @@ -249,6 +261,7 @@ class PricingService { // 尝试直接匹配 if (this.pricingData[modelName]) { + logger.debug(`💰 Found exact pricing match for ${modelName}`) return this.pricingData[modelName] } @@ -293,6 +306,20 @@ class PricingService { return null } + // 确保价格对象包含缓存价格 + ensureCachePricing(pricing) { + if (!pricing) return pricing + + // 如果缺少缓存价格,根据输入价格计算(缓存创建价格通常是输入价格的1.25倍,缓存读取是0.1倍) + if (!pricing.cache_creation_input_token_cost && pricing.input_cost_per_token) { + pricing.cache_creation_input_token_cost = pricing.input_cost_per_token * 1.25 + } + if (!pricing.cache_read_input_token_cost && pricing.input_cost_per_token) { + pricing.cache_read_input_token_cost = pricing.input_cost_per_token * 0.1 + } + return pricing + } + // 获取 1 小时缓存价格 getEphemeral1hPricing(modelName) { if (!modelName) { @@ -329,9 +356,40 @@ class PricingService { // 计算使用费用 calculateCost(usage, modelName) { + // 检查是否为 1M 上下文模型 + const isLongContextModel = modelName && modelName.includes('[1m]') + let isLongContextRequest = false + let useLongContextPricing = false + + if (isLongContextModel) { + // 计算总输入 tokens + const inputTokens = usage.input_tokens || 0 + const cacheCreationTokens = usage.cache_creation_input_tokens || 0 + const cacheReadTokens = usage.cache_read_input_tokens || 0 + const totalInputTokens = inputTokens + cacheCreationTokens + cacheReadTokens + + // 如果总输入超过 200k,使用 1M 上下文价格 + if (totalInputTokens > 200000) { + isLongContextRequest = true + // 检查是否有硬编码的 1M 价格 + if (this.longContextPricing[modelName]) { + useLongContextPricing = true + } else { + // 如果没有找到硬编码价格,使用第一个 1M 模型的价格作为默认 + const defaultLongContextModel = Object.keys(this.longContextPricing)[0] + if (defaultLongContextModel) { + useLongContextPricing = true + logger.warn( + `⚠️ No specific 1M pricing for ${modelName}, using default from ${defaultLongContextModel}` + ) + } + } + } + } + const pricing = this.getModelPricing(modelName) - if (!pricing) { + if (!pricing && !useLongContextPricing) { return { inputCost: 0, outputCost: 0, @@ -340,14 +398,35 @@ class PricingService { ephemeral5mCost: 0, ephemeral1hCost: 0, totalCost: 0, - hasPricing: false + hasPricing: false, + isLongContextRequest: false } } - const inputCost = (usage.input_tokens || 0) * (pricing.input_cost_per_token || 0) - const outputCost = (usage.output_tokens || 0) * (pricing.output_cost_per_token || 0) + let inputCost = 0 + let outputCost = 0 + + if (useLongContextPricing) { + // 使用 1M 上下文特殊价格(仅输入和输出价格改变) + const longContextPrices = + this.longContextPricing[modelName] || + this.longContextPricing[Object.keys(this.longContextPricing)[0]] + + inputCost = (usage.input_tokens || 0) * longContextPrices.input + outputCost = (usage.output_tokens || 0) * longContextPrices.output + + logger.info( + `💰 Using 1M context pricing for ${modelName}: input=$${longContextPrices.input}/token, output=$${longContextPrices.output}/token` + ) + } else { + // 使用正常价格 + inputCost = (usage.input_tokens || 0) * (pricing?.input_cost_per_token || 0) + outputCost = (usage.output_tokens || 0) * (pricing?.output_cost_per_token || 0) + } + + // 缓存价格保持不变(即使对于 1M 模型) const cacheReadCost = - (usage.cache_read_input_tokens || 0) * (pricing.cache_read_input_token_cost || 0) + (usage.cache_read_input_tokens || 0) * (pricing?.cache_read_input_token_cost || 0) // 处理缓存创建费用: // 1. 如果有详细的 cache_creation 对象,使用它 @@ -362,7 +441,7 @@ class PricingService { const ephemeral1hTokens = usage.cache_creation.ephemeral_1h_input_tokens || 0 // 5分钟缓存使用标准的 cache_creation_input_token_cost - ephemeral5mCost = ephemeral5mTokens * (pricing.cache_creation_input_token_cost || 0) + ephemeral5mCost = ephemeral5mTokens * (pricing?.cache_creation_input_token_cost || 0) // 1小时缓存使用硬编码的价格 const ephemeral1hPrice = this.getEphemeral1hPricing(modelName) @@ -373,7 +452,7 @@ class PricingService { } else if (usage.cache_creation_input_tokens) { // 旧格式,所有缓存创建 tokens 都按 5 分钟价格计算(向后兼容) cacheCreateCost = - (usage.cache_creation_input_tokens || 0) * (pricing.cache_creation_input_token_cost || 0) + (usage.cache_creation_input_tokens || 0) * (pricing?.cache_creation_input_token_cost || 0) ephemeral5mCost = cacheCreateCost } @@ -386,11 +465,22 @@ class PricingService { ephemeral1hCost, totalCost: inputCost + outputCost + cacheCreateCost + cacheReadCost, hasPricing: true, + isLongContextRequest, pricing: { - input: pricing.input_cost_per_token || 0, - output: pricing.output_cost_per_token || 0, - cacheCreate: pricing.cache_creation_input_token_cost || 0, - cacheRead: pricing.cache_read_input_token_cost || 0, + input: useLongContextPricing + ? ( + this.longContextPricing[modelName] || + this.longContextPricing[Object.keys(this.longContextPricing)[0]] + )?.input || 0 + : pricing?.input_cost_per_token || 0, + output: useLongContextPricing + ? ( + this.longContextPricing[modelName] || + this.longContextPricing[Object.keys(this.longContextPricing)[0]] + )?.output || 0 + : pricing?.output_cost_per_token || 0, + cacheCreate: pricing?.cache_creation_input_token_cost || 0, + cacheRead: pricing?.cache_read_input_token_cost || 0, ephemeral1h: this.getEphemeral1hPricing(modelName) } } diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index 287bb465..c83676a2 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -176,7 +176,8 @@ class UnifiedClaudeScheduler { boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error' && - boundAccount.status !== 'blocked' + boundAccount.status !== 'blocked' && + boundAccount.status !== 'temp_error' ) { const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id) if (!isRateLimited) { @@ -262,6 +263,7 @@ class UnifiedClaudeScheduler { account.isActive === 'true' && account.status !== 'error' && account.status !== 'blocked' && + account.status !== 'temp_error' && (account.accountType === 'shared' || !account.accountType) && // 兼容旧数据 this._isSchedulable(account.schedulable) ) { @@ -441,7 +443,12 @@ class UnifiedClaudeScheduler { try { if (accountType === 'claude-official') { const account = await redis.getClaudeAccount(accountId) - if (!account || account.isActive !== 'true' || account.status === 'error') { + if ( + !account || + account.isActive !== 'true' || + account.status === 'error' || + account.status === 'temp_error' + ) { return false } // 检查是否可调度 @@ -452,7 +459,15 @@ class UnifiedClaudeScheduler { return !(await claudeAccountService.isAccountRateLimited(accountId)) } else if (accountType === 'claude-console') { const account = await claudeConsoleAccountService.getAccount(accountId) - if (!account || !account.isActive || account.status !== 'active') { + if (!account || !account.isActive) { + return false + } + // 检查账户状态 + if ( + account.status !== 'active' && + account.status !== 'unauthorized' && + account.status !== 'overloaded' + ) { return false } // 检查是否可调度 @@ -460,7 +475,19 @@ class UnifiedClaudeScheduler { logger.info(`🚫 Claude Console account ${accountId} is not schedulable`) return false } - return !(await claudeConsoleAccountService.isAccountRateLimited(accountId)) + // 检查是否被限流 + if (await claudeConsoleAccountService.isAccountRateLimited(accountId)) { + return false + } + // 检查是否未授权(401错误) + if (account.status === 'unauthorized') { + return false + } + // 检查是否过载(529错误) + if (await claudeConsoleAccountService.isAccountOverloaded(accountId)) { + return false + } + return true } else if (accountType === 'bedrock') { const accountResult = await bedrockAccountService.getAccount(accountId) if (!accountResult.success || !accountResult.data.isActive) { diff --git a/src/utils/costCalculator.js b/src/utils/costCalculator.js index a0fe6700..e623abaa 100644 --- a/src/utils/costCalculator.js +++ b/src/utils/costCalculator.js @@ -31,6 +31,14 @@ const MODEL_PRICING = { cacheWrite: 18.75, cacheRead: 1.5 }, + + // Claude Opus 4.1 (新模型) + 'claude-opus-4-1-20250805': { + input: 15.0, + output: 75.0, + cacheWrite: 18.75, + cacheRead: 1.5 + }, // Claude 3 Sonnet 'claude-3-sonnet-20240229': { @@ -69,9 +77,57 @@ class CostCalculator { * @returns {Object} 费用详情 */ static calculateCost(usage, model = 'unknown') { - // 如果 usage 包含详细的 cache_creation 对象,使用 pricingService 来处理 - if (usage.cache_creation && typeof usage.cache_creation === 'object') { - return pricingService.calculateCost(usage, model) + // 如果 usage 包含详细的 cache_creation 对象或是 1M 模型,使用 pricingService 来处理 + if ( + (usage.cache_creation && typeof usage.cache_creation === 'object') || + (model && model.includes('[1m]')) + ) { + const result = pricingService.calculateCost(usage, model) + // 转换 pricingService 返回的格式到 costCalculator 的格式 + return { + model, + pricing: { + input: result.pricing.input * 1000000, // 转换为 per 1M tokens + output: result.pricing.output * 1000000, + cacheWrite: result.pricing.cacheCreate * 1000000, + cacheRead: result.pricing.cacheRead * 1000000 + }, + usingDynamicPricing: true, + isLongContextRequest: result.isLongContextRequest || false, + usage: { + inputTokens: usage.input_tokens || 0, + outputTokens: usage.output_tokens || 0, + cacheCreateTokens: usage.cache_creation_input_tokens || 0, + cacheReadTokens: usage.cache_read_input_tokens || 0, + totalTokens: + (usage.input_tokens || 0) + + (usage.output_tokens || 0) + + (usage.cache_creation_input_tokens || 0) + + (usage.cache_read_input_tokens || 0) + }, + costs: { + input: result.inputCost, + output: result.outputCost, + cacheWrite: result.cacheCreateCost, + cacheRead: result.cacheReadCost, + total: result.totalCost + }, + formatted: { + input: this.formatCost(result.inputCost), + output: this.formatCost(result.outputCost), + cacheWrite: this.formatCost(result.cacheCreateCost), + cacheRead: this.formatCost(result.cacheReadCost), + total: this.formatCost(result.totalCost) + }, + debug: { + isOpenAIModel: model.includes('gpt') || model.includes('o1'), + hasCacheCreatePrice: !!result.pricing.cacheCreate, + cacheCreateTokens: usage.cache_creation_input_tokens || 0, + cacheWritePriceUsed: result.pricing.cacheCreate * 1000000, + isLongContextModel: model && model.includes('[1m]'), + isLongContextRequest: result.isLongContextRequest || false + } + } } // 否则使用旧的逻辑(向后兼容) diff --git a/web/admin-spa/package-lock.json b/web/admin-spa/package-lock.json index 6d04a5f8..30b46f1e 100644 --- a/web/admin-spa/package-lock.json +++ b/web/admin-spa/package-lock.json @@ -3723,7 +3723,6 @@ "resolved": "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", "integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==", "dev": true, - "license": "MIT", "engines": { "node": ">=14.21.3" }, diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 488bff84..35e3f776 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -807,6 +807,25 @@

+ +
+ +
+
+ +
+ +
+
@@ -275,12 +275,9 @@
示例1: 时间窗口=60,请求次数=1000 → 每60分钟最多1000次请求
+
示例2: 时间窗口=1,费用=0.1 → 每分钟最多$0.1费用
- 示例2: 时间窗口=1,Token=10000 → 每分钟最多10,000个Token -
-
- 示例3: 窗口=30,请求=50,Token=100000 → - 每30分钟50次请求且不超10万Token + 示例3: 窗口=30,请求=50,费用=5 → 每30分钟50次请求且不超$5费用
@@ -336,6 +333,55 @@ +
+ +
+
+ + + + +
+ +

+ 设置 Opus 模型的周费用限制(周一到周日),仅限 Claude 官方账户,0 或留空表示无限制 +

+
+
+
{ } } + // 检查是否设置了时间窗口但费用限制为0 + if (form.rateLimitWindow && (!form.rateLimitCost || parseFloat(form.rateLimitCost) === 0)) { + let confirmed = false + if (window.showConfirm) { + confirmed = await window.showConfirm( + '费用限制提醒', + '您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n\n是否继续?', + '继续创建', + '返回修改' + ) + } else { + // 降级方案 + confirmed = confirm('您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n是否继续?') + } + if (!confirmed) { + return + } + } + loading.value = true try { // 准备提交的数据 const baseData = { description: form.description || undefined, - tokenLimit: - form.tokenLimit !== '' && form.tokenLimit !== null ? parseInt(form.tokenLimit) : null, + tokenLimit: 0, // 设置为0,清除历史token限制 rateLimitWindow: form.rateLimitWindow !== '' && form.rateLimitWindow !== null ? parseInt(form.rateLimitWindow) @@ -1001,6 +1066,10 @@ const createApiKey = async () => { form.rateLimitRequests !== '' && form.rateLimitRequests !== null ? parseInt(form.rateLimitRequests) : null, + rateLimitCost: + form.rateLimitCost !== '' && form.rateLimitCost !== null + ? parseFloat(form.rateLimitCost) + : null, concurrencyLimit: form.concurrencyLimit !== '' && form.concurrencyLimit !== null ? parseInt(form.concurrencyLimit) @@ -1009,6 +1078,10 @@ const createApiKey = async () => { form.dailyCostLimit !== '' && form.dailyCostLimit !== null ? parseFloat(form.dailyCostLimit) : 0, + weeklyOpusCostLimit: + form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null + ? parseFloat(form.weeklyOpusCostLimit) + : 0, expiresAt: form.expiresAt || undefined, permissions: form.permissions, tags: form.tags.length > 0 ? form.tags : undefined, diff --git a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue index 7d8069cf..f74b25f8 100644 --- a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue @@ -166,17 +166,17 @@
费用限制 (美元) -

- 窗口内最大Token -

+

窗口内最大费用

@@ -189,12 +189,9 @@
示例1: 时间窗口=60,请求次数=1000 → 每60分钟最多1000次请求
+
示例2: 时间窗口=1,费用=0.1 → 每分钟最多$0.1费用
- 示例2: 时间窗口=1,Token=10000 → 每分钟最多10,000个Token -
-
- 示例3: 窗口=30,请求=50,Token=100000 → - 每30分钟50次请求且不超10万Token + 示例3: 窗口=30,请求=50,费用=5 → 每30分钟50次请求且不超$5费用
@@ -250,6 +247,55 @@ +
+ +
+
+ + + + +
+ +

+ 设置 Opus 模型的周费用限制(周一到周日),仅限 Claude 官方账户,0 或留空表示无限制 +

+
+
+
{ // 表单数据 const form = reactive({ name: '', - tokenLimit: '', + tokenLimit: '', // 保留用于检测历史数据 rateLimitWindow: '', rateLimitRequests: '', + rateLimitCost: '', // 新增:费用限制 concurrencyLimit: '', dailyCostLimit: '', + weeklyOpusCostLimit: '', permissions: 'all', claudeAccountId: '', geminiAccountId: '', @@ -702,13 +750,31 @@ const removeTag = (index) => { // 更新 API Key const updateApiKey = async () => { + // 检查是否设置了时间窗口但费用限制为0 + if (form.rateLimitWindow && (!form.rateLimitCost || parseFloat(form.rateLimitCost) === 0)) { + let confirmed = false + if (window.showConfirm) { + confirmed = await window.showConfirm( + '费用限制提醒', + '您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n\n是否继续?', + '继续保存', + '返回修改' + ) + } else { + // 降级方案 + confirmed = confirm('您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n是否继续?') + } + if (!confirmed) { + return + } + } + loading.value = true try { // 准备提交的数据 const data = { - tokenLimit: - form.tokenLimit !== '' && form.tokenLimit !== null ? parseInt(form.tokenLimit) : 0, + tokenLimit: 0, // 清除历史token限制 rateLimitWindow: form.rateLimitWindow !== '' && form.rateLimitWindow !== null ? parseInt(form.rateLimitWindow) @@ -717,6 +783,10 @@ const updateApiKey = async () => { form.rateLimitRequests !== '' && form.rateLimitRequests !== null ? parseInt(form.rateLimitRequests) : 0, + rateLimitCost: + form.rateLimitCost !== '' && form.rateLimitCost !== null + ? parseFloat(form.rateLimitCost) + : 0, concurrencyLimit: form.concurrencyLimit !== '' && form.concurrencyLimit !== null ? parseInt(form.concurrencyLimit) @@ -725,6 +795,10 @@ const updateApiKey = async () => { form.dailyCostLimit !== '' && form.dailyCostLimit !== null ? parseFloat(form.dailyCostLimit) : 0, + weeklyOpusCostLimit: + form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null + ? parseFloat(form.weeklyOpusCostLimit) + : 0, permissions: form.permissions, tags: form.tags } @@ -893,11 +967,22 @@ onMounted(async () => { } form.name = props.apiKey.name + + // 处理速率限制迁移:如果有tokenLimit且没有rateLimitCost,提示用户 form.tokenLimit = props.apiKey.tokenLimit || '' + form.rateLimitCost = props.apiKey.rateLimitCost || '' + + // 如果有历史tokenLimit但没有rateLimitCost,提示用户需要重新设置 + if (props.apiKey.tokenLimit > 0 && !props.apiKey.rateLimitCost) { + // 可以根据需要添加提示,或者自动迁移(这里选择让用户手动设置) + console.log('检测到历史Token限制,请考虑设置费用限制') + } + form.rateLimitWindow = props.apiKey.rateLimitWindow || '' form.rateLimitRequests = props.apiKey.rateLimitRequests || '' form.concurrencyLimit = props.apiKey.concurrencyLimit || '' form.dailyCostLimit = props.apiKey.dailyCostLimit || '' + form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || '' form.permissions = props.apiKey.permissions || 'all' // 处理 Claude 账号(区分 OAuth 和 Console) if (props.apiKey.claudeConsoleAccountId) { diff --git a/web/admin-spa/src/components/apikeys/UsageDetailModal.vue b/web/admin-spa/src/components/apikeys/UsageDetailModal.vue index c084e602..54593e11 100644 --- a/web/admin-spa/src/components/apikeys/UsageDetailModal.vue +++ b/web/admin-spa/src/components/apikeys/UsageDetailModal.vue @@ -196,6 +196,8 @@ 时间窗口限制
+
Token @@ -48,6 +49,23 @@ />
+ + +
+
+ 费用 + + ${{ (currentCost || 0).toFixed(2) }}/${{ costLimit.toFixed(2) }} + +
+
+
+
+
@@ -102,6 +120,14 @@ const props = defineProps({ type: Number, default: 0 }, + currentCost: { + type: Number, + default: 0 + }, + costLimit: { + type: Number, + default: 0 + }, showProgress: { type: Boolean, default: true @@ -132,6 +158,7 @@ const windowState = computed(() => { const hasRequestLimit = computed(() => props.requestLimit > 0) const hasTokenLimit = computed(() => props.tokenLimit > 0) +const hasCostLimit = computed(() => props.costLimit > 0) // 方法 const formatTime = (seconds) => { @@ -196,6 +223,19 @@ const getTokenProgressColor = () => { return 'bg-purple-500' } +const getCostProgress = () => { + if (!props.costLimit || props.costLimit === 0) return 0 + const percentage = ((props.currentCost || 0) / props.costLimit) * 100 + return Math.min(percentage, 100) +} + +const getCostProgressColor = () => { + const progress = getCostProgress() + if (progress >= 100) return 'bg-red-500' + if (progress >= 80) return 'bg-yellow-500' + return 'bg-green-500' +} + // 更新倒计时 const updateCountdown = () => { if (props.windowEndTime && remainingSeconds.value > 0) { diff --git a/web/admin-spa/src/components/apistats/LimitConfig.vue b/web/admin-spa/src/components/apistats/LimitConfig.vue index a666e2d5..c5338184 100644 --- a/web/admin-spa/src/components/apistats/LimitConfig.vue +++ b/web/admin-spa/src/components/apistats/LimitConfig.vue @@ -45,10 +45,14 @@
- 请求次数和Token使用量为"或"的关系,任一达到限制即触发限流 + + 请求次数和费用限制为"或"的关系,任一达到限制即触发限流 + + + 请求次数和Token使用量为"或"的关系,任一达到限制即触发限流 + + 仅限制请求次数
diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index f0555b6e..683ab91f 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -191,7 +191,39 @@ - 会话窗口 +
+ 会话窗口 + + + + +
共享 + - {{ account.groupInfo.name }} + {{ group.name }}
不可调度 + + +
-
+
{{ account.usage.daily.requests || 0 }} 次
-
+
{{ formatNumber(account.usage.daily.allTokens || 0) }} tokens{{ formatNumber(account.usage.daily.allTokens || 0) }}M +
+
+
+ ${{ calculateDailyCost(account) }}
+ +
+
+
+ + {{ formatNumber(account.usage.sessionWindow.totalTokens) }}M + +
+
+
+ + ${{ formatCost(account.usage.sessionWindow.totalCost) }} + +
+
+ +
-
+
@@ -487,7 +558,9 @@ {{ account.sessionWindow.progress }}%
-
+ + +
{{ formatSessionWindow( @@ -498,7 +571,7 @@
剩余 {{ formatRemainingTime(account.sessionWindow.remainingTime) }}
@@ -646,21 +719,44 @@

今日使用

-

- {{ formatNumber(account.usage?.daily?.requests || 0) }} 次 -

-

- {{ formatNumber(account.usage?.daily?.allTokens || 0) }} tokens -

+
+
+
+

+ {{ account.usage?.daily?.requests || 0 }} 次 +

+
+
+
+

+ {{ formatNumber(account.usage?.daily?.allTokens || 0) }}M +

+
+
+
+

+ ${{ calculateDailyCost(account) }} +

+
+
-

总使用量

-

- {{ formatNumber(account.usage?.total?.requests || 0) }} 次 -

-

- {{ formatNumber(account.usage?.total?.allTokens || 0) }} tokens -

+

会话窗口

+
+
+
+

+ {{ formatNumber(account.usage.sessionWindow.totalTokens) }}M +

+
+
+
+

+ ${{ formatCost(account.usage.sessionWindow.totalCost) }} +

+
+
+
-
@@ -676,14 +772,27 @@ class="space-y-1.5 rounded-lg bg-gray-50 p-2 dark:bg-gray-700" >
- 会话窗口 +
+ 会话窗口 + + + +
{{ account.sessionWindow.progress }}%
@@ -824,7 +933,7 @@ const platformFilter = ref('all') const apiKeysLoaded = ref(false) const groupsLoaded = ref(false) const groupMembersLoaded = ref(false) -const accountGroupMap = ref(new Map()) +const accountGroupMap = ref(new Map()) // Map> // 下拉选项数据 const sortOptions = ref([ @@ -945,7 +1054,9 @@ const loadAccounts = async (forceReload = false) => { apiClient.get('/admin/claude-accounts', { params }), Promise.resolve({ success: true, data: [] }), // claude-console 占位 Promise.resolve({ success: true, data: [] }), // bedrock 占位 - Promise.resolve({ success: true, data: [] }) // gemini 占位 + Promise.resolve({ success: true, data: [] }), // gemini 占位 + Promise.resolve({ success: true, data: [] }), // openai 占位 + Promise.resolve({ success: true, data: [] }) // azure-openai 占位 ) break case 'claude-console': @@ -953,7 +1064,9 @@ const loadAccounts = async (forceReload = false) => { Promise.resolve({ success: true, data: [] }), // claude 占位 apiClient.get('/admin/claude-console-accounts', { params }), Promise.resolve({ success: true, data: [] }), // bedrock 占位 - Promise.resolve({ success: true, data: [] }) // gemini 占位 + Promise.resolve({ success: true, data: [] }), // gemini 占位 + Promise.resolve({ success: true, data: [] }), // openai 占位 + Promise.resolve({ success: true, data: [] }) // azure-openai 占位 ) break case 'bedrock': @@ -961,7 +1074,9 @@ const loadAccounts = async (forceReload = false) => { Promise.resolve({ success: true, data: [] }), // claude 占位 Promise.resolve({ success: true, data: [] }), // claude-console 占位 apiClient.get('/admin/bedrock-accounts', { params }), - Promise.resolve({ success: true, data: [] }) // gemini 占位 + Promise.resolve({ success: true, data: [] }), // gemini 占位 + Promise.resolve({ success: true, data: [] }), // openai 占位 + Promise.resolve({ success: true, data: [] }) // azure-openai 占位 ) break case 'gemini': @@ -969,7 +1084,29 @@ const loadAccounts = async (forceReload = false) => { Promise.resolve({ success: true, data: [] }), // claude 占位 Promise.resolve({ success: true, data: [] }), // claude-console 占位 Promise.resolve({ success: true, data: [] }), // bedrock 占位 - apiClient.get('/admin/gemini-accounts', { params }) + apiClient.get('/admin/gemini-accounts', { params }), + Promise.resolve({ success: true, data: [] }), // openai 占位 + Promise.resolve({ success: true, data: [] }) // azure-openai 占位 + ) + break + case 'openai': + 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 占位 + apiClient.get('/admin/openai-accounts', { params }), + Promise.resolve({ success: true, data: [] }) // azure-openai 占位 + ) + break + case 'azure_openai': + 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 占位 + apiClient.get('/admin/azure-openai-accounts', { params }) ) break } @@ -978,8 +1115,8 @@ const loadAccounts = async (forceReload = false) => { // 使用缓存机制加载 API Keys 和分组数据 await Promise.all([loadApiKeys(forceReload), loadAccountGroups(forceReload)]) - // 加载分组成员关系(需要在分组数据加载完成后) - await loadGroupMembers(forceReload) + // 后端账户API已经包含分组信息,不需要单独加载分组成员关系 + // await loadGroupMembers(forceReload) const [claudeData, claudeConsoleData, bedrockData, geminiData, openaiData, azureOpenaiData] = await Promise.all(requests) @@ -992,9 +1129,8 @@ const loadAccounts = async (forceReload = false) => { const boundApiKeysCount = apiKeys.value.filter( (key) => key.claudeAccountId === acc.id ).length - // 检查是否属于某个分组 - const groupInfo = accountGroupMap.value.get(acc.id) || null - return { ...acc, platform: 'claude', boundApiKeysCount, groupInfo } + // 后端已经包含了groupInfos,直接使用 + return { ...acc, platform: 'claude', boundApiKeysCount } }) allAccounts.push(...claudeAccounts) } @@ -1002,8 +1138,8 @@ const loadAccounts = async (forceReload = false) => { if (claudeConsoleData.success) { const claudeConsoleAccounts = (claudeConsoleData.data || []).map((acc) => { // Claude Console账户暂时不支持直接绑定 - const groupInfo = accountGroupMap.value.get(acc.id) || null - return { ...acc, platform: 'claude-console', boundApiKeysCount: 0, groupInfo } + // 后端已经包含了groupInfos,直接使用 + return { ...acc, platform: 'claude-console', boundApiKeysCount: 0 } }) allAccounts.push(...claudeConsoleAccounts) } @@ -1011,8 +1147,8 @@ const loadAccounts = async (forceReload = false) => { if (bedrockData.success) { const bedrockAccounts = (bedrockData.data || []).map((acc) => { // Bedrock账户暂时不支持直接绑定 - const groupInfo = accountGroupMap.value.get(acc.id) || null - return { ...acc, platform: 'bedrock', boundApiKeysCount: 0, groupInfo } + // 后端已经包含了groupInfos,直接使用 + return { ...acc, platform: 'bedrock', boundApiKeysCount: 0 } }) allAccounts.push(...bedrockAccounts) } @@ -1023,8 +1159,8 @@ const loadAccounts = async (forceReload = false) => { const boundApiKeysCount = apiKeys.value.filter( (key) => key.geminiAccountId === acc.id ).length - const groupInfo = accountGroupMap.value.get(acc.id) || null - return { ...acc, platform: 'gemini', boundApiKeysCount, groupInfo } + // 后端已经包含了groupInfos,直接使用 + return { ...acc, platform: 'gemini', boundApiKeysCount } }) allAccounts.push(...geminiAccounts) } @@ -1034,8 +1170,8 @@ const loadAccounts = async (forceReload = false) => { const boundApiKeysCount = apiKeys.value.filter( (key) => key.openaiAccountId === acc.id ).length - const groupInfo = accountGroupMap.value.get(acc.id) || null - return { ...acc, platform: 'openai', boundApiKeysCount, groupInfo } + // 后端已经包含了groupInfos,直接使用 + return { ...acc, platform: 'openai', boundApiKeysCount } }) allAccounts.push(...openaiAccounts) } @@ -1076,9 +1212,11 @@ const formatNumber = (num) => { if (num === null || num === undefined) return '0' const number = Number(num) if (number >= 1000000) { - return Math.floor(number / 1000000).toLocaleString() + 'M' + return (number / 1000000).toFixed(2) + } else if (number >= 1000) { + return (number / 1000000).toFixed(4) } - return number.toLocaleString() + return (number / 1000000).toFixed(6) } // 格式化最后使用时间 @@ -1131,36 +1269,6 @@ const loadAccountGroups = async (forceReload = false) => { } } -// 加载分组成员关系(缓存版本) -const loadGroupMembers = async (forceReload = false) => { - if (!forceReload && groupMembersLoaded.value) { - return // 使用缓存数据 - } - - try { - // 重置映射 - accountGroupMap.value.clear() - - // 获取所有分组的成员信息 - for (const group of accountGroups.value) { - try { - const membersResponse = await apiClient.get(`/admin/account-groups/${group.id}/members`) - if (membersResponse.success) { - const members = membersResponse.data || [] - members.forEach((member) => { - accountGroupMap.value.set(member.id, group) - }) - } - } catch (error) { - console.error(`Failed to load members for group ${group.id}:`, error) - } - } - groupMembersLoaded.value = true - } catch (error) { - console.error('Failed to load group members:', error) - } -} - // 清空缓存的函数 const clearCache = () => { apiKeysLoaded.value = false @@ -1452,6 +1560,55 @@ const getClaudeAccountType = (account) => { return 'Claude' } +// 获取停止调度的原因 +const getSchedulableReason = (account) => { + if (account.schedulable !== false) return null + + // Claude Console 账户的错误状态 + if (account.platform === 'claude-console') { + if (account.status === 'unauthorized') { + return 'API Key无效或已过期(401错误)' + } + if (account.overloadStatus === 'overloaded') { + return '服务过载(529错误)' + } + if (account.rateLimitStatus === 'limited') { + return '触发限流(429错误)' + } + if (account.status === 'blocked' && account.errorMessage) { + return account.errorMessage + } + } + + // Claude 官方账户的错误状态 + if (account.platform === 'claude') { + if (account.status === 'unauthorized') { + return '认证失败(401错误)' + } + if (account.status === 'error' && account.errorMessage) { + return account.errorMessage + } + if (account.isRateLimited) { + return '触发限流(429错误)' + } + // 自动停止调度的原因 + if (account.stoppedReason) { + return account.stoppedReason + } + } + + // 通用原因 + if (account.stoppedReason) { + return account.stoppedReason + } + if (account.errorMessage) { + return account.errorMessage + } + + // 默认为手动停止 + return '手动停止调度' +} + // 获取账户状态文本 const getAccountStatusText = (account) => { // 检查是否被封锁 @@ -1537,6 +1694,54 @@ const formatRelativeTime = (dateString) => { return formatLastUsed(dateString) } +// 获取会话窗口进度条的样式类 +const getSessionProgressBarClass = (status) => { + // 根据状态返回不同的颜色类,包含防御性检查 + if (!status) { + // 无状态信息时默认为蓝色 + return 'bg-gradient-to-r from-blue-500 to-indigo-600' + } + + // 转换为小写进行比较,避免大小写问题 + const normalizedStatus = String(status).toLowerCase() + + if (normalizedStatus === 'rejected') { + // 被拒绝 - 红色 + return 'bg-gradient-to-r from-red-500 to-red-600' + } else if (normalizedStatus === 'allowed_warning') { + // 警告状态 - 橙色/黄色 + return 'bg-gradient-to-r from-yellow-500 to-orange-500' + } else { + // 正常状态(allowed 或其他) - 蓝色 + return 'bg-gradient-to-r from-blue-500 to-indigo-600' + } +} + +// 格式化费用显示 +const formatCost = (cost) => { + if (!cost || cost === 0) return '0.0000' + if (cost < 0.0001) return cost.toExponential(2) + if (cost < 0.01) return cost.toFixed(6) + if (cost < 1) return cost.toFixed(4) + return cost.toFixed(2) +} + +// 计算每日费用(估算,基于平均模型价格) +const calculateDailyCost = (account) => { + if (!account.usage || !account.usage.daily) return '0.0000' + + const dailyTokens = account.usage.daily.allTokens || 0 + if (dailyTokens === 0) return '0.0000' + + // 使用平均价格估算(基于Claude 3.5 Sonnet的价格) + // 输入: $3/1M tokens, 输出: $15/1M tokens + // 假设平均比例为 输入:输出 = 3:1 + const avgPricePerMillion = 3 * 0.75 + 15 * 0.25 // 加权平均价格 + const cost = (dailyTokens / 1000000) * avgPricePerMillion + + return formatCost(cost) +} + // 切换调度状态 // const toggleDispatch = async (account) => { // await toggleSchedulable(account) diff --git a/web/admin-spa/src/views/ApiKeysView.vue b/web/admin-spa/src/views/ApiKeysView.vue index 533d8c38..54a5b350 100644 --- a/web/admin-spa/src/views/ApiKeysView.vue +++ b/web/admin-spa/src/views/ApiKeysView.vue @@ -466,7 +466,7 @@
- 费用限额 + 每日费用 ${{ (key.dailyCost || 0).toFixed(2) }} / ${{ key.dailyCostLimit.toFixed(2) @@ -482,9 +482,30 @@
+ +
+
+ Opus周费用 + + ${{ (key.weeklyOpusCost || 0).toFixed(2) }} / ${{ + key.weeklyOpusCostLimit.toFixed(2) + }} + +
+
+
+
+
+ { return 'bg-green-500' } +// 获取 Opus 周费用进度 +const getWeeklyOpusCostProgress = (key) => { + if (!key.weeklyOpusCostLimit || key.weeklyOpusCostLimit === 0) return 0 + const percentage = ((key.weeklyOpusCost || 0) / key.weeklyOpusCostLimit) * 100 + return Math.min(percentage, 100) +} + +// 获取 Opus 周费用进度条颜色 +const getWeeklyOpusCostProgressColor = (key) => { + const progress = getWeeklyOpusCostProgress(key) + if (progress >= 100) return 'bg-red-500' + if (progress >= 80) return 'bg-yellow-500' + return 'bg-green-500' +} + // 显示使用详情 const showUsageDetails = (apiKey) => { selectedApiKeyForDetail.value = apiKey