From e84c6a5555797a2e7ecb260543918a76047d727d Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 31 Aug 2025 17:27:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=9F=BA=E4=BA=8E?= =?UTF-8?q?=E8=B4=B9=E7=94=A8=E7=9A=84=E9=80=9F=E7=8E=87=E9=99=90=E5=88=B6?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 rateLimitCost 字段,支持按费用进行速率限制 - 新增 weeklyOpusCostLimit 字段,支持 Opus 模型周费用限制 - 优化速率限制逻辑,支持费用、请求数、token多维度控制 - 更新前端界面,添加费用限制配置选项 - 增强账户管理功能,支持费用统计和限制 - 改进 Redis 数据模型,支持费用计数器 - 优化价格计算服务,支持更精确的成本核算 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/middleware/auth.js | 172 ++++++++--- src/models/redis.js | 213 ++++++++++++- src/routes/admin.js | 93 +++++- src/routes/api.js | 84 ++++- src/routes/apiStats.js | 8 +- src/services/apiKeyService.js | 78 ++++- src/services/claudeAccountService.js | 123 +++++++- src/services/claudeConsoleAccountService.js | 138 +++++++++ src/services/claudeConsoleRelayService.js | 41 ++- src/services/claudeRelayService.js | 53 +++- src/services/pricingService.js | 96 +++++- src/services/unifiedClaudeScheduler.js | 24 +- src/utils/costCalculator.js | 54 +++- .../src/components/accounts/AccountForm.vue | 43 +++ .../components/apikeys/CreateApiKeyModal.vue | 99 +++++- .../components/apikeys/EditApiKeyModal.vue | 111 ++++++- .../components/apikeys/UsageDetailModal.vue | 2 + .../components/apikeys/WindowCountdown.vue | 40 +++ .../src/components/apistats/LimitConfig.vue | 14 +- web/admin-spa/src/views/AccountsView.vue | 292 ++++++++++++++++-- web/admin-spa/src/views/ApiKeysView.vue | 45 ++- 21 files changed, 1662 insertions(+), 161 deletions(-) diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 38c43485..89369c41 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -1,7 +1,7 @@ const apiKeyService = require('../services/apiKeyService') 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验证中间件(优化版) @@ -182,11 +182,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 // 转换为毫秒 @@ -199,6 +206,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) @@ -209,6 +217,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 } } @@ -216,6 +225,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) { @@ -236,24 +246,46 @@ const authenticateApiKey = async (req, res, next) => { }) } - // 检查Token使用量限制 + // 兼容性检查:优先使用Token限制(历史数据),否则使用费用限制 const tokenLimit = parseInt(validation.keyData.tokenLimit) - if (tokenLimit > 0 && currentTokens >= tokenLimit) { - const resetTime = new Date(windowStart + windowDuration) - const remainingMinutes = Math.ceil((resetTime - now) / 60000) + if (tokenLimit > 0) { + // 使用Token限制(向后兼容) + if (currentTokens >= tokenLimit) { + const resetTime = new Date(windowStart + windowDuration) + const remainingMinutes = Math.ceil((resetTime - now) / 60000) - logger.security( - `🚦 Rate limit exceeded (tokens) for key: ${validation.keyData.id} (${validation.keyData.name}), tokens: ${currentTokens}/${tokenLimit}` - ) + logger.security( + `🚦 Rate limit exceeded (tokens) for key: ${validation.keyData.id} (${validation.keyData.name}), tokens: ${currentTokens}/${tokenLimit}` + ) - return res.status(429).json({ - error: 'Rate limit exceeded', - message: `已达到 Token 使用限制 (${tokenLimit} tokens),将在 ${remainingMinutes} 分钟后重置`, - currentTokens, - tokenLimit, - resetAt: resetTime.toISOString(), - remainingMinutes - }) + return res.status(429).json({ + error: 'Rate limit exceeded', + message: `已达到 Token 使用限制 (${tokenLimit} tokens),将在 ${remainingMinutes} 分钟后重置`, + currentTokens, + tokenLimit, + resetAt: resetTime.toISOString(), + remainingMinutes + }) + } + } else if (rateLimitCost > 0) { + // 使用费用限制(新功能) + if (currentCost >= rateLimitCost) { + const resetTime = new Date(windowStart + windowDuration) + const remainingMinutes = Math.ceil((resetTime - now) / 60000) + + logger.security( + `💰 Rate limit exceeded (cost) for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${currentCost.toFixed(2)}/$${rateLimitCost}` + ) + + return res.status(429).json({ + error: 'Rate limit exceeded', + message: `已达到费用限制 ($${rateLimitCost}),将在 ${remainingMinutes} 分钟后重置`, + currentCost, + costLimit: rateLimitCost, + resetAt: resetTime.toISOString(), + remainingMinutes + }) + } } // 增加请求计数 @@ -265,10 +297,13 @@ const authenticateApiKey = async (req, res, next) => { windowDuration, requestCountKey, tokenCountKey, + costCountKey, // 新增:费用计数器 currentRequests: currentRequests + 1, currentTokens, + currentCost, // 新增:当前费用 rateLimitRequests, - tokenLimit + tokenLimit, + rateLimitCost // 新增:费用限制 } } @@ -297,6 +332,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, @@ -311,6 +386,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, @@ -713,35 +789,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() @@ -777,7 +859,7 @@ const globalRateLimit = async (req, res, next) => { retryAfter: Math.round(msBeforeNext / 1000) }) } -} + */ // 📊 请求大小限制中间件 const requestSizeLimit = (req, res, next) => { diff --git a/src/models/redis.js b/src/models/redis.js index 4d62bda7..db8fbf6d 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -29,6 +29,25 @@ function getHourInTimezone(date = new Date()) { return tzDate.getUTCHours() } +// 获取配置时区的 ISO 周(YYYY-Wxx 格式,周一到周日) +function getWeekStringInTimezone(date = new Date()) { + const tzDate = getDateInTimezone(date) + + // 获取年份 + const year = tzDate.getUTCFullYear() + + // 计算 ISO 周数(周一为第一天) + const dateObj = new Date(tzDate) + const dayOfWeek = dateObj.getUTCDay() || 7 // 将周日(0)转换为7 + const firstThursday = new Date(dateObj) + firstThursday.setUTCDate(dateObj.getUTCDate() + 4 - dayOfWeek) // 找到这周的周四 + + const yearStart = new Date(firstThursday.getUTCFullYear(), 0, 1) + const weekNumber = Math.ceil(((firstThursday - yearStart) / 86400000 + 1) / 7) + + return `${year}-W${String(weekNumber).padStart(2, '0')}` +} + class RedisClient { constructor() { this.client = null @@ -193,7 +212,8 @@ class RedisClient { cacheReadTokens = 0, model = 'unknown', ephemeral5mTokens = 0, // 新增:5分钟缓存 tokens - ephemeral1hTokens = 0 // 新增:1小时缓存 tokens + ephemeral1hTokens = 0, // 新增:1小时缓存 tokens + isLongContextRequest = false // 新增:是否为 1M 上下文请求(超过200k) ) { const key = `usage:${keyId}` const now = new Date() @@ -250,6 +270,12 @@ class RedisClient { // 详细缓存类型统计(新增) pipeline.hincrby(key, 'totalEphemeral5mTokens', ephemeral5mTokens) pipeline.hincrby(key, 'totalEphemeral1hTokens', ephemeral1hTokens) + // 1M 上下文请求统计(新增) + if (isLongContextRequest) { + pipeline.hincrby(key, 'totalLongContextInputTokens', finalInputTokens) + pipeline.hincrby(key, 'totalLongContextOutputTokens', finalOutputTokens) + pipeline.hincrby(key, 'totalLongContextRequests', 1) + } // 请求计数 pipeline.hincrby(key, 'totalRequests', 1) @@ -264,6 +290,12 @@ class RedisClient { // 详细缓存类型统计 pipeline.hincrby(daily, 'ephemeral5mTokens', ephemeral5mTokens) pipeline.hincrby(daily, 'ephemeral1hTokens', ephemeral1hTokens) + // 1M 上下文请求统计 + if (isLongContextRequest) { + pipeline.hincrby(daily, 'longContextInputTokens', finalInputTokens) + pipeline.hincrby(daily, 'longContextOutputTokens', finalOutputTokens) + pipeline.hincrby(daily, 'longContextRequests', 1) + } // 每月统计 pipeline.hincrby(monthly, 'tokens', coreTokens) @@ -376,7 +408,8 @@ class RedisClient { outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, - model = 'unknown' + model = 'unknown', + isLongContextRequest = false ) { const now = new Date() const today = getDateStringInTimezone(now) @@ -407,7 +440,8 @@ class RedisClient { finalInputTokens + finalOutputTokens + finalCacheCreateTokens + finalCacheReadTokens const coreTokens = finalInputTokens + finalOutputTokens - await Promise.all([ + // 构建统计操作数组 + const operations = [ // 账户总体统计 this.client.hincrby(accountKey, 'totalTokens', coreTokens), this.client.hincrby(accountKey, 'totalInputTokens', finalInputTokens), @@ -475,7 +509,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 +680,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}` @@ -1311,6 +1392,129 @@ class RedisClient { return 0 } } + + // 📊 获取账户会话窗口内的使用统计(包含模型细分) + 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) + + // 获取窗口内所有可能的小时键 + const hourlyKeys = [] + const currentHour = new Date(startDate) + currentHour.setMinutes(0) + currentHour.setSeconds(0) + currentHour.setMilliseconds(0) + + while (currentHour <= endDate) { + const dateStr = `${currentHour.getUTCFullYear()}-${String(currentHour.getUTCMonth() + 1).padStart(2, '0')}-${String(currentHour.getUTCDate()).padStart(2, '0')}` + const hourStr = String(currentHour.getUTCHours()).padStart(2, '0') + const key = `account_usage:hourly:${accountId}:${dateStr}:${hourStr}` + 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 = {} + + for (const [error, data] of results) { + if (error || !data || Object.keys(data).length === 0) { + continue + } + + // 处理总计数据 + totalInputTokens += parseInt(data.totalInputTokens || 0) + totalOutputTokens += parseInt(data.totalOutputTokens || 0) + totalCacheCreateTokens += parseInt(data.totalCacheCreateTokens || 0) + totalCacheReadTokens += parseInt(data.totalCacheReadTokens || 0) + totalAllTokens += parseInt(data.totalAllTokens || 0) + totalRequests += parseInt(data.totalRequests || 0) + + // 处理每个模型的数据 + 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) + } + } + } + } + } + + 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() @@ -1319,5 +1523,6 @@ const redisClient = new RedisClient() redisClient.getDateInTimezone = getDateInTimezone redisClient.getDateStringInTimezone = getDateStringInTimezone redisClient.getHourInTimezone = getHourInTimezone +redisClient.getWeekStringInTimezone = getWeekStringInTimezone module.exports = redisClient diff --git a/src/routes/admin.js b/src/routes/admin.js index 368d0a33..1436a6c0 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -397,11 +397,13 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { concurrencyLimit, rateLimitWindow, rateLimitRequests, + rateLimitCost, enableModelRestriction, restrictedModels, enableClientRestriction, allowedClients, dailyCostLimit, + weeklyOpusCostLimit, tags } = req.body @@ -494,11 +496,13 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { concurrencyLimit, rateLimitWindow, rateLimitRequests, + rateLimitCost, enableModelRestriction, restrictedModels, enableClientRestriction, allowedClients, dailyCostLimit, + weeklyOpusCostLimit, tags }) @@ -532,6 +536,7 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { enableClientRestriction, allowedClients, dailyCostLimit, + weeklyOpusCostLimit, tags } = req.body @@ -575,6 +580,7 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { enableClientRestriction, allowedClients, dailyCostLimit, + weeklyOpusCostLimit, tags }) @@ -685,6 +691,9 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => { if (updates.dailyCostLimit !== undefined) { finalUpdates.dailyCostLimit = updates.dailyCostLimit } + if (updates.weeklyOpusCostLimit !== undefined) { + finalUpdates.weeklyOpusCostLimit = updates.weeklyOpusCostLimit + } if (updates.permissions !== undefined) { finalUpdates.permissions = updates.permissions } @@ -795,6 +804,7 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { concurrencyLimit, rateLimitWindow, rateLimitRequests, + rateLimitCost, isActive, claudeAccountId, claudeConsoleAccountId, @@ -808,6 +818,7 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { allowedClients, expiresAt, dailyCostLimit, + weeklyOpusCostLimit, tags } = req.body @@ -844,6 +855,14 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { updates.rateLimitRequests = Number(rateLimitRequests) } + if (rateLimitCost !== undefined && rateLimitCost !== null && rateLimitCost !== '') { + const cost = Number(rateLimitCost) + if (isNaN(cost) || cost < 0) { + return res.status(400).json({ error: 'Rate limit cost must be a non-negative number' }) + } + updates.rateLimitCost = cost + } + if (claudeAccountId !== undefined) { // 空字符串表示解绑,null或空字符串都设置为空字符串 updates.claudeAccountId = claudeAccountId || '' @@ -935,6 +954,22 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { updates.dailyCostLimit = costLimit } + // 处理 Opus 周费用限制 + if ( + weeklyOpusCostLimit !== undefined && + weeklyOpusCostLimit !== null && + weeklyOpusCostLimit !== '' + ) { + const costLimit = Number(weeklyOpusCostLimit) + // 明确验证非负数(0 表示禁用,负数无意义) + if (isNaN(costLimit) || costLimit < 0) { + return res + .status(400) + .json({ error: 'Weekly Opus cost limit must be a non-negative number' }) + } + updates.weeklyOpusCostLimit = costLimit + } + // 处理标签 if (tags !== undefined) { if (!Array.isArray(tags)) { @@ -1468,13 +1503,53 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => { 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 + } + + const costResult = CostCalculator.calculateCost(usageData, modelName) + 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) { @@ -1488,7 +1563,8 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => { 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) { @@ -1502,7 +1578,8 @@ router.get('/claude-accounts', authenticateAdmin, async (req, res) => { 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 } } } @@ -1531,7 +1608,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => { accountType, platform = 'claude', priority, - groupId + groupId, + autoStopOnWarning } = req.body if (!name) { @@ -1568,7 +1646,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 }) // 如果是分组类型,将账户添加到分组 @@ -1826,6 +1905,8 @@ router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => { return { ...account, + // 转换schedulable为布尔值 + schedulable: account.schedulable === 'true' || account.schedulable === true, groupInfos, usage: { daily: usageStats.daily, @@ -1842,6 +1923,8 @@ router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => { 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 }, diff --git a/src/routes/api.js b/src/routes/api.js index bad90a41..73b771b6 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -5,6 +5,7 @@ const bedrockRelayService = require('../services/bedrockRelayService') const bedrockAccountService = require('../services/bedrockAccountService') const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler') const apiKeyService = require('../services/apiKeyService') +const pricingService = require('../services/pricingService') const { authenticateApiKey } = require('../middleware/auth') const logger = require('../utils/logger') const redis = require('../models/redis') @@ -131,14 +132,16 @@ async function handleMessagesRequest(req, res) { } apiKeyService - .recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId) + .recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'claude') .catch((error) => { logger.error('❌ Failed to record stream usage:', error) }) - // 更新时间窗口内的token计数 + // 更新时间窗口内的token计数和费用 if (req.rateLimitInfo) { const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + // 更新Token计数(向后兼容) redis .getClient() .incrby(req.rateLimitInfo.tokenCountKey, totalTokens) @@ -146,6 +149,22 @@ async function handleMessagesRequest(req, res) { logger.error('❌ Failed to update rate limit token count:', error) }) logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`) + + // 计算并更新费用计数(新功能) + if (req.rateLimitInfo.costCountKey) { + const costInfo = pricingService.calculateCost(usageData, model) + if (costInfo.totalCost > 0) { + redis + .getClient() + .incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost) + .catch((error) => { + logger.error('❌ Failed to update rate limit cost count:', error) + }) + logger.api( + `💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}` + ) + } + } } usageDataCaptured = true @@ -216,14 +235,22 @@ async function handleMessagesRequest(req, res) { } apiKeyService - .recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId) + .recordUsageWithDetails( + req.apiKey.id, + usageObject, + model, + usageAccountId, + 'claude-console' + ) .catch((error) => { logger.error('❌ Failed to record stream usage:', error) }) - // 更新时间窗口内的token计数 + // 更新时间窗口内的token计数和费用 if (req.rateLimitInfo) { const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + // 更新Token计数(向后兼容) redis .getClient() .incrby(req.rateLimitInfo.tokenCountKey, totalTokens) @@ -231,6 +258,22 @@ async function handleMessagesRequest(req, res) { logger.error('❌ Failed to update rate limit token count:', error) }) logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`) + + // 计算并更新费用计数(新功能) + if (req.rateLimitInfo.costCountKey) { + const costInfo = pricingService.calculateCost(usageData, model) + if (costInfo.totalCost > 0) { + redis + .getClient() + .incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost) + .catch((error) => { + logger.error('❌ Failed to update rate limit cost count:', error) + }) + logger.api( + `💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}` + ) + } + } } usageDataCaptured = true @@ -271,9 +314,11 @@ async function handleMessagesRequest(req, res) { logger.error('❌ Failed to record Bedrock stream usage:', error) }) - // 更新时间窗口内的token计数 + // 更新时间窗口内的token计数和费用 if (req.rateLimitInfo) { const totalTokens = inputTokens + outputTokens + + // 更新Token计数(向后兼容) redis .getClient() .incrby(req.rateLimitInfo.tokenCountKey, totalTokens) @@ -281,6 +326,20 @@ async function handleMessagesRequest(req, res) { logger.error('❌ Failed to update rate limit token count:', error) }) logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`) + + // 计算并更新费用计数(新功能) + if (req.rateLimitInfo.costCountKey) { + const costInfo = pricingService.calculateCost(result.usage, result.model) + if (costInfo.totalCost > 0) { + redis + .getClient() + .incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost) + .catch((error) => { + logger.error('❌ Failed to update rate limit cost count:', error) + }) + logger.api(`💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}`) + } + } } usageDataCaptured = true @@ -438,11 +497,24 @@ async function handleMessagesRequest(req, res) { responseAccountId ) - // 更新时间窗口内的token计数 + // 更新时间窗口内的token计数和费用 if (req.rateLimitInfo) { const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + // 更新Token计数(向后兼容) await redis.getClient().incrby(req.rateLimitInfo.tokenCountKey, totalTokens) logger.api(`📊 Updated rate limit token count: +${totalTokens} tokens`) + + // 计算并更新费用计数(新功能) + if (req.rateLimitInfo.costCountKey) { + const costInfo = pricingService.calculateCost(jsonData.usage, model) + if (costInfo.totalCost > 0) { + await redis + .getClient() + .incrbyfloat(req.rateLimitInfo.costCountKey, costInfo.totalCost) + logger.api(`💰 Updated rate limit cost count: +$${costInfo.totalCost.toFixed(6)}`) + } + } } usageRecorded = true diff --git a/src/routes/apiStats.js b/src/routes/apiStats.js index 2b8eca6f..3233b1f4 100644 --- a/src/routes/apiStats.js +++ b/src/routes/apiStats.js @@ -278,21 +278,24 @@ router.post('/api/user-stats', async (req, res) => { // 获取当前使用量 let currentWindowRequests = 0 let currentWindowTokens = 0 + let currentWindowCost = 0 // 新增:当前窗口费用 let currentDailyCost = 0 let windowStartTime = null let windowEndTime = null let windowRemainingSeconds = null try { - // 获取当前时间窗口的请求次数和Token使用量 + // 获取当前时间窗口的请求次数、Token使用量和费用 if (fullKeyData.rateLimitWindow > 0) { const client = redis.getClientSafe() const requestCountKey = `rate_limit:requests:${keyId}` const tokenCountKey = `rate_limit:tokens:${keyId}` + const costCountKey = `rate_limit:cost:${keyId}` // 新增:费用计数key const windowStartKey = `rate_limit:window_start:${keyId}` currentWindowRequests = parseInt((await client.get(requestCountKey)) || '0') currentWindowTokens = parseInt((await client.get(tokenCountKey)) || '0') + currentWindowCost = parseFloat((await client.get(costCountKey)) || '0') // 新增:获取当前窗口费用 // 获取窗口开始时间和计算剩余时间 const windowStart = await client.get(windowStartKey) @@ -313,6 +316,7 @@ router.post('/api/user-stats', async (req, res) => { // 重置计数为0,因为窗口已过期 currentWindowRequests = 0 currentWindowTokens = 0 + currentWindowCost = 0 // 新增:重置窗口费用 } } } @@ -356,10 +360,12 @@ router.post('/api/user-stats', async (req, res) => { concurrencyLimit: fullKeyData.concurrencyLimit || 0, rateLimitWindow: fullKeyData.rateLimitWindow || 0, rateLimitRequests: fullKeyData.rateLimitRequests || 0, + rateLimitCost: parseFloat(fullKeyData.rateLimitCost) || 0, // 新增:费用限制 dailyCostLimit: fullKeyData.dailyCostLimit || 0, // 当前使用量 currentWindowRequests, currentWindowTokens, + currentWindowCost, // 新增:当前窗口费用 currentDailyCost, // 时间窗口信息 windowStartTime, diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index 46be6352..94e7ae77 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -14,7 +14,7 @@ class ApiKeyService { const { name = 'Unnamed Key', description = '', - tokenLimit = config.limits.defaultTokenLimit, + tokenLimit = 0, // 默认为0,不再使用token限制 expiresAt = null, claudeAccountId = null, claudeConsoleAccountId = null, @@ -27,11 +27,13 @@ class ApiKeyService { concurrencyLimit = 0, rateLimitWindow = null, rateLimitRequests = null, + rateLimitCost = null, // 新增:速率限制费用字段 enableModelRestriction = false, restrictedModels = [], enableClientRestriction = false, allowedClients = [], dailyCostLimit = 0, + weeklyOpusCostLimit = 0, tags = [] } = options @@ -49,6 +51,7 @@ class ApiKeyService { concurrencyLimit: String(concurrencyLimit ?? 0), rateLimitWindow: String(rateLimitWindow ?? 0), rateLimitRequests: String(rateLimitRequests ?? 0), + rateLimitCost: String(rateLimitCost ?? 0), // 新增:速率限制费用字段 isActive: String(isActive), claudeAccountId: claudeAccountId || '', claudeConsoleAccountId: claudeConsoleAccountId || '', @@ -62,6 +65,7 @@ class ApiKeyService { enableClientRestriction: String(enableClientRestriction || false), allowedClients: JSON.stringify(allowedClients || []), dailyCostLimit: String(dailyCostLimit || 0), + weeklyOpusCostLimit: String(weeklyOpusCostLimit || 0), tags: JSON.stringify(tags || []), createdAt: new Date().toISOString(), lastUsedAt: '', @@ -83,6 +87,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, @@ -96,6 +101,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, @@ -184,12 +190,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 } @@ -213,22 +222,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) @@ -251,6 +265,7 @@ class ApiKeyService { // 重置计数为0,因为窗口已过期 key.currentWindowRequests = 0 key.currentWindowTokens = 0 + key.currentWindowCost = 0 // 新增:重置费用 } } else { // 窗口还未开始(没有任何请求) @@ -261,6 +276,7 @@ class ApiKeyService { } else { key.currentWindowRequests = 0 key.currentWindowTokens = 0 + key.currentWindowCost = 0 // 新增:重置费用 key.windowStartTime = null key.windowEndTime = null key.windowRemainingSeconds = null @@ -307,6 +323,7 @@ class ApiKeyService { 'concurrencyLimit', 'rateLimitWindow', 'rateLimitRequests', + 'rateLimitCost', // 新增:速率限制费用字段 'isActive', 'claudeAccountId', 'claudeConsoleAccountId', @@ -321,6 +338,7 @@ class ApiKeyService { 'enableClientRestriction', 'allowedClients', 'dailyCostLimit', + 'weeklyOpusCostLimit', 'tags' ] const updatedData = { ...keyData } @@ -396,6 +414,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, @@ -404,7 +429,10 @@ class ApiKeyService { outputTokens, cacheCreateTokens, cacheReadTokens, - model + model, + 0, // ephemeral5mTokens - 暂时为0,后续处理 + 0, // ephemeral1hTokens - 暂时为0,后续处理 + isLongContextRequest ) // 记录费用统计 @@ -433,7 +461,8 @@ class ApiKeyService { outputTokens, cacheCreateTokens, cacheReadTokens, - model + model, + isLongContextRequest ) logger.database( `📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})` @@ -460,8 +489,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 @@ -505,7 +564,8 @@ class ApiKeyService { cacheReadTokens, model, ephemeral5mTokens, // 传递5分钟缓存 tokens - ephemeral1hTokens // 传递1小时缓存 tokens + ephemeral1hTokens, // 传递1小时缓存 tokens + costInfo.isLongContextRequest || false // 传递 1M 上下文请求标记 ) // 记录费用统计 @@ -515,6 +575,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( @@ -541,7 +604,8 @@ class ApiKeyService { outputTokens, cacheCreateTokens, cacheReadTokens, - model + model, + costInfo.isLongContextRequest || false ) logger.database( `📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})` diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index a4ef8411..17bb3465 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -57,7 +57,8 @@ class ClaudeAccountService { platform = 'claude', priority = 50, // 调度优先级 (1-100,数字越小优先级越高) schedulable = true, // 是否可被调度 - subscriptionInfo = null // 手动设置的订阅信息 + subscriptionInfo = null, // 手动设置的订阅信息 + autoStopOnWarning = false // 5小时使用量接近限制时自动停止调度 } = options const accountId = uuidv4() @@ -88,6 +89,7 @@ class ClaudeAccountService { status: 'active', // 有OAuth数据的账户直接设为active errorMessage: '', schedulable: schedulable.toString(), // 是否可被调度 + autoStopOnWarning: autoStopOnWarning.toString(), // 5小时使用量接近限制时自动停止调度 // 优先使用手动设置的订阅信息,否则使用OAuth数据中的,否则默认为空 subscriptionInfo: subscriptionInfo ? JSON.stringify(subscriptionInfo) @@ -118,6 +120,7 @@ class ClaudeAccountService { status: 'created', // created, active, expired, error errorMessage: '', schedulable: schedulable.toString(), // 是否可被调度 + autoStopOnWarning: autoStopOnWarning.toString(), // 5小时使用量接近限制时自动停止调度 // 手动设置的订阅信息 subscriptionInfo: subscriptionInfo ? JSON.stringify(subscriptionInfo) : '' } @@ -158,7 +161,8 @@ class ClaudeAccountService { status: accountData.status, createdAt: accountData.createdAt, expiresAt: accountData.expiresAt, - scopes: claudeAiOauth ? claudeAiOauth.scopes : [] + scopes: claudeAiOauth ? claudeAiOauth.scopes : [], + autoStopOnWarning } } @@ -479,7 +483,11 @@ class ClaudeAccountService { lastRequestTime: null }, // 添加调度状态 - schedulable: account.schedulable !== 'false' // 默认为true,兼容历史数据 + schedulable: account.schedulable !== 'false', // 默认为true,兼容历史数据 + // 添加自动停止调度设置 + autoStopOnWarning: account.autoStopOnWarning === 'true', // 默认为false + // 添加停止原因 + stoppedReason: account.stoppedReason || null } }) ) @@ -1284,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)` ) @@ -1329,7 +1373,8 @@ class ClaudeAccountService { windowEnd: null, progress: 0, remainingTime: null, - lastRequestTime: accountData.lastRequestTime || null + lastRequestTime: accountData.lastRequestTime || null, + sessionWindowStatus: accountData.sessionWindowStatus || null } } @@ -1346,7 +1391,8 @@ class ClaudeAccountService { windowEnd: accountData.sessionWindowEnd, progress: 100, remainingTime: 0, - lastRequestTime: accountData.lastRequestTime || null + lastRequestTime: accountData.lastRequestTime || null, + sessionWindowStatus: accountData.sessionWindowStatus || null } } @@ -1364,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) @@ -1889,6 +1936,70 @@ class ClaudeAccountService { throw error } } + + // 更新会话窗口状态(allowed, allowed_warning, rejected) + async updateSessionWindowStatus(accountId, status) { + try { + // 参数验证 + if (!accountId || !status) { + logger.warn( + `Invalid parameters for updateSessionWindowStatus: accountId=${accountId}, status=${status}` + ) + return + } + + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + logger.warn(`Account not found: ${accountId}`) + return + } + + // 验证状态值是否有效 + const validStatuses = ['allowed', 'allowed_warning', 'rejected'] + if (!validStatuses.includes(status)) { + logger.warn(`Invalid session window status: ${status} for account ${accountId}`) + return + } + + // 更新会话窗口状态 + accountData.sessionWindowStatus = status + accountData.sessionWindowStatusUpdatedAt = new Date().toISOString() + + // 如果状态是 allowed_warning 且账户设置了自动停止调度 + if (status === 'allowed_warning' && accountData.autoStopOnWarning === 'true') { + logger.warn( + `⚠️ Account ${accountData.name} (${accountId}) approaching 5h limit, auto-stopping scheduling` + ) + accountData.schedulable = 'false' + accountData.stoppedReason = '5小时使用量接近限制,自动停止调度' + accountData.autoStoppedAt = new Date().toISOString() + + // 发送Webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name || 'Claude Account', + platform: 'claude', + status: 'warning', + errorCode: 'CLAUDE_5H_LIMIT_WARNING', + reason: '5小时使用量接近限制,已自动停止调度', + timestamp: new Date().toISOString() + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification:', webhookError) + } + } + + await redis.setClaudeAccount(accountId, accountData) + + logger.info( + `📊 Updated session window status for account ${accountData.name} (${accountId}): ${status}` + ) + } catch (error) { + logger.error(`❌ Failed to update session window status for account ${accountId}:`, error) + } + } } module.exports = new ClaudeAccountService() diff --git a/src/services/claudeConsoleAccountService.js b/src/services/claudeConsoleAccountService.js index c2044895..7bde2c29 100644 --- a/src/services/claudeConsoleAccountService.js +++ b/src/services/claudeConsoleAccountService.js @@ -453,6 +453,144 @@ class ClaudeConsoleAccountService { } } + // 🚫 标记账号为未授权状态(401错误) + async markAccountUnauthorized(accountId) { + try { + const client = redis.getClientSafe() + const account = await this.getAccount(accountId) + + if (!account) { + throw new Error('Account not found') + } + + const updates = { + schedulable: 'false', + status: 'unauthorized', + errorMessage: 'API Key无效或已过期(401错误)', + unauthorizedAt: new Date().toISOString(), + unauthorizedCount: String((parseInt(account.unauthorizedCount || '0') || 0) + 1) + } + + await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) + + // 发送Webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || 'Claude Console Account', + platform: 'claude-console', + status: 'error', + errorCode: 'CLAUDE_CONSOLE_UNAUTHORIZED', + reason: 'API Key无效或已过期(401错误),账户已停止调度', + timestamp: new Date().toISOString() + }) + } catch (webhookError) { + logger.error('Failed to send unauthorized webhook notification:', webhookError) + } + + logger.warn( + `🚫 Claude Console account marked as unauthorized: ${account.name} (${accountId})` + ) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark Claude Console account as unauthorized: ${accountId}`, error) + throw error + } + } + + // 🚫 标记账号为过载状态(529错误) + async markAccountOverloaded(accountId) { + try { + const client = redis.getClientSafe() + const account = await this.getAccount(accountId) + + if (!account) { + throw new Error('Account not found') + } + + const updates = { + overloadedAt: new Date().toISOString(), + overloadStatus: 'overloaded', + errorMessage: '服务过载(529错误)' + } + + await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) + + // 发送Webhook通知 + try { + const webhookNotifier = require('../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || 'Claude Console Account', + platform: 'claude-console', + status: 'error', + errorCode: 'CLAUDE_CONSOLE_OVERLOADED', + reason: '服务过载(529错误)。账户将暂时停止调度', + timestamp: new Date().toISOString() + }) + } catch (webhookError) { + logger.error('Failed to send overload webhook notification:', webhookError) + } + + logger.warn(`🚫 Claude Console account marked as overloaded: ${account.name} (${accountId})`) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark Claude Console account as overloaded: ${accountId}`, error) + throw error + } + } + + // ✅ 移除账号的过载状态 + async removeAccountOverload(accountId) { + try { + const client = redis.getClientSafe() + + await client.hdel(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, 'overloadedAt', 'overloadStatus') + + logger.success(`✅ Overload status removed for Claude Console account: ${accountId}`) + return { success: true } + } catch (error) { + logger.error( + `❌ Failed to remove overload status for Claude Console account: ${accountId}`, + error + ) + throw error + } + } + + // 🔍 检查账号是否处于过载状态 + async isAccountOverloaded(accountId) { + try { + const account = await this.getAccount(accountId) + if (!account) { + return false + } + + if (account.overloadStatus === 'overloaded' && account.overloadedAt) { + const overloadedAt = new Date(account.overloadedAt) + const now = new Date() + const minutesSinceOverload = (now - overloadedAt) / (1000 * 60) + + // 过载状态持续10分钟后自动恢复 + if (minutesSinceOverload >= 10) { + await this.removeAccountOverload(accountId) + return false + } + + return true + } + + return false + } catch (error) { + logger.error( + `❌ Failed to check overload status for Claude Console account: ${accountId}`, + error + ) + return false + } + } + // 🚫 标记账号为封锁状态(模型不支持等原因) async blockAccount(accountId, reason) { try { diff --git a/src/services/claudeConsoleRelayService.js b/src/services/claudeConsoleRelayService.js index dafb7f98..27920a47 100644 --- a/src/services/claudeConsoleRelayService.js +++ b/src/services/claudeConsoleRelayService.js @@ -175,16 +175,26 @@ class ClaudeConsoleRelayService { `[DEBUG] Response data preview: ${typeof response.data === 'string' ? response.data.substring(0, 200) : JSON.stringify(response.data).substring(0, 200)}` ) - // 检查是否为限流错误 - if (response.status === 429) { + // 检查错误状态并相应处理 + if (response.status === 401) { + logger.warn(`🚫 Unauthorized error detected for Claude Console account ${accountId}`) + await claudeConsoleAccountService.markAccountUnauthorized(accountId) + } else if (response.status === 429) { logger.warn(`🚫 Rate limit detected for Claude Console account ${accountId}`) await claudeConsoleAccountService.markAccountRateLimited(accountId) + } else if (response.status === 529) { + logger.warn(`🚫 Overload error detected for Claude Console account ${accountId}`) + await claudeConsoleAccountService.markAccountOverloaded(accountId) } else if (response.status === 200 || response.status === 201) { - // 如果请求成功,检查并移除限流状态 + // 如果请求成功,检查并移除错误状态 const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(accountId) if (isRateLimited) { await claudeConsoleAccountService.removeAccountRateLimit(accountId) } + const isOverloaded = await claudeConsoleAccountService.isAccountOverloaded(accountId) + if (isOverloaded) { + await claudeConsoleAccountService.removeAccountOverload(accountId) + } } // 更新最后使用时间 @@ -363,8 +373,12 @@ class ClaudeConsoleRelayService { if (response.status !== 200) { logger.error(`❌ Claude Console API returned error status: ${response.status}`) - if (response.status === 429) { + if (response.status === 401) { + claudeConsoleAccountService.markAccountUnauthorized(accountId) + } else if (response.status === 429) { claudeConsoleAccountService.markAccountRateLimited(accountId) + } else if (response.status === 529) { + claudeConsoleAccountService.markAccountOverloaded(accountId) } // 设置错误响应的状态码和响应头 @@ -396,12 +410,17 @@ class ClaudeConsoleRelayService { return } - // 成功响应,检查并移除限流状态 + // 成功响应,检查并移除错误状态 claudeConsoleAccountService.isAccountRateLimited(accountId).then((isRateLimited) => { if (isRateLimited) { claudeConsoleAccountService.removeAccountRateLimit(accountId) } }) + claudeConsoleAccountService.isAccountOverloaded(accountId).then((isOverloaded) => { + if (isOverloaded) { + claudeConsoleAccountService.removeAccountOverload(accountId) + } + }) // 设置响应头 if (!responseStream.headersSent) { @@ -564,9 +583,15 @@ class ClaudeConsoleRelayService { logger.error('❌ Claude Console Claude stream request error:', error.message) - // 检查是否是429错误 - if (error.response && error.response.status === 429) { - claudeConsoleAccountService.markAccountRateLimited(accountId) + // 检查错误状态 + if (error.response) { + if (error.response.status === 401) { + claudeConsoleAccountService.markAccountUnauthorized(accountId) + } else if (error.response.status === 429) { + claudeConsoleAccountService.markAccountRateLimited(accountId) + } else if (error.response.status === 529) { + claudeConsoleAccountService.markAccountOverloaded(accountId) + } } // 发送错误响应 diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index 0ca60f1b..57e42438 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -180,15 +180,15 @@ class ClaudeRelayService { // 记录401错误 await this.recordUnauthorizedError(accountId) - // 检查是否需要标记为异常(连续3次401) + // 检查是否需要标记为异常(遇到1次401就停止调度) const errorCount = await this.getUnauthorizedErrorCount(accountId) logger.info( `🔐 Account ${accountId} has ${errorCount} consecutive 401 errors in the last 5 minutes` ) - if (errorCount >= 3) { + if (errorCount >= 1) { logger.error( - `❌ Account ${accountId} exceeded 401 error threshold (${errorCount} errors), marking as unauthorized` + `❌ Account ${accountId} encountered 401 error (${errorCount} errors), marking as unauthorized` ) await unifiedClaudeScheduler.markAccountUnauthorized( accountId, @@ -264,6 +264,27 @@ class ClaudeRelayService { ) } } else if (response.statusCode === 200 || response.statusCode === 201) { + // 提取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) @@ -454,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 } @@ -1189,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) { // 提取限流重置时间戳 diff --git a/src/services/pricingService.js b/src/services/pricingService.js index 5ded4c0a..0084a2ab 100644 --- a/src/services/pricingService.js +++ b/src/services/pricingService.js @@ -55,6 +55,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 模型的价格 + } } // 初始化价格服务 @@ -329,9 +340,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 +382,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 +425,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 +436,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 +449,22 @@ class PricingService { ephemeral1hCost, totalCost: inputCost + outputCost + cacheCreateCost + cacheReadCost, hasPricing: true, + isLongContextRequest, pricing: { - input: pricing.input_cost_per_token || 0, - output: pricing.output_cost_per_token || 0, - cacheCreate: pricing.cache_creation_input_token_cost || 0, - cacheRead: pricing.cache_read_input_token_cost || 0, + input: useLongContextPricing + ? ( + this.longContextPricing[modelName] || + this.longContextPricing[Object.keys(this.longContextPricing)[0]] + )?.input || 0 + : pricing?.input_cost_per_token || 0, + output: useLongContextPricing + ? ( + this.longContextPricing[modelName] || + this.longContextPricing[Object.keys(this.longContextPricing)[0]] + )?.output || 0 + : pricing?.output_cost_per_token || 0, + cacheCreate: pricing?.cache_creation_input_token_cost || 0, + cacheRead: pricing?.cache_read_input_token_cost || 0, ephemeral1h: this.getEphemeral1hPricing(modelName) } } diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index 4e6535bd..c83676a2 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -459,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 } // 检查是否可调度 @@ -467,7 +475,19 @@ class UnifiedClaudeScheduler { logger.info(`🚫 Claude Console account ${accountId} is not schedulable`) return false } - return !(await claudeConsoleAccountService.isAccountRateLimited(accountId)) + // 检查是否被限流 + if (await claudeConsoleAccountService.isAccountRateLimited(accountId)) { + return false + } + // 检查是否未授权(401错误) + if (account.status === 'unauthorized') { + return false + } + // 检查是否过载(529错误) + if (await claudeConsoleAccountService.isAccountOverloaded(accountId)) { + return false + } + return true } else if (accountType === 'bedrock') { const accountResult = await bedrockAccountService.getAccount(accountId) if (!accountResult.success || !accountResult.data.isActive) { diff --git a/src/utils/costCalculator.js b/src/utils/costCalculator.js index a0fe6700..3c3b7c41 100644 --- a/src/utils/costCalculator.js +++ b/src/utils/costCalculator.js @@ -69,9 +69,57 @@ class CostCalculator { * @returns {Object} 费用详情 */ static calculateCost(usage, model = 'unknown') { - // 如果 usage 包含详细的 cache_creation 对象,使用 pricingService 来处理 - if (usage.cache_creation && typeof usage.cache_creation === 'object') { - return pricingService.calculateCost(usage, model) + // 如果 usage 包含详细的 cache_creation 对象或是 1M 模型,使用 pricingService 来处理 + if ( + (usage.cache_creation && typeof usage.cache_creation === 'object') || + (model && model.includes('[1m]')) + ) { + const result = pricingService.calculateCost(usage, model) + // 转换 pricingService 返回的格式到 costCalculator 的格式 + return { + model, + pricing: { + input: result.pricing.input * 1000000, // 转换为 per 1M tokens + output: result.pricing.output * 1000000, + cacheWrite: result.pricing.cacheCreate * 1000000, + cacheRead: result.pricing.cacheRead * 1000000 + }, + usingDynamicPricing: true, + isLongContextRequest: result.isLongContextRequest || false, + usage: { + inputTokens: usage.input_tokens || 0, + outputTokens: usage.output_tokens || 0, + cacheCreateTokens: usage.cache_creation_input_tokens || 0, + cacheReadTokens: usage.cache_read_input_tokens || 0, + totalTokens: + (usage.input_tokens || 0) + + (usage.output_tokens || 0) + + (usage.cache_creation_input_tokens || 0) + + (usage.cache_read_input_tokens || 0) + }, + costs: { + input: result.inputCost, + output: result.outputCost, + cacheWrite: result.cacheCreateCost, + cacheRead: result.cacheReadCost, + total: result.totalCost + }, + formatted: { + input: this.formatCost(result.inputCost), + output: this.formatCost(result.outputCost), + cacheWrite: this.formatCost(result.cacheCreateCost), + cacheRead: this.formatCost(result.cacheReadCost), + total: this.formatCost(result.totalCost) + }, + debug: { + isOpenAIModel: model.includes('gpt') || model.includes('o1'), + hasCacheCreatePrice: !!result.pricing.cacheCreate, + cacheCreateTokens: usage.cache_creation_input_tokens || 0, + cacheWritePriceUsed: result.pricing.cacheCreate * 1000000, + isLongContextModel: model && model.includes('[1m]'), + isLongContextRequest: result.isLongContextRequest || false + } + } } // 否则使用旧的逻辑(向后兼容) diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 2943deca..465952d5 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -797,6 +797,25 @@

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

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

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

- 窗口内最大Token -

+

窗口内最大费用

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

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

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

今日使用

-

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

-

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

+
+
+
+

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

+
+
+
+

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

+
+
+
+

+ ${{ calculateDailyCost(account) }} +

+
+
-

总使用量

-

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

-

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

+

会话窗口

+
+
+
+

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

+
+
+
+

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

+
+
+
-
@@ -678,14 +772,27 @@ class="space-y-1.5 rounded-lg bg-gray-50 p-2 dark:bg-gray-700" >
- 会话窗口 +
+ 会话窗口 + + + +
{{ account.sessionWindow.progress }}%
@@ -947,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': @@ -955,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': @@ -963,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': @@ -971,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 } @@ -1077,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) } // 格式化最后使用时间 @@ -1423,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) => { // 检查是否被封锁 @@ -1508,6 +1694,54 @@ const formatRelativeTime = (dateString) => { return formatLastUsed(dateString) } +// 获取会话窗口进度条的样式类 +const getSessionProgressBarClass = (status) => { + // 根据状态返回不同的颜色类,包含防御性检查 + if (!status) { + // 无状态信息时默认为蓝色 + return 'bg-gradient-to-r from-blue-500 to-indigo-600' + } + + // 转换为小写进行比较,避免大小写问题 + const normalizedStatus = String(status).toLowerCase() + + if (normalizedStatus === 'rejected') { + // 被拒绝 - 红色 + return 'bg-gradient-to-r from-red-500 to-red-600' + } else if (normalizedStatus === 'allowed_warning') { + // 警告状态 - 橙色/黄色 + return 'bg-gradient-to-r from-yellow-500 to-orange-500' + } else { + // 正常状态(allowed 或其他) - 蓝色 + return 'bg-gradient-to-r from-blue-500 to-indigo-600' + } +} + +// 格式化费用显示 +const formatCost = (cost) => { + if (!cost || cost === 0) return '0.0000' + if (cost < 0.0001) return cost.toExponential(2) + if (cost < 0.01) return cost.toFixed(6) + if (cost < 1) return cost.toFixed(4) + return cost.toFixed(2) +} + +// 计算每日费用(估算,基于平均模型价格) +const calculateDailyCost = (account) => { + if (!account.usage || !account.usage.daily) return '0.0000' + + const dailyTokens = account.usage.daily.allTokens || 0 + if (dailyTokens === 0) return '0.0000' + + // 使用平均价格估算(基于Claude 3.5 Sonnet的价格) + // 输入: $3/1M tokens, 输出: $15/1M tokens + // 假设平均比例为 输入:输出 = 3:1 + const avgPricePerMillion = 3 * 0.75 + 15 * 0.25 // 加权平均价格 + const cost = (dailyTokens / 1000000) * avgPricePerMillion + + return formatCost(cost) +} + // 切换调度状态 // const toggleDispatch = async (account) => { // await toggleSchedulable(account) diff --git a/web/admin-spa/src/views/ApiKeysView.vue b/web/admin-spa/src/views/ApiKeysView.vue index 6b01e835..9f5a183c 100644 --- a/web/admin-spa/src/views/ApiKeysView.vue +++ b/web/admin-spa/src/views/ApiKeysView.vue @@ -416,7 +416,7 @@
- 费用限额 + 每日费用 ${{ (key.dailyCost || 0).toFixed(2) }} / ${{ key.dailyCostLimit.toFixed(2) @@ -432,9 +432,30 @@
+ +
+
+ Opus周费用 + + ${{ (key.weeklyOpusCost || 0).toFixed(2) }} / ${{ + key.weeklyOpusCostLimit.toFixed(2) + }} + +
+
+
+
+
+ { return 'bg-green-500' } +// 获取 Opus 周费用进度 +const getWeeklyOpusCostProgress = (key) => { + if (!key.weeklyOpusCostLimit || key.weeklyOpusCostLimit === 0) return 0 + const percentage = ((key.weeklyOpusCost || 0) / key.weeklyOpusCostLimit) * 100 + return Math.min(percentage, 100) +} + +// 获取 Opus 周费用进度条颜色 +const getWeeklyOpusCostProgressColor = (key) => { + const progress = getWeeklyOpusCostProgress(key) + if (progress >= 100) return 'bg-red-500' + if (progress >= 80) return 'bg-yellow-500' + return 'bg-green-500' +} + // 显示使用详情 const showUsageDetails = (apiKey) => { selectedApiKeyForDetail.value = apiKey