Merge remote-tracking branch 'f3n9/main' into um-5

This commit is contained in:
Feng Yue
2025-08-31 23:12:46 +08:00
27 changed files with 2515 additions and 271 deletions

View File

@@ -1 +1 @@
1.1.121
1.1.122

View File

@@ -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('账户分组查询结果不正确')

379
scripts/test-multi-group.js Normal file
View File

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

View File

@@ -509,7 +509,8 @@ class Application {
const [expiredKeys, errorAccounts] = await Promise.all([
apiKeyService.cleanupExpiredKeys(),
claudeAccountService.cleanupErrorAccounts()
claudeAccountService.cleanupErrorAccounts(),
claudeAccountService.cleanupTempErrorAccounts() // 新增:清理临时错误账户
])
await redis.cleanup()

View File

@@ -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,9 +247,11 @@ const authenticateApiKey = async (req, res, next) => {
})
}
// 检查Token使用量限制
// 兼容性检查优先使用Token限制历史数据否则使用费用限制
const tokenLimit = parseInt(validation.keyData.tokenLimit)
if (tokenLimit > 0 && currentTokens >= tokenLimit) {
if (tokenLimit > 0) {
// 使用Token限制向后兼容
if (currentTokens >= tokenLimit) {
const resetTime = new Date(windowStart + windowDuration)
const remainingMinutes = Math.ceil((resetTime - now) / 60000)
@@ -256,6 +268,26 @@ const authenticateApiKey = async (req, res, next) => {
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
})
}
}
// 增加请求计数
await redis.getClient().incr(requestCountKey)
@@ -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) => {

View File

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

View File

@@ -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)
// 如果获取统计失败,返回空统计
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 }
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,14 +1948,34 @@ router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => {
`⚠️ Failed to get usage stats for Claude Console account ${account.id}:`,
statsError.message
)
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,14 +2264,32 @@ router.get('/bedrock-accounts', authenticateAdmin, async (req, res) => {
`⚠️ Failed to get usage stats for Bedrock account ${account.id}:`,
statsError.message
)
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,14 +2712,32 @@ router.get('/gemini-accounts', authenticateAdmin, async (req, res) => {
statsError.message
)
// 如果获取统计失败,返回空统计
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)
)
}
}

View File

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

View File

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

View File

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

View File

@@ -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})`

View File

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

View File

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

View File

@@ -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) {
// 检查错误状态
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)
}
}
// 发送错误响应

View File

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

View File

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

View File

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

View File

@@ -32,6 +32,14 @@ const MODEL_PRICING = {
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': {
input: 3.0,
@@ -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
}
}
}
// 否则使用旧的逻辑(向后兼容)

View File

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

View File

@@ -807,6 +807,25 @@
</p>
</div>
<!-- Claude 5小时限制自动停止调度选项 -->
<div v-if="form.platform === 'claude'" class="mt-4">
<label class="flex items-start">
<input
v-model="form.autoStopOnWarning"
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<div class="ml-3">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
5小时使用量接近限制时自动停止调度
</span>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
当系统检测到账户接近5小时使用限制时自动暂停调度该账户。进入新的时间窗口后会自动恢复调度。
</p>
</div>
</label>
</div>
<!-- 所有平台的优先级设置 -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
@@ -1318,6 +1337,25 @@
</p>
</div>
<!-- Claude 5小时限制自动停止调度选项编辑模式 -->
<div v-if="form.platform === 'claude'" class="mt-4">
<label class="flex items-start">
<input
v-model="form.autoStopOnWarning"
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<div class="ml-3">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
5小时使用量接近限制时自动停止调度
</span>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
当系统检测到账户接近5小时使用限制时自动暂停调度该账户。进入新的时间窗口后会自动恢复调度。
</p>
</div>
</label>
</div>
<!-- 所有平台的优先级设置(编辑模式) -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
@@ -1883,6 +1921,7 @@ const form = ref({
description: props.account?.description || '',
accountType: props.account?.accountType || 'shared',
subscriptionType: 'claude_max', // 默认为 Claude Max兼容旧数据
autoStopOnWarning: props.account?.autoStopOnWarning || false, // 5小时限制自动停止调度
groupId: '',
projectId: props.account?.projectId || '',
idToken: '',
@@ -2148,6 +2187,7 @@ const handleOAuthSuccess = async (tokenInfo) => {
// Claude使用claudeAiOauth字段
data.claudeAiOauth = tokenInfo.claudeAiOauth || tokenInfo
data.priority = form.value.priority || 50
data.autoStopOnWarning = form.value.autoStopOnWarning || false
// 添加订阅类型信息
data.subscriptionInfo = {
accountType: form.value.subscriptionType || 'claude_max',
@@ -2299,6 +2339,7 @@ const createAccount = async () => {
scopes: [] // 手动添加没有 scopes
}
data.priority = form.value.priority || 50
data.autoStopOnWarning = form.value.autoStopOnWarning || false
// 添加订阅类型信息
data.subscriptionInfo = {
accountType: form.value.subscriptionType || 'claude_max',
@@ -2537,6 +2578,7 @@ const updateAccount = async () => {
// Claude 官方账号优先级和订阅类型更新
if (props.account.platform === 'claude') {
data.priority = form.value.priority || 50
data.autoStopOnWarning = form.value.autoStopOnWarning || false
// 更新订阅类型信息
data.subscriptionInfo = {
accountType: form.value.subscriptionType || 'claude_max',
@@ -2912,6 +2954,7 @@ watch(
description: newAccount.description || '',
accountType: newAccount.accountType || 'shared',
subscriptionType: subscriptionType,
autoStopOnWarning: newAccount.autoStopOnWarning || false,
groupId: groupId,
projectId: newAccount.projectId || '',
accessToken: '',

View File

@@ -252,17 +252,17 @@
<div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
>Token 限制</label
>费用限制 (美元)</label
>
<input
v-model="form.tokenLimit"
v-model="form.rateLimitCost"
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="无限制"
step="0.01"
type="number"
/>
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
窗口内最大Token
</p>
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大费用</p>
</div>
</div>
@@ -275,12 +275,9 @@
<div>
<strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求
</div>
<div><strong>示例2:</strong> 时间窗口=1费用=0.1 每分钟最多$0.1费用</div>
<div>
<strong>示例2:</strong> 时间窗口=1Token=10000 每分钟最多10,000个Token
</div>
<div>
<strong>示例3:</strong> 窗口=30请求=50Token=100000
每30分钟50次请求且不超10万Token
<strong>示例3:</strong> 窗口=30请求=50费用=5 每30分钟50次请求且不超$5费用
</div>
</div>
</div>
@@ -336,6 +333,55 @@
</div>
</div>
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>Opus 模型周费用限制 (美元)</label
>
<div class="space-y-2">
<div class="flex gap-2">
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '100'"
>
$100
</button>
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '500'"
>
$500
</button>
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '1000'"
>
$1000
</button>
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = ''"
>
自定义
</button>
</div>
<input
v-model="form.weeklyOpusCostLimit"
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="0 表示无限制"
step="0.01"
type="number"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
设置 Opus 模型的周费用限制周一到周日仅限 Claude 官方账户0 或留空表示无限制
</p>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>并发限制 (可选)</label
@@ -739,11 +785,12 @@ const form = reactive({
batchCount: 10,
name: '',
description: '',
tokenLimit: '',
rateLimitWindow: '',
rateLimitRequests: '',
rateLimitCost: '', // 新增:费用限制
concurrencyLimit: '',
dailyCostLimit: '',
weeklyOpusCostLimit: '',
expireDuration: '',
customExpireDate: '',
expiresAt: null,
@@ -985,14 +1032,32 @@ const createApiKey = 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 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,

View File

@@ -166,17 +166,17 @@
<div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
>Token 限制</label
>费用限制 (美元)</label
>
<input
v-model="form.tokenLimit"
v-model="form.rateLimitCost"
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="无限制"
step="0.01"
type="number"
/>
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
窗口内最大Token
</p>
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大费用</p>
</div>
</div>
@@ -189,12 +189,9 @@
<div>
<strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求
</div>
<div><strong>示例2:</strong> 时间窗口=1费用=0.1 每分钟最多$0.1费用</div>
<div>
<strong>示例2:</strong> 时间窗口=1Token=10000 每分钟最多10,000个Token
</div>
<div>
<strong>示例3:</strong> 窗口=30请求=50Token=100000
每30分钟50次请求且不超10万Token
<strong>示例3:</strong> 窗口=30请求=50费用=5 每30分钟50次请求且不超$5费用
</div>
</div>
</div>
@@ -250,6 +247,55 @@
</div>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>Opus 模型周费用限制 (美元)</label
>
<div class="space-y-3">
<div class="flex gap-2">
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '100'"
>
$100
</button>
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '500'"
>
$500
</button>
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '1000'"
>
$1000
</button>
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = ''"
>
自定义
</button>
</div>
<input
v-model="form.weeklyOpusCostLimit"
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="0 表示无限制"
step="0.01"
type="number"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
设置 Opus 模型的周费用限制周一到周日仅限 Claude 官方账户0 或留空表示无限制
</p>
</div>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>并发限制</label
@@ -632,11 +678,13 @@ const unselectedTags = computed(() => {
// 表单数据
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) {

View File

@@ -196,6 +196,8 @@
时间窗口限制
</h5>
<WindowCountdown
:cost-limit="apiKey.rateLimitCost"
:current-cost="apiKey.currentWindowCost"
:current-requests="apiKey.currentWindowRequests"
:current-tokens="apiKey.currentWindowTokens"
label="窗口状态"

View File

@@ -33,6 +33,7 @@
</div>
</div>
<!-- Token限制向后兼容 -->
<div v-if="hasTokenLimit" class="space-y-0.5">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-400">Token</span>
@@ -48,6 +49,23 @@
/>
</div>
</div>
<!-- 费用限制新功能 -->
<div v-if="hasCostLimit" class="space-y-0.5">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-400">费用</span>
<span class="text-gray-600">
${{ (currentCost || 0).toFixed(2) }}/${{ costLimit.toFixed(2) }}
</span>
</div>
<div class="h-1 w-full rounded-full bg-gray-200">
<div
class="h-1 rounded-full transition-all duration-300"
:class="getCostProgressColor()"
:style="{ width: getCostProgress() + '%' }"
/>
</div>
</div>
</div>
<!-- 额外提示信息 -->
@@ -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) {

View File

@@ -45,10 +45,14 @@
<div
v-if="
statsData.limits.rateLimitWindow > 0 &&
(statsData.limits.rateLimitRequests > 0 || statsData.limits.tokenLimit > 0)
(statsData.limits.rateLimitRequests > 0 ||
statsData.limits.tokenLimit > 0 ||
statsData.limits.rateLimitCost > 0)
"
>
<WindowCountdown
:cost-limit="statsData.limits.rateLimitCost"
:current-cost="statsData.limits.currentWindowCost"
:current-requests="statsData.limits.currentWindowRequests"
:current-tokens="statsData.limits.currentWindowTokens"
label="时间窗口限制"
@@ -64,7 +68,13 @@
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1" />
<span v-if="statsData.limits.rateLimitCost > 0">
请求次数和费用限制为"或"的关系,任一达到限制即触发限流
</span>
<span v-else-if="statsData.limits.tokenLimit > 0">
请求次数和Token使用量为"或"的关系,任一达到限制即触发限流
</span>
<span v-else> 仅限制请求次数 </span>
</div>
</div>

View File

@@ -191,7 +191,39 @@
<th
class="w-[10%] min-w-[100px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
会话窗口
<div class="flex items-center gap-2">
<span>会话窗口</span>
<el-tooltip placement="top">
<template #content>
<div class="space-y-2">
<div>会话窗口进度表示5小时窗口的时间进度</div>
<div class="space-y-1 text-xs">
<div class="flex items-center gap-2">
<div
class="h-2 w-16 rounded bg-gradient-to-r from-blue-500 to-indigo-600"
></div>
<span>正常请求正常处理</span>
</div>
<div class="flex items-center gap-2">
<div
class="h-2 w-16 rounded bg-gradient-to-r from-yellow-500 to-orange-500"
></div>
<span>警告接近限制</span>
</div>
<div class="flex items-center gap-2">
<div
class="h-2 w-16 rounded bg-gradient-to-r from-red-500 to-red-600"
></div>
<span>拒绝达到速率限制</span>
</div>
</div>
</div>
</template>
<i
class="fas fa-question-circle cursor-help text-xs text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-400"
/>
</el-tooltip>
</div>
</th>
<th
class="w-[8%] min-w-[80px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
@@ -240,12 +272,14 @@
>
<i class="fas fa-share-alt mr-1" />共享
</span>
<!-- 显示所有分组 -->
<span
v-if="account.groupInfo"
v-for="group in account.groupInfos"
:key="group.id"
class="ml-1 inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-400"
:title="`所属分组: ${account.groupInfo.name}`"
:title="`所属分组: ${group.name}`"
>
<i class="fas fa-folder mr-1" />{{ account.groupInfo.name }}
<i class="fas fa-folder mr-1" />{{ group.name }}
</span>
</div>
<div
@@ -393,6 +427,14 @@
>
<i class="fas fa-pause-circle mr-1" />
不可调度
<el-tooltip
v-if="getSchedulableReason(account)"
:content="getSchedulableReason(account)"
effect="dark"
placement="top"
>
<i class="fas fa-question-circle ml-1 cursor-help text-gray-500" />
</el-tooltip>
</span>
<span
v-if="account.status === 'blocked' && account.errorMessage"
@@ -447,15 +489,21 @@
<td class="whitespace-nowrap px-3 py-4 text-sm">
<div v-if="account.usage && account.usage.daily" class="space-y-1">
<div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-green-500" />
<div class="h-2 w-2 rounded-full bg-blue-500" />
<span class="text-sm font-medium text-gray-900 dark:text-gray-100"
>{{ account.usage.daily.requests || 0 }} 次</span
>
</div>
<div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-blue-500" />
<div class="h-2 w-2 rounded-full bg-purple-500" />
<span class="text-xs text-gray-600 dark:text-gray-300"
>{{ formatNumber(account.usage.daily.allTokens || 0) }} tokens</span
>{{ formatNumber(account.usage.daily.allTokens || 0) }}M</span
>
</div>
<div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-green-500" />
<span class="text-xs text-gray-600 dark:text-gray-300"
>${{ calculateDailyCost(account) }}</span
>
</div>
<div
@@ -476,10 +524,33 @@
"
class="space-y-2"
>
<div class="flex items-center gap-2">
<div class="h-2 w-24 rounded-full bg-gray-200">
<!-- 使用统计在顶部 -->
<div
class="h-2 rounded-full bg-gradient-to-r from-blue-500 to-indigo-600 transition-all duration-300"
v-if="account.usage && account.usage.sessionWindow"
class="flex items-center gap-3 text-xs"
>
<div class="flex items-center gap-1">
<div class="h-1.5 w-1.5 rounded-full bg-purple-500" />
<span class="font-medium text-gray-900 dark:text-gray-100">
{{ formatNumber(account.usage.sessionWindow.totalTokens) }}M
</span>
</div>
<div class="flex items-center gap-1">
<div class="h-1.5 w-1.5 rounded-full bg-green-500" />
<span class="font-medium text-gray-900 dark:text-gray-100">
${{ formatCost(account.usage.sessionWindow.totalCost) }}
</span>
</div>
</div>
<!-- 进度条 -->
<div class="flex items-center gap-2">
<div class="h-2 w-24 rounded-full bg-gray-200 dark:bg-gray-700">
<div
:class="[
'h-2 rounded-full transition-all duration-300',
getSessionProgressBarClass(account.sessionWindow.sessionWindowStatus)
]"
:style="{ width: account.sessionWindow.progress + '%' }"
/>
</div>
@@ -487,7 +558,9 @@
{{ account.sessionWindow.progress }}%
</span>
</div>
<div class="text-xs text-gray-600 dark:text-gray-300">
<!-- 时间信息 -->
<div class="text-xs text-gray-600 dark:text-gray-400">
<div>
{{
formatSessionWindow(
@@ -498,7 +571,7 @@
</div>
<div
v-if="account.sessionWindow.remainingTime > 0"
class="font-medium text-indigo-600"
class="font-medium text-indigo-600 dark:text-indigo-400"
>
剩余 {{ formatRemainingTime(account.sessionWindow.remainingTime) }}
</div>
@@ -646,23 +719,46 @@
<div class="mb-3 grid grid-cols-2 gap-3">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">今日使用</p>
<div class="space-y-1">
<div class="flex items-center gap-1.5">
<div class="h-1.5 w-1.5 rounded-full bg-blue-500" />
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formatNumber(account.usage?.daily?.requests || 0) }} 次
</p>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ formatNumber(account.usage?.daily?.allTokens || 0) }} tokens
{{ account.usage?.daily?.requests || 0 }} 次
</p>
</div>
<div class="flex items-center gap-1.5">
<div class="h-1.5 w-1.5 rounded-full bg-purple-500" />
<p class="text-xs text-gray-600 dark:text-gray-400">
{{ formatNumber(account.usage?.daily?.allTokens || 0) }}M
</p>
</div>
<div class="flex items-center gap-1.5">
<div class="h-1.5 w-1.5 rounded-full bg-green-500" />
<p class="text-xs text-gray-600 dark:text-gray-400">
${{ calculateDailyCost(account) }}
</p>
</div>
</div>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">总使用量</p>
<p class="text-xs text-gray-500 dark:text-gray-400">会话窗口</p>
<div v-if="account.usage && account.usage.sessionWindow" class="space-y-1">
<div class="flex items-center gap-1.5">
<div class="h-1.5 w-1.5 rounded-full bg-purple-500" />
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formatNumber(account.usage?.total?.requests || 0) }}
{{ formatNumber(account.usage.sessionWindow.totalTokens) }}M
</p>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ formatNumber(account.usage?.total?.allTokens || 0) }} tokens
</div>
<div class="flex items-center gap-1.5">
<div class="h-1.5 w-1.5 rounded-full bg-green-500" />
<p class="text-xs text-gray-600 dark:text-gray-400">
${{ formatCost(account.usage.sessionWindow.totalCost) }}
</p>
</div>
</div>
<div v-else class="text-sm font-semibold text-gray-400">-</div>
</div>
</div>
<!-- 状态信息 -->
<div class="mb-3 space-y-2">
@@ -676,14 +772,27 @@
class="space-y-1.5 rounded-lg bg-gray-50 p-2 dark:bg-gray-700"
>
<div class="flex items-center justify-between text-xs">
<div class="flex items-center gap-1">
<span class="font-medium text-gray-600 dark:text-gray-300">会话窗口</span>
<el-tooltip
content="会话窗口进度不代表使用量仅表示距离下一个5小时窗口的剩余时间"
placement="top"
>
<i
class="fas fa-question-circle cursor-help text-xs text-gray-400 hover:text-gray-600"
/>
</el-tooltip>
</div>
<span class="font-medium text-gray-700 dark:text-gray-200">
{{ account.sessionWindow.progress }}%
</span>
</div>
<div class="h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-600">
<div
class="h-full bg-gradient-to-r from-blue-500 to-indigo-600 transition-all duration-300"
:class="[
'h-full transition-all duration-300',
getSessionProgressBarClass(account.sessionWindow.sessionWindowStatus)
]"
:style="{ width: account.sessionWindow.progress + '%' }"
/>
</div>
@@ -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<accountId, Array<groupInfo>>
// 下拉选项数据
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)

View File

@@ -466,7 +466,7 @@
<!-- 每日费用限制进度条 -->
<div v-if="key.dailyCostLimit > 0" class="space-y-1">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500 dark:text-gray-400">费用限额</span>
<span class="text-gray-500 dark:text-gray-400">每日费用</span>
<span class="text-gray-700 dark:text-gray-300">
${{ (key.dailyCost || 0).toFixed(2) }} / ${{
key.dailyCostLimit.toFixed(2)
@@ -482,9 +482,30 @@
</div>
</div>
<!-- Opus 周费用限制进度条 -->
<div v-if="key.weeklyOpusCostLimit > 0" class="space-y-1">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500 dark:text-gray-400">Opus周费用</span>
<span class="text-gray-700 dark:text-gray-300">
${{ (key.weeklyOpusCost || 0).toFixed(2) }} / ${{
key.weeklyOpusCostLimit.toFixed(2)
}}
</span>
</div>
<div class="h-1.5 w-full rounded-full bg-gray-200">
<div
class="h-1.5 rounded-full transition-all duration-300"
:class="getWeeklyOpusCostProgressColor(key)"
:style="{ width: getWeeklyOpusCostProgress(key) + '%' }"
/>
</div>
</div>
<!-- 时间窗口限制进度条 -->
<WindowCountdown
v-if="key.rateLimitWindow > 0"
:cost-limit="key.rateLimitCost"
:current-cost="key.currentWindowCost"
:current-requests="key.currentWindowRequests"
:current-tokens="key.currentWindowTokens"
:rate-limit-window="key.rateLimitWindow"
@@ -1059,8 +1080,11 @@
<!-- 移动端时间窗口限制 -->
<WindowCountdown
v-if="
key.rateLimitWindow > 0 && (key.rateLimitRequests > 0 || key.tokenLimit > 0)
key.rateLimitWindow > 0 &&
(key.rateLimitRequests > 0 || key.tokenLimit > 0 || key.rateLimitCost > 0)
"
:cost-limit="key.rateLimitCost"
:current-cost="key.currentWindowCost"
:current-requests="key.currentWindowRequests"
:current-tokens="key.currentWindowTokens"
:rate-limit-window="key.rateLimitWindow"
@@ -2480,6 +2504,21 @@ const getDailyCostProgressColor = (key) => {
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