mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
1
This commit is contained in:
@@ -1195,12 +1195,16 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
}), cost: $${dailyCost.toFixed(2)}/$${dailyCostLimit}`
|
}), cost: $${dailyCost.toFixed(2)}/$${dailyCostLimit}`
|
||||||
)
|
)
|
||||||
|
|
||||||
return res.status(429).json({
|
// 使用 402 Payment Required 而非 429,避免客户端自动重试
|
||||||
error: 'Daily cost limit exceeded',
|
return res.status(402).json({
|
||||||
message: `已达到每日费用限制 ($${dailyCostLimit})`,
|
error: {
|
||||||
|
type: 'insufficient_quota',
|
||||||
|
message: `已达到每日费用限制 ($${dailyCostLimit})`,
|
||||||
|
code: 'daily_cost_limit_exceeded'
|
||||||
|
},
|
||||||
currentCost: dailyCost,
|
currentCost: dailyCost,
|
||||||
costLimit: dailyCostLimit,
|
costLimit: dailyCostLimit,
|
||||||
resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString() // 明天0点重置
|
resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1224,9 +1228,13 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
}), cost: $${totalCost.toFixed(2)}/$${totalCostLimit}`
|
}), cost: $${totalCost.toFixed(2)}/$${totalCostLimit}`
|
||||||
)
|
)
|
||||||
|
|
||||||
return res.status(429).json({
|
// 使用 402 Payment Required 而非 429,避免客户端自动重试
|
||||||
error: 'Total cost limit exceeded',
|
return res.status(402).json({
|
||||||
message: `已达到总费用限制 ($${totalCostLimit})`,
|
error: {
|
||||||
|
type: 'insufficient_quota',
|
||||||
|
message: `已达到总费用限制 ($${totalCostLimit})`,
|
||||||
|
code: 'total_cost_limit_exceeded'
|
||||||
|
},
|
||||||
currentCost: totalCost,
|
currentCost: totalCost,
|
||||||
costLimit: totalCostLimit
|
costLimit: totalCostLimit
|
||||||
})
|
})
|
||||||
@@ -1265,12 +1273,16 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
resetDate.setDate(now.getDate() + daysUntilMonday)
|
resetDate.setDate(now.getDate() + daysUntilMonday)
|
||||||
resetDate.setHours(0, 0, 0, 0)
|
resetDate.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
return res.status(429).json({
|
// 使用 402 Payment Required 而非 429,避免客户端自动重试
|
||||||
error: 'Weekly Opus cost limit exceeded',
|
return res.status(402).json({
|
||||||
message: `已达到 Opus 模型周费用限制 ($${weeklyOpusCostLimit})`,
|
error: {
|
||||||
|
type: 'insufficient_quota',
|
||||||
|
message: `已达到 Opus 模型周费用限制 ($${weeklyOpusCostLimit})`,
|
||||||
|
code: 'weekly_opus_cost_limit_exceeded'
|
||||||
|
},
|
||||||
currentCost: weeklyOpusCost,
|
currentCost: weeklyOpusCost,
|
||||||
costLimit: weeklyOpusCostLimit,
|
costLimit: weeklyOpusCostLimit,
|
||||||
resetAt: resetDate.toISOString() // 下周一重置
|
resetAt: resetDate.toISOString()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -734,6 +734,18 @@ class RedisClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 对象字段(JSON 解析)
|
||||||
|
const objectFields = ['serviceRates']
|
||||||
|
for (const field of objectFields) {
|
||||||
|
if (parsed[field]) {
|
||||||
|
try {
|
||||||
|
parsed[field] = JSON.parse(parsed[field])
|
||||||
|
} catch (e) {
|
||||||
|
parsed[field] = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -964,7 +976,9 @@ class RedisClient {
|
|||||||
model = 'unknown',
|
model = 'unknown',
|
||||||
ephemeral5mTokens = 0, // 新增:5分钟缓存 tokens
|
ephemeral5mTokens = 0, // 新增:5分钟缓存 tokens
|
||||||
ephemeral1hTokens = 0, // 新增:1小时缓存 tokens
|
ephemeral1hTokens = 0, // 新增:1小时缓存 tokens
|
||||||
isLongContextRequest = false // 新增:是否为 1M 上下文请求(超过200k)
|
isLongContextRequest = false, // 新增:是否为 1M 上下文请求(超过200k)
|
||||||
|
realCost = 0, // 真实费用(官方API费用)
|
||||||
|
ratedCost = 0 // 计费费用(应用倍率后)
|
||||||
) {
|
) {
|
||||||
const key = `usage:${keyId}`
|
const key = `usage:${keyId}`
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
@@ -1089,6 +1103,13 @@ class RedisClient {
|
|||||||
// 详细缓存类型统计
|
// 详细缓存类型统计
|
||||||
pipeline.hincrby(keyModelDaily, 'ephemeral5mTokens', ephemeral5mTokens)
|
pipeline.hincrby(keyModelDaily, 'ephemeral5mTokens', ephemeral5mTokens)
|
||||||
pipeline.hincrby(keyModelDaily, 'ephemeral1hTokens', ephemeral1hTokens)
|
pipeline.hincrby(keyModelDaily, 'ephemeral1hTokens', ephemeral1hTokens)
|
||||||
|
// 费用统计(使用整数存储,单位:微美元,1美元=1000000微美元)
|
||||||
|
if (realCost > 0) {
|
||||||
|
pipeline.hincrby(keyModelDaily, 'realCostMicro', Math.round(realCost * 1000000))
|
||||||
|
}
|
||||||
|
if (ratedCost > 0) {
|
||||||
|
pipeline.hincrby(keyModelDaily, 'ratedCostMicro', Math.round(ratedCost * 1000000))
|
||||||
|
}
|
||||||
|
|
||||||
// API Key级别的模型统计 - 每月
|
// API Key级别的模型统计 - 每月
|
||||||
pipeline.hincrby(keyModelMonthly, 'inputTokens', finalInputTokens)
|
pipeline.hincrby(keyModelMonthly, 'inputTokens', finalInputTokens)
|
||||||
@@ -1100,6 +1121,13 @@ class RedisClient {
|
|||||||
// 详细缓存类型统计
|
// 详细缓存类型统计
|
||||||
pipeline.hincrby(keyModelMonthly, 'ephemeral5mTokens', ephemeral5mTokens)
|
pipeline.hincrby(keyModelMonthly, 'ephemeral5mTokens', ephemeral5mTokens)
|
||||||
pipeline.hincrby(keyModelMonthly, 'ephemeral1hTokens', ephemeral1hTokens)
|
pipeline.hincrby(keyModelMonthly, 'ephemeral1hTokens', ephemeral1hTokens)
|
||||||
|
// 费用统计
|
||||||
|
if (realCost > 0) {
|
||||||
|
pipeline.hincrby(keyModelMonthly, 'realCostMicro', Math.round(realCost * 1000000))
|
||||||
|
}
|
||||||
|
if (ratedCost > 0) {
|
||||||
|
pipeline.hincrby(keyModelMonthly, 'ratedCostMicro', Math.round(ratedCost * 1000000))
|
||||||
|
}
|
||||||
|
|
||||||
// API Key级别的模型统计 - 所有时间(无 TTL)
|
// API Key级别的模型统计 - 所有时间(无 TTL)
|
||||||
const keyModelAlltime = `usage:${keyId}:model:alltime:${normalizedModel}`
|
const keyModelAlltime = `usage:${keyId}:model:alltime:${normalizedModel}`
|
||||||
@@ -1108,6 +1136,13 @@ class RedisClient {
|
|||||||
pipeline.hincrby(keyModelAlltime, 'cacheCreateTokens', finalCacheCreateTokens)
|
pipeline.hincrby(keyModelAlltime, 'cacheCreateTokens', finalCacheCreateTokens)
|
||||||
pipeline.hincrby(keyModelAlltime, 'cacheReadTokens', finalCacheReadTokens)
|
pipeline.hincrby(keyModelAlltime, 'cacheReadTokens', finalCacheReadTokens)
|
||||||
pipeline.hincrby(keyModelAlltime, 'requests', 1)
|
pipeline.hincrby(keyModelAlltime, 'requests', 1)
|
||||||
|
// 费用统计
|
||||||
|
if (realCost > 0) {
|
||||||
|
pipeline.hincrby(keyModelAlltime, 'realCostMicro', Math.round(realCost * 1000000))
|
||||||
|
}
|
||||||
|
if (ratedCost > 0) {
|
||||||
|
pipeline.hincrby(keyModelAlltime, 'ratedCostMicro', Math.round(ratedCost * 1000000))
|
||||||
|
}
|
||||||
|
|
||||||
// 小时级别统计
|
// 小时级别统计
|
||||||
pipeline.hincrby(hourly, 'tokens', coreTokens)
|
pipeline.hincrby(hourly, 'tokens', coreTokens)
|
||||||
@@ -1133,6 +1168,13 @@ class RedisClient {
|
|||||||
pipeline.hincrby(keyModelHourly, 'cacheReadTokens', finalCacheReadTokens)
|
pipeline.hincrby(keyModelHourly, 'cacheReadTokens', finalCacheReadTokens)
|
||||||
pipeline.hincrby(keyModelHourly, 'allTokens', totalTokens)
|
pipeline.hincrby(keyModelHourly, 'allTokens', totalTokens)
|
||||||
pipeline.hincrby(keyModelHourly, 'requests', 1)
|
pipeline.hincrby(keyModelHourly, 'requests', 1)
|
||||||
|
// 费用统计
|
||||||
|
if (realCost > 0) {
|
||||||
|
pipeline.hincrby(keyModelHourly, 'realCostMicro', Math.round(realCost * 1000000))
|
||||||
|
}
|
||||||
|
if (ratedCost > 0) {
|
||||||
|
pipeline.hincrby(keyModelHourly, 'ratedCostMicro', Math.round(ratedCost * 1000000))
|
||||||
|
}
|
||||||
|
|
||||||
// 新增:系统级分钟统计
|
// 新增:系统级分钟统计
|
||||||
pipeline.hincrby(systemMinuteKey, 'requests', 1)
|
pipeline.hincrby(systemMinuteKey, 'requests', 1)
|
||||||
@@ -1385,6 +1427,8 @@ class RedisClient {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取使用了指定模型的 Key IDs(OR 逻辑)
|
* 获取使用了指定模型的 Key IDs(OR 逻辑)
|
||||||
|
* 使用 EXISTS + pipeline 批量检查 alltime 键,避免 KEYS 全量扫描
|
||||||
|
* 支持分批处理和 fallback 到 SCAN 模式
|
||||||
*/
|
*/
|
||||||
async getKeyIdsWithModels(keyIds, models) {
|
async getKeyIdsWithModels(keyIds, models) {
|
||||||
if (!keyIds.length || !models.length) {
|
if (!keyIds.length || !models.length) {
|
||||||
@@ -1393,16 +1437,67 @@ class RedisClient {
|
|||||||
|
|
||||||
const client = this.getClientSafe()
|
const client = this.getClientSafe()
|
||||||
const result = new Set()
|
const result = new Set()
|
||||||
|
const BATCH_SIZE = 1000
|
||||||
|
|
||||||
|
// 构建所有需要检查的 key
|
||||||
|
const checkKeys = []
|
||||||
|
const keyIdMap = new Map()
|
||||||
|
|
||||||
// 批量检查每个 keyId 是否使用过任意一个指定模型
|
|
||||||
for (const keyId of keyIds) {
|
for (const keyId of keyIds) {
|
||||||
for (const model of models) {
|
for (const model of models) {
|
||||||
// 检查是否有该模型的使用记录(daily 或 monthly)
|
const key = `usage:${keyId}:model:alltime:${model}`
|
||||||
const pattern = `usage:${keyId}:model:*:${model}:*`
|
checkKeys.push(key)
|
||||||
const keys = await client.keys(pattern)
|
keyIdMap.set(key, keyId)
|
||||||
if (keys.length > 0) {
|
}
|
||||||
result.add(keyId)
|
}
|
||||||
break // 找到一个就够了(OR 逻辑)
|
|
||||||
|
// 分批 EXISTS 检查(避免单个 pipeline 过大)
|
||||||
|
for (let i = 0; i < checkKeys.length; i += BATCH_SIZE) {
|
||||||
|
const batch = checkKeys.slice(i, i + BATCH_SIZE)
|
||||||
|
const pipeline = client.pipeline()
|
||||||
|
for (const key of batch) {
|
||||||
|
pipeline.exists(key)
|
||||||
|
}
|
||||||
|
const results = await pipeline.exec()
|
||||||
|
|
||||||
|
for (let j = 0; j < batch.length; j++) {
|
||||||
|
const [err, exists] = results[j]
|
||||||
|
if (!err && exists) {
|
||||||
|
result.add(keyIdMap.get(batch[j]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: 如果 alltime 键全部不存在,回退到 SCAN 模式
|
||||||
|
if (result.size === 0 && keyIds.length > 0) {
|
||||||
|
// 多抽样检查:抽取最多 3 个 keyId 检查是否有 alltime 数据
|
||||||
|
const sampleIndices = new Set()
|
||||||
|
sampleIndices.add(0) // 始终包含第一个
|
||||||
|
if (keyIds.length > 1) sampleIndices.add(keyIds.length - 1) // 包含最后一个
|
||||||
|
if (keyIds.length > 2) sampleIndices.add(Math.floor(keyIds.length / 2)) // 包含中间一个
|
||||||
|
|
||||||
|
let hasAnyAlltimeData = false
|
||||||
|
for (const idx of sampleIndices) {
|
||||||
|
const samplePattern = `usage:${keyIds[idx]}:model:alltime:*`
|
||||||
|
const sampleKeys = await this.scanKeys(samplePattern)
|
||||||
|
if (sampleKeys.length > 0) {
|
||||||
|
hasAnyAlltimeData = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAnyAlltimeData) {
|
||||||
|
// alltime 数据不存在,回退到旧扫描逻辑
|
||||||
|
logger.warn('⚠️ alltime 模型数据不存在,回退到 SCAN 模式(建议运行迁移脚本)')
|
||||||
|
for (const keyId of keyIds) {
|
||||||
|
for (const model of models) {
|
||||||
|
const pattern = `usage:${keyId}:model:*:${model}:*`
|
||||||
|
const keys = await this.scanKeys(pattern)
|
||||||
|
if (keys.length > 0) {
|
||||||
|
result.add(keyId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1830,10 +1925,10 @@ class RedisClient {
|
|||||||
return costMap
|
return costMap
|
||||||
}
|
}
|
||||||
|
|
||||||
// 💰 回退方法:使用 KEYS 命令计算单个账户的每日费用
|
// 💰 回退方法:计算单个账户的每日费用(使用 scanKeys 替代 keys)
|
||||||
async getAccountDailyCostFallback(accountId, today, CostCalculator) {
|
async getAccountDailyCostFallback(accountId, today, CostCalculator) {
|
||||||
const pattern = `account_usage:model:daily:${accountId}:*:${today}`
|
const pattern = `account_usage:model:daily:${accountId}:*:${today}`
|
||||||
const modelKeys = await this.client.keys(pattern)
|
const modelKeys = await this.scanKeys(pattern)
|
||||||
|
|
||||||
if (!modelKeys || modelKeys.length === 0) {
|
if (!modelKeys || modelKeys.length === 0) {
|
||||||
return 0
|
return 0
|
||||||
@@ -1898,8 +1993,11 @@ class RedisClient {
|
|||||||
} else if (accountType === 'openai-responses') {
|
} else if (accountType === 'openai-responses') {
|
||||||
accountData = await this.client.hgetall(`openai_responses_account:${accountId}`)
|
accountData = await this.client.hgetall(`openai_responses_account:${accountId}`)
|
||||||
} else {
|
} else {
|
||||||
// 尝试多个前缀
|
// 尝试多个前缀(优先 claude:account:)
|
||||||
accountData = await this.client.hgetall(`claude_account:${accountId}`)
|
accountData = await this.client.hgetall(`claude:account:${accountId}`)
|
||||||
|
if (!accountData.createdAt) {
|
||||||
|
accountData = await this.client.hgetall(`claude_account:${accountId}`)
|
||||||
|
}
|
||||||
if (!accountData.createdAt) {
|
if (!accountData.createdAt) {
|
||||||
accountData = await this.client.hgetall(`openai:account:${accountId}`)
|
accountData = await this.client.hgetall(`openai:account:${accountId}`)
|
||||||
}
|
}
|
||||||
@@ -1978,15 +2076,24 @@ class RedisClient {
|
|||||||
// 📈 获取所有账户的使用统计
|
// 📈 获取所有账户的使用统计
|
||||||
async getAllAccountsUsageStats() {
|
async getAllAccountsUsageStats() {
|
||||||
try {
|
try {
|
||||||
// 获取所有Claude账户
|
// 使用 getAllIdsByIndex 获取账户 ID(自动处理索引/SCAN 回退)
|
||||||
const accountKeys = await this.client.keys('claude_account:*')
|
const accountIds = await this.getAllIdsByIndex(
|
||||||
|
'claude:account:index',
|
||||||
|
'claude:account:*',
|
||||||
|
/^claude:account:(.+)$/
|
||||||
|
)
|
||||||
|
|
||||||
|
if (accountIds.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
const accountStats = []
|
const accountStats = []
|
||||||
|
|
||||||
for (const accountKey of accountKeys) {
|
for (const accountId of accountIds) {
|
||||||
const accountId = accountKey.replace('claude_account:', '')
|
const accountKey = `claude:account:${accountId}`
|
||||||
const accountData = await this.client.hgetall(accountKey)
|
const accountData = await this.client.hgetall(accountKey)
|
||||||
|
|
||||||
if (accountData.name) {
|
if (accountData && accountData.name) {
|
||||||
const stats = await this.getAccountUsageStats(accountId)
|
const stats = await this.getAccountUsageStats(accountId)
|
||||||
accountStats.push({
|
accountStats.push({
|
||||||
id: accountId,
|
id: accountId,
|
||||||
@@ -2009,7 +2116,7 @@ class RedisClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🧹 清空所有API Key的使用统计数据
|
// 🧹 清空所有API Key的使用统计数据(使用 scanKeys + batchDelChunked 优化)
|
||||||
async resetAllUsageStats() {
|
async resetAllUsageStats() {
|
||||||
const client = this.getClientSafe()
|
const client = this.getClientSafe()
|
||||||
const stats = {
|
const stats = {
|
||||||
@@ -2020,56 +2127,53 @@ class RedisClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取所有API Key ID
|
// 1. 获取所有 API Key ID(使用 scanKeys)
|
||||||
const apiKeyIds = []
|
const apiKeyKeys = await this.scanKeys('apikey:*')
|
||||||
const apiKeyKeys = await client.keys('apikey:*')
|
const apiKeyIds = apiKeyKeys
|
||||||
|
.filter((k) => k !== 'apikey:hash_map' && k.split(':').length === 2)
|
||||||
|
.map((k) => k.replace('apikey:', ''))
|
||||||
|
|
||||||
for (const key of apiKeyKeys) {
|
// 2. 批量删除总体使用统计
|
||||||
if (key === 'apikey:hash_map') {
|
const usageKeys = apiKeyIds.map((id) => `usage:${id}`)
|
||||||
continue
|
stats.deletedKeys = await this.batchDelChunked(usageKeys)
|
||||||
} // 跳过哈希映射表
|
|
||||||
const keyId = key.replace('apikey:', '')
|
|
||||||
apiKeyIds.push(keyId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清空每个API Key的使用统计
|
// 3. 使用 scanKeys 获取并批量删除 daily 统计
|
||||||
for (const keyId of apiKeyIds) {
|
const dailyKeys = await this.scanKeys('usage:daily:*')
|
||||||
// 删除总体使用统计
|
stats.deletedDailyKeys = await this.batchDelChunked(dailyKeys)
|
||||||
const usageKey = `usage:${keyId}`
|
|
||||||
const deleted = await client.del(usageKey)
|
// 4. 使用 scanKeys 获取并批量删除 monthly 统计
|
||||||
if (deleted > 0) {
|
const monthlyKeys = await this.scanKeys('usage:monthly:*')
|
||||||
stats.deletedKeys++
|
stats.deletedMonthlyKeys = await this.batchDelChunked(monthlyKeys)
|
||||||
|
|
||||||
|
// 5. 批量重置 lastUsedAt(仅对存在的 key 操作,避免重建空 hash)
|
||||||
|
const BATCH_SIZE = 500
|
||||||
|
for (let i = 0; i < apiKeyIds.length; i += BATCH_SIZE) {
|
||||||
|
const batch = apiKeyIds.slice(i, i + BATCH_SIZE)
|
||||||
|
const existsPipeline = client.pipeline()
|
||||||
|
for (const keyId of batch) {
|
||||||
|
existsPipeline.exists(`apikey:${keyId}`)
|
||||||
}
|
}
|
||||||
|
const existsResults = await existsPipeline.exec()
|
||||||
|
|
||||||
// 删除该API Key的每日统计(使用精确的keyId匹配)
|
const updatePipeline = client.pipeline()
|
||||||
const dailyKeys = await client.keys(`usage:daily:${keyId}:*`)
|
let updateCount = 0
|
||||||
if (dailyKeys.length > 0) {
|
for (let j = 0; j < batch.length; j++) {
|
||||||
await client.del(...dailyKeys)
|
const [err, exists] = existsResults[j]
|
||||||
stats.deletedDailyKeys += dailyKeys.length
|
if (!err && exists) {
|
||||||
|
updatePipeline.hset(`apikey:${batch[j]}`, 'lastUsedAt', '')
|
||||||
|
updateCount++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (updateCount > 0) {
|
||||||
// 删除该API Key的每月统计(使用精确的keyId匹配)
|
await updatePipeline.exec()
|
||||||
const monthlyKeys = await client.keys(`usage:monthly:${keyId}:*`)
|
stats.resetApiKeys += updateCount
|
||||||
if (monthlyKeys.length > 0) {
|
|
||||||
await client.del(...monthlyKeys)
|
|
||||||
stats.deletedMonthlyKeys += monthlyKeys.length
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重置API Key的lastUsedAt字段
|
|
||||||
const keyData = await client.hgetall(`apikey:${keyId}`)
|
|
||||||
if (keyData && Object.keys(keyData).length > 0) {
|
|
||||||
keyData.lastUsedAt = ''
|
|
||||||
await client.hset(`apikey:${keyId}`, keyData)
|
|
||||||
stats.resetApiKeys++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 额外清理:删除所有可能遗漏的usage相关键
|
// 6. 清理所有 usage 相关键(使用 scanKeys + batchDelChunked)
|
||||||
const allUsageKeys = await client.keys('usage:*')
|
const allUsageKeys = await this.scanKeys('usage:*')
|
||||||
if (allUsageKeys.length > 0) {
|
const additionalDeleted = await this.batchDelChunked(allUsageKeys)
|
||||||
await client.del(...allUsageKeys)
|
stats.deletedKeys += additionalDeleted
|
||||||
stats.deletedKeys += allUsageKeys.length
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats
|
return stats
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2406,16 +2510,21 @@ class RedisClient {
|
|||||||
return await this.client.del(key)
|
return await this.client.del(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 📈 系统统计
|
// 📈 系统统计(使用 scanKeys 替代 keys)
|
||||||
async getSystemStats() {
|
async getSystemStats() {
|
||||||
const keys = await Promise.all([
|
const keys = await Promise.all([
|
||||||
this.client.keys('apikey:*'),
|
this.scanKeys('apikey:*'),
|
||||||
this.client.keys('claude:account:*'),
|
this.scanKeys('claude:account:*'),
|
||||||
this.client.keys('usage:*')
|
this.scanKeys('usage:*')
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// 过滤 apikey 索引键,只统计实际的 apikey
|
||||||
|
const apiKeyCount = keys[0].filter(
|
||||||
|
(k) => k !== 'apikey:hash_map' && k.split(':').length === 2
|
||||||
|
).length
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalApiKeys: keys[0].length,
|
totalApiKeys: apiKeyCount,
|
||||||
totalClaudeAccounts: keys[1].length,
|
totalClaudeAccounts: keys[1].length,
|
||||||
totalUsageRecords: keys[2].length
|
totalUsageRecords: keys[2].length
|
||||||
}
|
}
|
||||||
@@ -2771,13 +2880,13 @@ class RedisClient {
|
|||||||
return await this.client.del(key)
|
return await this.client.del(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🧹 清理过期数据
|
// 🧹 清理过期数据(使用 scanKeys 替代 keys)
|
||||||
async cleanup() {
|
async cleanup() {
|
||||||
try {
|
try {
|
||||||
const patterns = ['usage:daily:*', 'ratelimit:*', 'session:*', 'sticky_session:*', 'oauth:*']
|
const patterns = ['usage:daily:*', 'ratelimit:*', 'session:*', 'sticky_session:*', 'oauth:*']
|
||||||
|
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
const keys = await this.client.keys(pattern)
|
const keys = await this.scanKeys(pattern)
|
||||||
const pipeline = this.client.pipeline()
|
const pipeline = this.client.pipeline()
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
@@ -3039,13 +3148,13 @@ class RedisClient {
|
|||||||
// 🔧 并发管理方法(用于管理员手动清理)
|
// 🔧 并发管理方法(用于管理员手动清理)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取所有并发状态
|
* 获取所有并发状态(使用 scanKeys 替代 keys)
|
||||||
* @returns {Promise<Array>} 并发状态列表
|
* @returns {Promise<Array>} 并发状态列表
|
||||||
*/
|
*/
|
||||||
async getAllConcurrencyStatus() {
|
async getAllConcurrencyStatus() {
|
||||||
try {
|
try {
|
||||||
const client = this.getClientSafe()
|
const client = this.getClientSafe()
|
||||||
const keys = await client.keys('concurrency:*')
|
const keys = await this.scanKeys('concurrency:*')
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const results = []
|
const results = []
|
||||||
|
|
||||||
@@ -3238,13 +3347,13 @@ class RedisClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 强制清理所有并发计数
|
* 强制清理所有并发计数(使用 scanKeys 替代 keys)
|
||||||
* @returns {Promise<Object>} 清理结果
|
* @returns {Promise<Object>} 清理结果
|
||||||
*/
|
*/
|
||||||
async forceClearAllConcurrency() {
|
async forceClearAllConcurrency() {
|
||||||
try {
|
try {
|
||||||
const client = this.getClientSafe()
|
const client = this.getClientSafe()
|
||||||
const keys = await client.keys('concurrency:*')
|
const keys = await this.scanKeys('concurrency:*')
|
||||||
|
|
||||||
let totalCleared = 0
|
let totalCleared = 0
|
||||||
let legacyCleared = 0
|
let legacyCleared = 0
|
||||||
@@ -3298,7 +3407,7 @@ class RedisClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清理过期的并发条目(不影响活跃请求)
|
* 清理过期的并发条目(不影响活跃请求,使用 scanKeys 替代 keys)
|
||||||
* @param {string} apiKeyId - API Key ID(可选,不传则清理所有)
|
* @param {string} apiKeyId - API Key ID(可选,不传则清理所有)
|
||||||
* @returns {Promise<Object>} 清理结果
|
* @returns {Promise<Object>} 清理结果
|
||||||
*/
|
*/
|
||||||
@@ -3311,7 +3420,7 @@ class RedisClient {
|
|||||||
if (apiKeyId) {
|
if (apiKeyId) {
|
||||||
keys = [`concurrency:${apiKeyId}`]
|
keys = [`concurrency:${apiKeyId}`]
|
||||||
} else {
|
} else {
|
||||||
keys = await client.keys('concurrency:*')
|
keys = await this.scanKeys('concurrency:*')
|
||||||
}
|
}
|
||||||
|
|
||||||
let totalCleaned = 0
|
let totalCleaned = 0
|
||||||
@@ -4890,7 +4999,9 @@ redisClient.ensureMonthlyMonthsIndex = async function () {
|
|||||||
|
|
||||||
if (missingMonths.length > 0) {
|
if (missingMonths.length > 0) {
|
||||||
await this.client.sadd('usage:model:monthly:months', ...missingMonths)
|
await this.client.sadd('usage:model:monthly:months', ...missingMonths)
|
||||||
logger.info(`📅 补充月份索引: ${missingMonths.length} 个月份 (${missingMonths.sort().join(', ')})`)
|
logger.info(
|
||||||
|
`📅 补充月份索引: ${missingMonths.length} 个月份 (${missingMonths.sort().join(', ')})`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,27 @@ function validatePermissions(permissions) {
|
|||||||
return `Permissions must be an array. Valid values are: ${VALID_PERMISSIONS.join(', ')}`
|
return `Permissions must be an array. Valid values are: ${VALID_PERMISSIONS.join(', ')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 serviceRates 格式
|
||||||
|
* @param {any} serviceRates - 服务倍率对象
|
||||||
|
* @returns {string|null} - 返回错误消息,null 表示验证通过
|
||||||
|
*/
|
||||||
|
function validateServiceRates(serviceRates) {
|
||||||
|
if (serviceRates === undefined || serviceRates === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (typeof serviceRates !== 'object' || Array.isArray(serviceRates)) {
|
||||||
|
return 'Service rates must be an object'
|
||||||
|
}
|
||||||
|
for (const [service, rate] of Object.entries(serviceRates)) {
|
||||||
|
const numRate = Number(rate)
|
||||||
|
if (!Number.isFinite(numRate) || numRate < 0) {
|
||||||
|
return `Invalid rate for service "${service}": must be a non-negative number`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// 👥 用户管理 (用于API Key分配)
|
// 👥 用户管理 (用于API Key分配)
|
||||||
|
|
||||||
// 获取所有用户列表(用于API Key分配)
|
// 获取所有用户列表(用于API Key分配)
|
||||||
@@ -814,7 +835,9 @@ router.put('/api-keys/tags/:tagName', authenticateAdmin, async (req, res) => {
|
|||||||
return res.status(400).json({ error: result.error })
|
return res.status(400).json({ error: result.error })
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`🏷️ Renamed tag "${decodedTagName}" to "${trimmedNewName}" in ${result.affectedCount} API keys`)
|
logger.info(
|
||||||
|
`🏷️ Renamed tag "${decodedTagName}" to "${trimmedNewName}" in ${result.affectedCount} API keys`
|
||||||
|
)
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `Tag renamed in ${result.affectedCount} API keys`,
|
message: `Tag renamed in ${result.affectedCount} API keys`,
|
||||||
@@ -1426,7 +1449,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
activationDays, // 新增:激活后有效天数
|
activationDays, // 新增:激活后有效天数
|
||||||
activationUnit, // 新增:激活时间单位 (hours/days)
|
activationUnit, // 新增:激活时间单位 (hours/days)
|
||||||
expirationMode, // 新增:过期模式
|
expirationMode, // 新增:过期模式
|
||||||
icon // 新增:图标
|
icon, // 新增:图标
|
||||||
|
serviceRates // API Key 级别服务倍率
|
||||||
} = req.body
|
} = req.body
|
||||||
|
|
||||||
// 输入验证
|
// 输入验证
|
||||||
@@ -1553,6 +1577,12 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
return res.status(400).json({ error: permissionsError })
|
return res.status(400).json({ error: permissionsError })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证服务倍率
|
||||||
|
const serviceRatesError = validateServiceRates(serviceRates)
|
||||||
|
if (serviceRatesError) {
|
||||||
|
return res.status(400).json({ error: serviceRatesError })
|
||||||
|
}
|
||||||
|
|
||||||
const newKey = await apiKeyService.generateApiKey({
|
const newKey = await apiKeyService.generateApiKey({
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
@@ -1580,7 +1610,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
activationDays,
|
activationDays,
|
||||||
activationUnit,
|
activationUnit,
|
||||||
expirationMode,
|
expirationMode,
|
||||||
icon
|
icon,
|
||||||
|
serviceRates
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.success(`🔑 Admin created new API key: ${name}`)
|
logger.success(`🔑 Admin created new API key: ${name}`)
|
||||||
@@ -1622,7 +1653,8 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
activationDays,
|
activationDays,
|
||||||
activationUnit,
|
activationUnit,
|
||||||
expirationMode,
|
expirationMode,
|
||||||
icon
|
icon,
|
||||||
|
serviceRates
|
||||||
} = req.body
|
} = req.body
|
||||||
|
|
||||||
// 输入验证
|
// 输入验证
|
||||||
@@ -1646,6 +1678,12 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
return res.status(400).json({ error: batchPermissionsError })
|
return res.status(400).json({ error: batchPermissionsError })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证服务倍率
|
||||||
|
const batchServiceRatesError = validateServiceRates(serviceRates)
|
||||||
|
if (batchServiceRatesError) {
|
||||||
|
return res.status(400).json({ error: batchServiceRatesError })
|
||||||
|
}
|
||||||
|
|
||||||
// 生成批量API Keys
|
// 生成批量API Keys
|
||||||
const createdKeys = []
|
const createdKeys = []
|
||||||
const errors = []
|
const errors = []
|
||||||
@@ -1680,7 +1718,8 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
activationDays,
|
activationDays,
|
||||||
activationUnit,
|
activationUnit,
|
||||||
expirationMode,
|
expirationMode,
|
||||||
icon
|
icon,
|
||||||
|
serviceRates
|
||||||
})
|
})
|
||||||
|
|
||||||
// 保留原始 API Key 供返回
|
// 保留原始 API Key 供返回
|
||||||
@@ -1754,6 +1793,14 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证服务倍率
|
||||||
|
if (updates.serviceRates !== undefined) {
|
||||||
|
const updateServiceRatesError = validateServiceRates(updates.serviceRates)
|
||||||
|
if (updateServiceRatesError) {
|
||||||
|
return res.status(400).json({ error: updateServiceRatesError })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`🔄 Admin batch editing ${keyIds.length} API keys with updates: ${JSON.stringify(updates)}`
|
`🔄 Admin batch editing ${keyIds.length} API keys with updates: ${JSON.stringify(updates)}`
|
||||||
)
|
)
|
||||||
@@ -1822,6 +1869,9 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
if (updates.enabled !== undefined) {
|
if (updates.enabled !== undefined) {
|
||||||
finalUpdates.enabled = updates.enabled
|
finalUpdates.enabled = updates.enabled
|
||||||
}
|
}
|
||||||
|
if (updates.serviceRates !== undefined) {
|
||||||
|
finalUpdates.serviceRates = updates.serviceRates
|
||||||
|
}
|
||||||
|
|
||||||
// 处理账户绑定
|
// 处理账户绑定
|
||||||
if (updates.claudeAccountId !== undefined) {
|
if (updates.claudeAccountId !== undefined) {
|
||||||
@@ -1939,7 +1989,8 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
|||||||
totalCostLimit,
|
totalCostLimit,
|
||||||
weeklyOpusCostLimit,
|
weeklyOpusCostLimit,
|
||||||
tags,
|
tags,
|
||||||
ownerId // 新增:所有者ID字段
|
ownerId, // 新增:所有者ID字段
|
||||||
|
serviceRates // API Key 级别服务倍率
|
||||||
} = req.body
|
} = req.body
|
||||||
|
|
||||||
// 只允许更新指定字段
|
// 只允许更新指定字段
|
||||||
@@ -2125,6 +2176,15 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
|||||||
updates.tags = tags
|
updates.tags = tags
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理服务倍率
|
||||||
|
if (serviceRates !== undefined) {
|
||||||
|
const singleServiceRatesError = validateServiceRates(serviceRates)
|
||||||
|
if (singleServiceRatesError) {
|
||||||
|
return res.status(400).json({ error: singleServiceRatesError })
|
||||||
|
}
|
||||||
|
updates.serviceRates = serviceRates
|
||||||
|
}
|
||||||
|
|
||||||
// 处理活跃/禁用状态状态, 放在过期处理后,以确保后续增加禁用key功能
|
// 处理活跃/禁用状态状态, 放在过期处理后,以确保后续增加禁用key功能
|
||||||
if (isActive !== undefined) {
|
if (isActive !== undefined) {
|
||||||
if (typeof isActive !== 'boolean') {
|
if (typeof isActive !== 'boolean') {
|
||||||
|
|||||||
@@ -792,7 +792,10 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
|||||||
outputTokens: 0,
|
outputTokens: 0,
|
||||||
cacheCreateTokens: 0,
|
cacheCreateTokens: 0,
|
||||||
cacheReadTokens: 0,
|
cacheReadTokens: 0,
|
||||||
allTokens: 0
|
allTokens: 0,
|
||||||
|
realCostMicro: 0,
|
||||||
|
ratedCostMicro: 0,
|
||||||
|
hasStoredCost: false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -803,12 +806,18 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
|||||||
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
|
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
|
||||||
modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
|
modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
|
||||||
modelUsage.allTokens += parseInt(data.allTokens) || 0
|
modelUsage.allTokens += parseInt(data.allTokens) || 0
|
||||||
|
modelUsage.realCostMicro += parseInt(data.realCostMicro) || 0
|
||||||
|
modelUsage.ratedCostMicro += parseInt(data.ratedCostMicro) || 0
|
||||||
|
// 检查 Redis 数据是否包含成本字段
|
||||||
|
if ('realCostMicro' in data || 'ratedCostMicro' in data) {
|
||||||
|
modelUsage.hasStoredCost = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
// 转换为数组并计算费用
|
// 转换为数组并处理费用
|
||||||
const modelStats = []
|
const modelStats = []
|
||||||
for (const [model, usage] of modelUsageMap) {
|
for (const [model, usage] of modelUsageMap) {
|
||||||
const usageData = {
|
const usageData = {
|
||||||
@@ -818,8 +827,18 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
|||||||
cache_read_input_tokens: usage.cacheReadTokens
|
cache_read_input_tokens: usage.cacheReadTokens
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 优先使用存储的费用,否则回退到重新计算
|
||||||
|
const hasStoredCost = usage.hasStoredCost
|
||||||
const costData = CostCalculator.calculateCost(usageData, model)
|
const costData = CostCalculator.calculateCost(usageData, model)
|
||||||
|
|
||||||
|
// 如果有存储的费用,覆盖计算的费用
|
||||||
|
if (hasStoredCost) {
|
||||||
|
costData.costs.real = (usage.realCostMicro || 0) / 1000000
|
||||||
|
costData.costs.rated = (usage.ratedCostMicro || 0) / 1000000
|
||||||
|
costData.costs.total = costData.costs.real // 保持兼容
|
||||||
|
costData.formatted.total = `$${costData.costs.real.toFixed(6)}`
|
||||||
|
}
|
||||||
|
|
||||||
modelStats.push({
|
modelStats.push({
|
||||||
model,
|
model,
|
||||||
requests: usage.requests,
|
requests: usage.requests,
|
||||||
@@ -830,7 +849,8 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
|||||||
allTokens: usage.allTokens,
|
allTokens: usage.allTokens,
|
||||||
costs: costData.costs,
|
costs: costData.costs,
|
||||||
formatted: costData.formatted,
|
formatted: costData.formatted,
|
||||||
pricing: costData.pricing
|
pricing: costData.pricing,
|
||||||
|
isLegacy: !hasStoredCost
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1343,8 +1363,21 @@ router.post('/api/user-model-stats', async (req, res) => {
|
|||||||
cache_read_input_tokens: parseInt(data.cacheReadTokens) || 0
|
cache_read_input_tokens: parseInt(data.cacheReadTokens) || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 优先使用存储的费用,否则回退到重新计算
|
||||||
|
// 检查字段是否存在(而非 > 0),以支持真正的零成本场景
|
||||||
|
const realCostMicro = parseInt(data.realCostMicro) || 0
|
||||||
|
const ratedCostMicro = parseInt(data.ratedCostMicro) || 0
|
||||||
|
const hasStoredCost = 'realCostMicro' in data || 'ratedCostMicro' in data
|
||||||
const costData = CostCalculator.calculateCost(usage, model)
|
const costData = CostCalculator.calculateCost(usage, model)
|
||||||
|
|
||||||
|
// 如果有存储的费用,覆盖计算的费用
|
||||||
|
if (hasStoredCost) {
|
||||||
|
costData.costs.real = realCostMicro / 1000000
|
||||||
|
costData.costs.rated = ratedCostMicro / 1000000
|
||||||
|
costData.costs.total = costData.costs.real
|
||||||
|
costData.formatted.total = `$${costData.costs.real.toFixed(6)}`
|
||||||
|
}
|
||||||
|
|
||||||
// alltime 键不存储 allTokens,需要计算
|
// alltime 键不存储 allTokens,需要计算
|
||||||
const allTokens =
|
const allTokens =
|
||||||
period === 'alltime'
|
period === 'alltime'
|
||||||
@@ -1364,7 +1397,8 @@ router.post('/api/user-model-stats', async (req, res) => {
|
|||||||
allTokens,
|
allTokens,
|
||||||
costs: costData.costs,
|
costs: costData.costs,
|
||||||
formatted: costData.formatted,
|
formatted: costData.formatted,
|
||||||
pricing: costData.pricing
|
pricing: costData.pricing,
|
||||||
|
isLegacy: !hasStoredCost
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -602,9 +602,7 @@ class ApiKeyService {
|
|||||||
const globalTags = await redis.getGlobalTags()
|
const globalTags = await redis.getGlobalTags()
|
||||||
// 过滤空值和空格
|
// 过滤空值和空格
|
||||||
return [
|
return [
|
||||||
...new Set(
|
...new Set([...indexTags, ...globalTags].map((t) => (t ? t.trim() : '')).filter((t) => t))
|
||||||
[...indexTags, ...globalTags].map((t) => (t ? t.trim() : '')).filter((t) => t)
|
|
||||||
)
|
|
||||||
].sort()
|
].sort()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -724,7 +722,9 @@ class ApiKeyService {
|
|||||||
const strTags = tags.filter((t) => typeof t === 'string')
|
const strTags = tags.filter((t) => typeof t === 'string')
|
||||||
if (strTags.some((t) => t.trim() === normalizedOld)) {
|
if (strTags.some((t) => t.trim() === normalizedOld)) {
|
||||||
foundInKeys = true
|
foundInKeys = true
|
||||||
const newTags = [...new Set(strTags.map((t) => (t.trim() === normalizedOld ? normalizedNew : t)))]
|
const newTags = [
|
||||||
|
...new Set(strTags.map((t) => (t.trim() === normalizedOld ? normalizedNew : t)))
|
||||||
|
]
|
||||||
await this.updateApiKey(key.id, { tags: newTags })
|
await this.updateApiKey(key.id, { tags: newTags })
|
||||||
affectedCount++
|
affectedCount++
|
||||||
}
|
}
|
||||||
@@ -732,7 +732,9 @@ class ApiKeyService {
|
|||||||
|
|
||||||
// 检查全局集合是否有该标签
|
// 检查全局集合是否有该标签
|
||||||
const globalTags = await redis.getGlobalTags()
|
const globalTags = await redis.getGlobalTags()
|
||||||
const foundInGlobal = globalTags.some((t) => typeof t === 'string' && t.trim() === normalizedOld)
|
const foundInGlobal = globalTags.some(
|
||||||
|
(t) => typeof t === 'string' && t.trim() === normalizedOld
|
||||||
|
)
|
||||||
|
|
||||||
if (!foundInKeys && !foundInGlobal) {
|
if (!foundInKeys && !foundInGlobal) {
|
||||||
return { affectedCount: 0, error: '标签不存在' }
|
return { affectedCount: 0, error: '标签不存在' }
|
||||||
@@ -1537,7 +1539,16 @@ class ApiKeyService {
|
|||||||
isLongContextRequest = totalInputTokens > 200000
|
isLongContextRequest = totalInputTokens > 200000
|
||||||
}
|
}
|
||||||
|
|
||||||
// 记录API Key级别的使用统计
|
// 计算费用(应用服务倍率)
|
||||||
|
const realCost = costInfo.costs.total
|
||||||
|
let ratedCost = realCost
|
||||||
|
if (realCost > 0) {
|
||||||
|
const serviceRatesService = require('./serviceRatesService')
|
||||||
|
const service = serviceRatesService.getService(accountType, model)
|
||||||
|
ratedCost = await this.calculateRatedCost(keyId, service, realCost)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录API Key级别的使用统计(包含费用)
|
||||||
await redis.incrementTokenUsage(
|
await redis.incrementTokenUsage(
|
||||||
keyId,
|
keyId,
|
||||||
totalTokens,
|
totalTokens,
|
||||||
@@ -1548,20 +1559,16 @@ class ApiKeyService {
|
|||||||
model,
|
model,
|
||||||
0, // ephemeral5mTokens - 暂时为0,后续处理
|
0, // ephemeral5mTokens - 暂时为0,后续处理
|
||||||
0, // ephemeral1hTokens - 暂时为0,后续处理
|
0, // ephemeral1hTokens - 暂时为0,后续处理
|
||||||
isLongContextRequest
|
isLongContextRequest,
|
||||||
|
realCost,
|
||||||
|
ratedCost
|
||||||
)
|
)
|
||||||
|
|
||||||
// 记录费用统计(应用服务倍率)
|
// 记录费用统计到每日/每月汇总
|
||||||
const realCost = costInfo.costs.total
|
|
||||||
let ratedCost = realCost
|
|
||||||
if (realCost > 0) {
|
if (realCost > 0) {
|
||||||
const serviceRatesService = require('./serviceRatesService')
|
|
||||||
const service = serviceRatesService.getService(accountType, model)
|
|
||||||
ratedCost = await this.calculateRatedCost(keyId, service, realCost)
|
|
||||||
|
|
||||||
await redis.incrementDailyCost(keyId, ratedCost, realCost)
|
await redis.incrementDailyCost(keyId, ratedCost, realCost)
|
||||||
logger.database(
|
logger.database(
|
||||||
`💰 Recorded cost for ${keyId}: rated=$${ratedCost.toFixed(6)}, real=$${realCost.toFixed(6)}, model: ${model}, service: ${service}`
|
`💰 Recorded cost for ${keyId}: rated=$${ratedCost.toFixed(6)}, real=$${realCost.toFixed(6)}, model: ${model}`
|
||||||
)
|
)
|
||||||
|
|
||||||
// 记录 Opus 周费用(如果适用)
|
// 记录 Opus 周费用(如果适用)
|
||||||
@@ -1744,7 +1751,16 @@ class ApiKeyService {
|
|||||||
ephemeral1hTokens = usageObject.cache_creation.ephemeral_1h_input_tokens || 0
|
ephemeral1hTokens = usageObject.cache_creation.ephemeral_1h_input_tokens || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// 记录API Key级别的使用统计 - 这个必须执行
|
// 计算费用(应用服务倍率)- 需要在 incrementTokenUsage 之前计算
|
||||||
|
const realCostWithDetails = costInfo.totalCost || 0
|
||||||
|
let ratedCostWithDetails = realCostWithDetails
|
||||||
|
if (realCostWithDetails > 0) {
|
||||||
|
const serviceRatesService = require('./serviceRatesService')
|
||||||
|
const service = serviceRatesService.getService(accountType, model)
|
||||||
|
ratedCostWithDetails = await this.calculateRatedCost(keyId, service, realCostWithDetails)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录API Key级别的使用统计(包含费用)
|
||||||
await redis.incrementTokenUsage(
|
await redis.incrementTokenUsage(
|
||||||
keyId,
|
keyId,
|
||||||
totalTokens,
|
totalTokens,
|
||||||
@@ -1753,27 +1769,29 @@ class ApiKeyService {
|
|||||||
cacheCreateTokens,
|
cacheCreateTokens,
|
||||||
cacheReadTokens,
|
cacheReadTokens,
|
||||||
model,
|
model,
|
||||||
ephemeral5mTokens, // 传递5分钟缓存 tokens
|
ephemeral5mTokens,
|
||||||
ephemeral1hTokens, // 传递1小时缓存 tokens
|
ephemeral1hTokens,
|
||||||
costInfo.isLongContextRequest || false // 传递 1M 上下文请求标记
|
costInfo.isLongContextRequest || false,
|
||||||
|
realCostWithDetails,
|
||||||
|
ratedCostWithDetails
|
||||||
)
|
)
|
||||||
|
|
||||||
// 记录费用统计(应用服务倍率)
|
// 记录费用到每日/每月汇总
|
||||||
const realCostWithDetails = costInfo.totalCost || 0
|
|
||||||
let ratedCostWithDetails = realCostWithDetails
|
|
||||||
if (realCostWithDetails > 0) {
|
if (realCostWithDetails > 0) {
|
||||||
const serviceRatesService = require('./serviceRatesService')
|
|
||||||
const service = serviceRatesService.getService(accountType, model)
|
|
||||||
ratedCostWithDetails = await this.calculateRatedCost(keyId, service, realCostWithDetails)
|
|
||||||
|
|
||||||
// 记录倍率成本和真实成本
|
// 记录倍率成本和真实成本
|
||||||
await redis.incrementDailyCost(keyId, ratedCostWithDetails, realCostWithDetails)
|
await redis.incrementDailyCost(keyId, ratedCostWithDetails, realCostWithDetails)
|
||||||
logger.database(
|
logger.database(
|
||||||
`💰 Recorded cost for ${keyId}: rated=$${ratedCostWithDetails.toFixed(6)}, real=$${realCostWithDetails.toFixed(6)}, model: ${model}, service: ${service}`
|
`💰 Recorded cost for ${keyId}: rated=$${ratedCostWithDetails.toFixed(6)}, real=$${realCostWithDetails.toFixed(6)}, model: ${model}`
|
||||||
)
|
)
|
||||||
|
|
||||||
// 记录 Opus 周费用(如果适用,也应用倍率)
|
// 记录 Opus 周费用(如果适用,也应用倍率)
|
||||||
await this.recordOpusCost(keyId, ratedCostWithDetails, realCostWithDetails, model, accountType)
|
await this.recordOpusCost(
|
||||||
|
keyId,
|
||||||
|
ratedCostWithDetails,
|
||||||
|
realCostWithDetails,
|
||||||
|
model,
|
||||||
|
accountType
|
||||||
|
)
|
||||||
|
|
||||||
// 记录详细的缓存费用(如果有)
|
// 记录详细的缓存费用(如果有)
|
||||||
if (costInfo.ephemeral5mCost > 0 || costInfo.ephemeral1hCost > 0) {
|
if (costInfo.ephemeral5mCost > 0 || costInfo.ephemeral1hCost > 0) {
|
||||||
@@ -2529,7 +2547,12 @@ class ApiKeyService {
|
|||||||
|
|
||||||
logger.success(`💸 Deducted $${actualDeducted} from key ${keyId}, new limit: $${newLimit}`)
|
logger.success(`💸 Deducted $${actualDeducted} from key ${keyId}, new limit: $${newLimit}`)
|
||||||
|
|
||||||
return { success: true, previousLimit: currentLimit, newTotalCostLimit: newLimit, actualDeducted }
|
return {
|
||||||
|
success: true,
|
||||||
|
previousLimit: currentLimit,
|
||||||
|
newTotalCostLimit: newLimit,
|
||||||
|
actualDeducted
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to deduct total cost limit:', error)
|
logger.error('❌ Failed to deduct total cost limit:', error)
|
||||||
throw error
|
throw error
|
||||||
|
|||||||
@@ -214,12 +214,12 @@ class ClaudeCodeHeadersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取所有账号的 headers 信息
|
* 获取所有账号的 headers 信息(使用 scanKeys 替代 keys)
|
||||||
*/
|
*/
|
||||||
async getAllAccountHeaders() {
|
async getAllAccountHeaders() {
|
||||||
try {
|
try {
|
||||||
const pattern = 'claude_code_headers:*'
|
const pattern = 'claude_code_headers:*'
|
||||||
const keys = await redis.getClient().keys(pattern)
|
const keys = await redis.scanKeys(pattern)
|
||||||
|
|
||||||
const results = {}
|
const results = {}
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
|
|||||||
@@ -125,6 +125,14 @@ const getServiceFromModel = (model) => {
|
|||||||
|
|
||||||
// 计算 CC 扣费
|
// 计算 CC 扣费
|
||||||
const calculateCcCost = (model) => {
|
const calculateCcCost = (model) => {
|
||||||
|
// 使用 isLegacy 判断是否有存储的计费费用
|
||||||
|
if (!model.isLegacy && model.costs?.rated !== undefined) {
|
||||||
|
const ccCost = model.costs.rated
|
||||||
|
if (ccCost >= 1) return '$' + ccCost.toFixed(2)
|
||||||
|
if (ccCost >= 0.01) return '$' + ccCost.toFixed(4)
|
||||||
|
return '$' + ccCost.toFixed(6)
|
||||||
|
}
|
||||||
|
// 回退到重新计算(历史数据)
|
||||||
const cost = model.costs?.total || 0
|
const cost = model.costs?.total || 0
|
||||||
if (!cost || !serviceRates.value?.rates) return '$0.00'
|
if (!cost || !serviceRates.value?.rates) return '$0.00'
|
||||||
const service = getServiceFromModel(model.model)
|
const service = getServiceFromModel(model.model)
|
||||||
|
|||||||
@@ -158,12 +158,13 @@ const serviceStats = computed(() => {
|
|||||||
outputTokens: 0,
|
outputTokens: 0,
|
||||||
cacheCreateTokens: 0,
|
cacheCreateTokens: 0,
|
||||||
cacheReadTokens: 0,
|
cacheReadTokens: 0,
|
||||||
cost: 0,
|
realCost: 0,
|
||||||
|
ratedCost: 0,
|
||||||
pricing: null
|
pricing: null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 聚合模型数据
|
// 聚合模型数据 - 按模型逐个计算计费费用
|
||||||
modelStats.value.forEach((model) => {
|
modelStats.value.forEach((model) => {
|
||||||
const service = getServiceFromModel(model.model)
|
const service = getServiceFromModel(model.model)
|
||||||
if (stats[service]) {
|
if (stats[service]) {
|
||||||
@@ -171,24 +172,35 @@ const serviceStats = computed(() => {
|
|||||||
stats[service].outputTokens += model.outputTokens || 0
|
stats[service].outputTokens += model.outputTokens || 0
|
||||||
stats[service].cacheCreateTokens += model.cacheCreateTokens || 0
|
stats[service].cacheCreateTokens += model.cacheCreateTokens || 0
|
||||||
stats[service].cacheReadTokens += model.cacheReadTokens || 0
|
stats[service].cacheReadTokens += model.cacheReadTokens || 0
|
||||||
stats[service].cost += model.costs?.total || 0
|
// 累加官方费用
|
||||||
|
const modelRealCost = model.costs?.real ?? model.costs?.total ?? 0
|
||||||
|
stats[service].realCost += modelRealCost
|
||||||
|
// 按模型判断:有存储费用用存储的,否则用当前倍率计算
|
||||||
|
const globalRate = serviceRates.value.rates[service] || 1.0
|
||||||
|
const keyRate = multiKeyMode.value ? 1.0 : (keyServiceRates.value?.[service] ?? 1.0)
|
||||||
|
const modelRatedCost =
|
||||||
|
!model.isLegacy && model.costs?.rated !== undefined
|
||||||
|
? model.costs.rated
|
||||||
|
: modelRealCost * globalRate * keyRate
|
||||||
|
stats[service].ratedCost += modelRatedCost
|
||||||
if (!stats[service].pricing && model.pricing) {
|
if (!stats[service].pricing && model.pricing) {
|
||||||
stats[service].pricing = model.pricing
|
stats[service].pricing = model.pricing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 转换为数组并计算计费费用
|
// 转换为数组
|
||||||
return Object.entries(stats)
|
return Object.entries(stats)
|
||||||
.filter(
|
.filter(
|
||||||
([, data]) =>
|
([, data]) =>
|
||||||
data.inputTokens > 0 || data.outputTokens > 0 || data.cacheCreateTokens > 0 || data.cost > 0
|
data.inputTokens > 0 ||
|
||||||
|
data.outputTokens > 0 ||
|
||||||
|
data.cacheCreateTokens > 0 ||
|
||||||
|
data.realCost > 0
|
||||||
)
|
)
|
||||||
.map(([service, data]) => {
|
.map(([service, data]) => {
|
||||||
const globalRate = serviceRates.value.rates[service] || 1.0
|
const globalRate = serviceRates.value.rates[service] || 1.0
|
||||||
// 批量模式下不使用 Key 倍率
|
|
||||||
const keyRate = multiKeyMode.value ? 1.0 : (keyServiceRates.value?.[service] ?? 1.0)
|
const keyRate = multiKeyMode.value ? 1.0 : (keyServiceRates.value?.[service] ?? 1.0)
|
||||||
const ccCostValue = data.cost * globalRate * keyRate
|
|
||||||
const p = data.pricing
|
const p = data.pricing
|
||||||
return {
|
return {
|
||||||
name: service,
|
name: service,
|
||||||
@@ -199,14 +211,14 @@ const serviceStats = computed(() => {
|
|||||||
outputTokens: data.outputTokens,
|
outputTokens: data.outputTokens,
|
||||||
cacheCreateTokens: data.cacheCreateTokens,
|
cacheCreateTokens: data.cacheCreateTokens,
|
||||||
cacheReadTokens: data.cacheReadTokens,
|
cacheReadTokens: data.cacheReadTokens,
|
||||||
officialCost: formatCost(data.cost),
|
officialCost: formatCost(data.realCost),
|
||||||
ccCost: formatCost(ccCostValue),
|
ccCost: formatCost(data.ratedCost),
|
||||||
pricing: p
|
pricing: p
|
||||||
? {
|
? {
|
||||||
input: formatCost(p.input * 1e6),
|
input: formatCost(p.input),
|
||||||
output: formatCost(p.output * 1e6),
|
output: formatCost(p.output),
|
||||||
cacheCreate: p.cacheCreate ? formatCost(p.cacheCreate * 1e6) : null,
|
cacheCreate: p.cacheCreate ? formatCost(p.cacheCreate) : null,
|
||||||
cacheRead: p.cacheRead ? formatCost(p.cacheRead * 1e6) : null
|
cacheRead: p.cacheRead ? formatCost(p.cacheRead) : null
|
||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user