Compare commits

..

6 Commits

Author SHA1 Message Date
github-actions[bot]
79f2cebdb8 chore: sync VERSION file with release v1.1.235 [skip ci] 2025-12-15 01:48:14 +00:00
Wesley Liddick
bd7b8884ab Merge pull request #801 from miraserver/fix/cost-calculation-and-ui-display
fix: correct API key cost calculation and UI display issues
2025-12-14 20:48:00 -05:00
github-actions[bot]
38e0adb499 chore: sync VERSION file with release v1.1.234 [skip ci] 2025-12-15 01:44:56 +00:00
shaw
7698f5ce11 chore: 增加opus4.5快捷映射按钮 2025-12-15 09:44:36 +08:00
shaw
ce13e5ddb1 fix: console账号转发使用白名单透传header 2025-12-15 09:38:51 +08:00
John Doe
baafebbf7b fix: correct API key cost calculation and UI display issues
- Fix admin panel cost display for "all time" period using permanent Redis key
- Fix user statistics total cost limit to show complete history
- Fix restricted models list overflow with scrollable container

Backend changes:
- src/routes/admin/apiKeys.js: Use allTimeCost for timeRange='all' instead of scanning TTL keys
- src/routes/apiStats.js: Prioritize permanent usage:cost:total key over monthly keys

Frontend changes:
- web/admin-spa/src/components/apistats/LimitConfig.vue: Add overflow-visible and scrolling to model list

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 18:11:02 +03:00
6 changed files with 94 additions and 97 deletions

View File

@@ -1 +1 @@
1.1.233
1.1.235

View File

@@ -945,6 +945,30 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
allTimeCost = parseFloat((await client.get(totalCostKey)) || '0')
}
// 🔧 FIX: 对于 "全部时间" 时间范围,直接使用 allTimeCost
// 因为 usage:*:model:daily:* 键有 30 天 TTL旧数据已经过期
if (timeRange === 'all' && allTimeCost > 0) {
logger.debug(`📊 使用 allTimeCost 计算 timeRange='all': ${allTimeCost}`)
return {
requests: 0, // 旧数据详情不可用
tokens: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
cost: allTimeCost,
formattedCost: CostCalculator.formatCost(allTimeCost),
// 实时限制数据(始终返回,不受时间范围影响)
dailyCost,
currentWindowCost,
windowRemainingSeconds,
windowStartTime,
windowEndTime,
allTimeCost
}
}
// 只在启用了窗口限制时查询窗口数据
if (rateLimitWindow > 0) {
const costCountKey = `rate_limit:cost:${keyId}`

View File

@@ -206,74 +206,85 @@ router.post('/api/user-stats', async (req, res) => {
// 获取验证结果中的完整keyData包含isActive状态和cost信息
const fullKeyData = keyData
// 计算总费用 - 使用与模型统计相同的逻辑(按模型分别计算)
// 🔧 FIX: 使用 allTimeCost 而不是扫描月度键
// 计算总费用 - 优先使用持久化的总费用计数器
let totalCost = 0
let formattedCost = '$0.000000'
try {
const client = redis.getClientSafe()
// 获取所有月度模型统计与model-stats接口相同的逻辑
const allModelKeys = await client.keys(`usage:${keyId}:model:monthly:*:*`)
const modelUsageMap = new Map()
// 读取累积的总费用(没有 TTL 的持久键
const totalCostKey = `usage:cost:total:${keyId}`
const allTimeCost = parseFloat((await client.get(totalCostKey)) || '0')
for (const key of allModelKeys) {
const modelMatch = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/)
if (!modelMatch) {
continue
}
if (allTimeCost > 0) {
totalCost = allTimeCost
formattedCost = CostCalculator.formatCost(allTimeCost)
logger.debug(`📊 使用 allTimeCost 计算用户统计: ${allTimeCost}`)
} else {
// Fallback: 如果 allTimeCost 为空(旧键),尝试月度键
const allModelKeys = await client.keys(`usage:${keyId}:model:monthly:*:*`)
const modelUsageMap = new Map()
const model = modelMatch[1]
const data = await client.hgetall(key)
if (data && Object.keys(data).length > 0) {
if (!modelUsageMap.has(model)) {
modelUsageMap.set(model, {
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0
})
for (const key of allModelKeys) {
const modelMatch = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/)
if (!modelMatch) {
continue
}
const modelUsage = modelUsageMap.get(model)
modelUsage.inputTokens += parseInt(data.inputTokens) || 0
modelUsage.outputTokens += parseInt(data.outputTokens) || 0
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
}
}
const model = modelMatch[1]
const data = await client.hgetall(key)
// 按模型计算费用并汇总
for (const [model, usage] of modelUsageMap) {
const usageData = {
input_tokens: usage.inputTokens,
output_tokens: usage.outputTokens,
cache_creation_input_tokens: usage.cacheCreateTokens,
cache_read_input_tokens: usage.cacheReadTokens
if (data && Object.keys(data).length > 0) {
if (!modelUsageMap.has(model)) {
modelUsageMap.set(model, {
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0
})
}
const modelUsage = modelUsageMap.get(model)
modelUsage.inputTokens += parseInt(data.inputTokens) || 0
modelUsage.outputTokens += parseInt(data.outputTokens) || 0
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
}
}
const costResult = CostCalculator.calculateCost(usageData, model)
totalCost += costResult.costs.total
}
// 按模型计算费用并汇总
for (const [model, usage] of modelUsageMap) {
const usageData = {
input_tokens: usage.inputTokens,
output_tokens: usage.outputTokens,
cache_creation_input_tokens: usage.cacheCreateTokens,
cache_read_input_tokens: usage.cacheReadTokens
}
// 如果没有模型级别的详细数据,回退到总体数据计算
if (modelUsageMap.size === 0 && fullKeyData.usage?.total?.allTokens > 0) {
const usage = fullKeyData.usage.total
const costUsage = {
input_tokens: usage.inputTokens || 0,
output_tokens: usage.outputTokens || 0,
cache_creation_input_tokens: usage.cacheCreateTokens || 0,
cache_read_input_tokens: usage.cacheReadTokens || 0
const costResult = CostCalculator.calculateCost(usageData, model)
totalCost += costResult.costs.total
}
const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022')
totalCost = costResult.costs.total
}
// 如果没有模型级别的详细数据,回退到总体数据计算
if (modelUsageMap.size === 0 && fullKeyData.usage?.total?.allTokens > 0) {
const usage = fullKeyData.usage.total
const costUsage = {
input_tokens: usage.inputTokens || 0,
output_tokens: usage.outputTokens || 0,
cache_creation_input_tokens: usage.cacheCreateTokens || 0,
cache_read_input_tokens: usage.cacheReadTokens || 0
}
formattedCost = CostCalculator.formatCost(totalCost)
const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022')
totalCost = costResult.costs.total
}
formattedCost = CostCalculator.formatCost(totalCost)
}
} catch (error) {
logger.warn(`Failed to calculate detailed cost for key ${keyId}:`, error)
logger.warn(`Failed to calculate cost for key ${keyId}:`, error)
// 回退到简单计算
if (fullKeyData.usage?.total?.allTokens > 0) {
const usage = fullKeyData.usage.total

View File

@@ -11,6 +11,7 @@ const {
} = require('../utils/errorSanitizer')
const userMessageQueueService = require('./userMessageQueueService')
const { isStreamWritable } = require('../utils/streamHelper')
const { filterForClaude } = require('../utils/headerFilter')
class ClaudeConsoleRelayService {
constructor() {
@@ -1302,30 +1303,9 @@ class ClaudeConsoleRelayService {
// 🔧 过滤客户端请求头
_filterClientHeaders(clientHeaders) {
const sensitiveHeaders = [
'content-type',
'user-agent',
'authorization',
'x-api-key',
'host',
'content-length',
'connection',
'proxy-authorization',
'content-encoding',
'transfer-encoding',
'anthropic-version'
]
const filteredHeaders = {}
Object.keys(clientHeaders || {}).forEach((key) => {
const lowerKey = key.toLowerCase()
if (!sensitiveHeaders.includes(lowerKey)) {
filteredHeaders[key] = clientHeaders[key]
}
})
return filteredHeaders
// 使用统一的 headerFilter 工具类(白名单模式)
// 与 claudeRelayService 保持一致,避免透传 CDN headers 触发上游 API 安全检查
return filterForClaude(clientHeaders)
}
// 🕐 更新最后使用时间

View File

@@ -1320,10 +1320,10 @@
class="rounded-lg bg-blue-100 px-3 py-1 text-xs text-blue-700 transition-colors hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50"
type="button"
@click="
addPresetMapping('claude-sonnet-4-20250514', 'claude-sonnet-4-20250514')
addPresetMapping('claude-opus-4-5-20251101', 'claude-opus-4-5-20251101')
"
>
+ Sonnet 4
+ Opus 4.5
</button>
<button
class="rounded-lg bg-indigo-100 px-3 py-1 text-xs text-indigo-700 transition-colors hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400 dark:hover:bg-indigo-900/50"
@@ -1334,24 +1334,6 @@
>
+ Sonnet 4.5
</button>
<button
class="rounded-lg bg-purple-100 px-3 py-1 text-xs text-purple-700 transition-colors hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400 dark:hover:bg-purple-900/50"
type="button"
@click="
addPresetMapping('claude-opus-4-1-20250805', 'claude-opus-4-1-20250805')
"
>
+ Opus 4.1
</button>
<button
class="rounded-lg bg-green-100 px-3 py-1 text-xs text-green-700 transition-colors hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50"
type="button"
@click="
addPresetMapping('claude-3-5-haiku-20241022', 'claude-3-5-haiku-20241022')
"
>
+ Haiku 3.5
</button>
<button
class="rounded-lg bg-emerald-100 px-3 py-1 text-xs text-emerald-700 transition-colors hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 dark:hover:bg-emerald-900/50"
type="button"

View File

@@ -284,7 +284,7 @@
</div>
<!-- 详细限制信息 -->
<div v-if="hasModelRestrictions" class="card p-4 md:p-6">
<div v-if="hasModelRestrictions" class="card !overflow-visible p-4 md:p-6">
<h3
class="mb-3 flex items-center text-lg font-bold text-gray-900 dark:text-gray-100 md:mb-4 md:text-xl"
>
@@ -301,7 +301,7 @@
<i class="fas fa-robot mr-1 text-xs md:mr-2 md:text-sm" />
受限模型列表
</h4>
<div class="space-y-1 md:space-y-2">
<div class="max-h-64 space-y-1 overflow-y-auto pr-1 md:max-h-80 md:space-y-2">
<div
v-for="model in statsData.restrictions.restrictedModels"
:key="model"