Files
claude-relay-service/src/routes/admin/usageStats.js

2507 lines
86 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const express = require('express')
const apiKeyService = require('../../services/apiKeyService')
const ccrAccountService = require('../../services/ccrAccountService')
const claudeAccountService = require('../../services/claudeAccountService')
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
const geminiAccountService = require('../../services/geminiAccountService')
const geminiApiAccountService = require('../../services/geminiApiAccountService')
const openaiAccountService = require('../../services/openaiAccountService')
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
const droidAccountService = require('../../services/droidAccountService')
const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger')
const CostCalculator = require('../../utils/costCalculator')
const pricingService = require('../../services/pricingService')
const router = express.Router()
const accountTypeNames = {
claude: 'Claude官方',
'claude-console': 'Claude Console',
ccr: 'Claude Console Relay',
openai: 'OpenAI',
'openai-responses': 'OpenAI Responses',
gemini: 'Gemini',
'gemini-api': 'Gemini API',
droid: 'Droid',
unknown: '未知渠道'
}
const resolveAccountByPlatform = async (accountId, platform) => {
const serviceMap = {
claude: claudeAccountService,
'claude-console': claudeConsoleAccountService,
gemini: geminiAccountService,
'gemini-api': geminiApiAccountService,
openai: openaiAccountService,
'openai-responses': openaiResponsesAccountService,
droid: droidAccountService,
ccr: ccrAccountService
}
if (platform && serviceMap[platform]) {
try {
const account = await serviceMap[platform].getAccount(accountId)
if (account) {
return { ...account, platform }
}
} catch (error) {
logger.debug(`⚠️ Failed to get account ${accountId} from ${platform}: ${error.message}`)
}
}
for (const [platformName, service] of Object.entries(serviceMap)) {
try {
const account = await service.getAccount(accountId)
if (account) {
return { ...account, platform: platformName }
}
} catch (error) {
logger.debug(`⚠️ Failed to get account ${accountId} from ${platformName}: ${error.message}`)
}
}
return null
}
const getApiKeyName = async (keyId) => {
try {
const keyData = await redis.getApiKey(keyId)
return keyData?.name || keyData?.label || keyId
} catch (error) {
logger.debug(`⚠️ Failed to get API key name for ${keyId}: ${error.message}`)
return keyId
}
}
// 📊 账户使用统计
// 获取所有账户的使用统计
router.get('/accounts/usage-stats', authenticateAdmin, async (req, res) => {
try {
const accountsStats = await redis.getAllAccountsUsageStats()
return res.json({
success: true,
data: accountsStats,
summary: {
totalAccounts: accountsStats.length,
activeToday: accountsStats.filter((account) => account.daily.requests > 0).length,
totalDailyTokens: accountsStats.reduce(
(sum, account) => sum + (account.daily.allTokens || 0),
0
),
totalDailyRequests: accountsStats.reduce(
(sum, account) => sum + (account.daily.requests || 0),
0
)
},
timestamp: new Date().toISOString()
})
} catch (error) {
logger.error('❌ Failed to get accounts usage stats:', error)
return res.status(500).json({
success: false,
error: 'Failed to get accounts usage stats',
message: error.message
})
}
})
// 获取单个账户的使用统计
router.get('/accounts/:accountId/usage-stats', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const accountStats = await redis.getAccountUsageStats(accountId)
// 获取账户基本信息
const accountData = await claudeAccountService.getAccount(accountId)
if (!accountData) {
return res.status(404).json({
success: false,
error: 'Account not found'
})
}
return res.json({
success: true,
data: {
...accountStats,
accountInfo: {
name: accountData.name,
email: accountData.email,
status: accountData.status,
isActive: accountData.isActive,
createdAt: accountData.createdAt
}
},
timestamp: new Date().toISOString()
})
} catch (error) {
logger.error('❌ Failed to get account usage stats:', error)
return res.status(500).json({
success: false,
error: 'Failed to get account usage stats',
message: error.message
})
}
})
// 获取账号近30天使用历史
router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const { platform = 'claude', days = 30 } = req.query
const allowedPlatforms = [
'claude',
'claude-console',
'openai',
'openai-responses',
'gemini',
'gemini-api',
'droid'
]
if (!allowedPlatforms.includes(platform)) {
return res.status(400).json({
success: false,
error: 'Unsupported account platform'
})
}
const accountTypeMap = {
openai: 'openai',
'openai-responses': 'openai-responses',
'gemini-api': 'gemini-api',
droid: 'droid'
}
const fallbackModelMap = {
claude: 'claude-3-5-sonnet-20241022',
'claude-console': 'claude-3-5-sonnet-20241022',
openai: 'gpt-4o-mini-2024-07-18',
'openai-responses': 'gpt-4o-mini-2024-07-18',
gemini: 'gemini-1.5-flash',
'gemini-api': 'gemini-2.0-flash',
droid: 'unknown'
}
// 获取账户信息以获取创建时间
let accountData = null
let accountCreatedAt = null
try {
switch (platform) {
case 'claude':
accountData = await claudeAccountService.getAccount(accountId)
break
case 'claude-console':
accountData = await claudeConsoleAccountService.getAccount(accountId)
break
case 'openai':
accountData = await openaiAccountService.getAccount(accountId)
break
case 'openai-responses':
accountData = await openaiResponsesAccountService.getAccount(accountId)
break
case 'gemini':
accountData = await geminiAccountService.getAccount(accountId)
break
case 'gemini-api': {
accountData = await geminiApiAccountService.getAccount(accountId)
break
}
case 'droid':
accountData = await droidAccountService.getAccount(accountId)
break
}
if (accountData && accountData.createdAt) {
accountCreatedAt = new Date(accountData.createdAt)
}
} catch (error) {
logger.warn(`Failed to get account data for avgDailyCost calculation: ${error.message}`)
}
const client = redis.getClientSafe()
const fallbackModel = fallbackModelMap[platform] || 'unknown'
const daysCount = Math.min(Math.max(parseInt(days, 10) || 30, 1), 60)
// 获取概览统计数据
const accountUsageStats = await redis.getAccountUsageStats(
accountId,
accountTypeMap[platform] || null
)
const history = []
let totalCost = 0
let totalRequests = 0
let totalTokens = 0
let highestCostDay = null
let highestRequestDay = null
const sumModelCostsForDay = async (dateKey) => {
const modelPattern = `account_usage:model:daily:${accountId}:*:${dateKey}`
const modelKeys = await client.keys(modelPattern)
let summedCost = 0
if (modelKeys.length === 0) {
return summedCost
}
for (const modelKey of modelKeys) {
const modelParts = modelKey.split(':')
const modelName = modelParts[4] || 'unknown'
const modelData = await client.hgetall(modelKey)
if (!modelData || Object.keys(modelData).length === 0) {
continue
}
const usage = {
input_tokens: parseInt(modelData.inputTokens) || 0,
output_tokens: parseInt(modelData.outputTokens) || 0,
cache_creation_input_tokens: parseInt(modelData.cacheCreateTokens) || 0,
cache_read_input_tokens: parseInt(modelData.cacheReadTokens) || 0
}
const costResult = CostCalculator.calculateCost(usage, modelName)
summedCost += costResult.costs.total
}
return summedCost
}
const today = new Date()
for (let offset = daysCount - 1; offset >= 0; offset--) {
const date = new Date(today)
date.setDate(date.getDate() - offset)
const tzDate = redis.getDateInTimezone(date)
const dateKey = redis.getDateStringInTimezone(date)
const monthLabel = String(tzDate.getUTCMonth() + 1).padStart(2, '0')
const dayLabel = String(tzDate.getUTCDate()).padStart(2, '0')
const label = `${monthLabel}/${dayLabel}`
const dailyKey = `account_usage:daily:${accountId}:${dateKey}`
const dailyData = await client.hgetall(dailyKey)
const inputTokens = parseInt(dailyData?.inputTokens) || 0
const outputTokens = parseInt(dailyData?.outputTokens) || 0
const cacheCreateTokens = parseInt(dailyData?.cacheCreateTokens) || 0
const cacheReadTokens = parseInt(dailyData?.cacheReadTokens) || 0
const allTokens =
parseInt(dailyData?.allTokens) ||
inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
const requests = parseInt(dailyData?.requests) || 0
let cost = await sumModelCostsForDay(dateKey)
if (cost === 0 && allTokens > 0) {
const fallbackUsage = {
input_tokens: inputTokens,
output_tokens: outputTokens,
cache_creation_input_tokens: cacheCreateTokens,
cache_read_input_tokens: cacheReadTokens
}
const fallbackResult = CostCalculator.calculateCost(fallbackUsage, fallbackModel)
cost = fallbackResult.costs.total
}
const normalizedCost = Math.round(cost * 1_000_000) / 1_000_000
totalCost += normalizedCost
totalRequests += requests
totalTokens += allTokens
if (!highestCostDay || normalizedCost > highestCostDay.cost) {
highestCostDay = {
date: dateKey,
label,
cost: normalizedCost,
formattedCost: CostCalculator.formatCost(normalizedCost)
}
}
if (!highestRequestDay || requests > highestRequestDay.requests) {
highestRequestDay = {
date: dateKey,
label,
requests
}
}
history.push({
date: dateKey,
label,
cost: normalizedCost,
formattedCost: CostCalculator.formatCost(normalizedCost),
requests,
tokens: allTokens
})
}
// 计算实际使用天数(从账户创建到现在)
let actualDaysForAvg = daysCount
if (accountCreatedAt) {
const now = new Date()
const diffTime = Math.abs(now - accountCreatedAt)
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
// 使用实际使用天数,但不超过请求的天数范围
actualDaysForAvg = Math.min(diffDays, daysCount)
// 至少为1天避免除零
actualDaysForAvg = Math.max(actualDaysForAvg, 1)
}
// 使用实际天数计算日均值
const avgDailyCost = actualDaysForAvg > 0 ? totalCost / actualDaysForAvg : 0
const avgDailyRequests = actualDaysForAvg > 0 ? totalRequests / actualDaysForAvg : 0
const avgDailyTokens = actualDaysForAvg > 0 ? totalTokens / actualDaysForAvg : 0
const todayData = history.length > 0 ? history[history.length - 1] : null
return res.json({
success: true,
data: {
history,
summary: {
days: daysCount,
actualDaysUsed: actualDaysForAvg, // 实际使用的天数(用于计算日均值)
accountCreatedAt: accountCreatedAt ? accountCreatedAt.toISOString() : null,
totalCost,
totalCostFormatted: CostCalculator.formatCost(totalCost),
totalRequests,
totalTokens,
avgDailyCost,
avgDailyCostFormatted: CostCalculator.formatCost(avgDailyCost),
avgDailyRequests,
avgDailyTokens,
today: todayData
? {
date: todayData.date,
cost: todayData.cost,
costFormatted: todayData.formattedCost,
requests: todayData.requests,
tokens: todayData.tokens
}
: null,
highestCostDay,
highestRequestDay
},
overview: accountUsageStats,
generatedAt: new Date().toISOString()
}
})
} catch (error) {
logger.error('❌ Failed to get account usage history:', error)
return res.status(500).json({
success: false,
error: 'Failed to get account usage history',
message: error.message
})
}
})
// 📊 使用趋势和成本分析
// 获取使用趋势数据
router.get('/usage-trend', authenticateAdmin, async (req, res) => {
try {
const { days = 7, granularity = 'day', startDate, endDate } = req.query
const client = redis.getClientSafe()
const trendData = []
if (granularity === 'hour') {
// 小时粒度统计
let startTime, endTime
if (startDate && endDate) {
// 使用自定义时间范围
startTime = new Date(startDate)
endTime = new Date(endDate)
// 调试日志
logger.info('📊 Usage trend hour granularity - received times:')
logger.info(` startDate (raw): ${startDate}`)
logger.info(` endDate (raw): ${endDate}`)
logger.info(` startTime (parsed): ${startTime.toISOString()}`)
logger.info(` endTime (parsed): ${endTime.toISOString()}`)
logger.info(
` System timezone offset: ${require('../../../config/config').system.timezoneOffset || 8}`
)
} else {
// 默认最近24小时
endTime = new Date()
startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000)
}
// 确保时间范围不超过24小时
const timeDiff = endTime - startTime
if (timeDiff > 24 * 60 * 60 * 1000) {
return res.status(400).json({
error: '小时粒度查询时间范围不能超过24小时'
})
}
// 按小时遍历
const currentHour = new Date(startTime)
currentHour.setMinutes(0, 0, 0)
while (currentHour <= endTime) {
// 注意前端发送的时间已经是UTC时间不需要再次转换
// 直接从currentHour生成对应系统时区的日期和小时
const tzCurrentHour = redis.getDateInTimezone(currentHour)
const dateStr = redis.getDateStringInTimezone(currentHour)
const hour = String(tzCurrentHour.getUTCHours()).padStart(2, '0')
const hourKey = `${dateStr}:${hour}`
// 获取当前小时的模型统计数据
const modelPattern = `usage:model:hourly:*:${hourKey}`
const modelKeys = await client.keys(modelPattern)
let hourInputTokens = 0
let hourOutputTokens = 0
let hourRequests = 0
let hourCacheCreateTokens = 0
let hourCacheReadTokens = 0
let hourCost = 0
for (const modelKey of modelKeys) {
const modelMatch = modelKey.match(/usage:model:hourly:(.+):\d{4}-\d{2}-\d{2}:\d{2}$/)
if (!modelMatch) {
continue
}
const model = modelMatch[1]
const data = await client.hgetall(modelKey)
if (data && Object.keys(data).length > 0) {
const modelInputTokens = parseInt(data.inputTokens) || 0
const modelOutputTokens = parseInt(data.outputTokens) || 0
const modelCacheCreateTokens = parseInt(data.cacheCreateTokens) || 0
const modelCacheReadTokens = parseInt(data.cacheReadTokens) || 0
const modelRequests = parseInt(data.requests) || 0
hourInputTokens += modelInputTokens
hourOutputTokens += modelOutputTokens
hourCacheCreateTokens += modelCacheCreateTokens
hourCacheReadTokens += modelCacheReadTokens
hourRequests += modelRequests
const modelUsage = {
input_tokens: modelInputTokens,
output_tokens: modelOutputTokens,
cache_creation_input_tokens: modelCacheCreateTokens,
cache_read_input_tokens: modelCacheReadTokens
}
const modelCostResult = CostCalculator.calculateCost(modelUsage, model)
hourCost += modelCostResult.costs.total
}
}
// 如果没有模型级别的数据尝试API Key级别的数据
if (modelKeys.length === 0) {
const pattern = `usage:hourly:*:${hourKey}`
const keys = await client.keys(pattern)
for (const key of keys) {
const data = await client.hgetall(key)
if (data) {
hourInputTokens += parseInt(data.inputTokens) || 0
hourOutputTokens += parseInt(data.outputTokens) || 0
hourRequests += parseInt(data.requests) || 0
hourCacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
hourCacheReadTokens += parseInt(data.cacheReadTokens) || 0
}
}
const usage = {
input_tokens: hourInputTokens,
output_tokens: hourOutputTokens,
cache_creation_input_tokens: hourCacheCreateTokens,
cache_read_input_tokens: hourCacheReadTokens
}
const costResult = CostCalculator.calculateCost(usage, 'unknown')
hourCost = costResult.costs.total
}
// 格式化时间标签 - 使用系统时区的显示
const tzDateForLabel = redis.getDateInTimezone(currentHour)
const month = String(tzDateForLabel.getUTCMonth() + 1).padStart(2, '0')
const day = String(tzDateForLabel.getUTCDate()).padStart(2, '0')
const hourStr = String(tzDateForLabel.getUTCHours()).padStart(2, '0')
trendData.push({
// 对于小时粒度只返回hour字段不返回date字段
hour: currentHour.toISOString(), // 保留原始ISO时间用于排序
label: `${month}/${day} ${hourStr}:00`, // 添加格式化的标签
inputTokens: hourInputTokens,
outputTokens: hourOutputTokens,
requests: hourRequests,
cacheCreateTokens: hourCacheCreateTokens,
cacheReadTokens: hourCacheReadTokens,
totalTokens:
hourInputTokens + hourOutputTokens + hourCacheCreateTokens + hourCacheReadTokens,
cost: hourCost
})
// 移到下一个小时
currentHour.setHours(currentHour.getHours() + 1)
}
} else {
// 天粒度统计(保持原有逻辑)
const daysCount = parseInt(days) || 7
const today = new Date()
// 获取过去N天的数据
for (let i = 0; i < daysCount; i++) {
const date = new Date(today)
date.setDate(date.getDate() - i)
const dateStr = redis.getDateStringInTimezone(date)
// 汇总当天所有API Key的使用数据
const pattern = `usage:daily:*:${dateStr}`
const keys = await client.keys(pattern)
let dayInputTokens = 0
let dayOutputTokens = 0
let dayRequests = 0
let dayCacheCreateTokens = 0
let dayCacheReadTokens = 0
let dayCost = 0
// 按模型统计使用量
// const modelUsageMap = new Map();
// 获取当天所有模型的使用数据
const modelPattern = `usage:model:daily:*:${dateStr}`
const modelKeys = await client.keys(modelPattern)
for (const modelKey of modelKeys) {
// 解析模型名称
const modelMatch = modelKey.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/)
if (!modelMatch) {
continue
}
const model = modelMatch[1]
const data = await client.hgetall(modelKey)
if (data && Object.keys(data).length > 0) {
const modelInputTokens = parseInt(data.inputTokens) || 0
const modelOutputTokens = parseInt(data.outputTokens) || 0
const modelCacheCreateTokens = parseInt(data.cacheCreateTokens) || 0
const modelCacheReadTokens = parseInt(data.cacheReadTokens) || 0
const modelRequests = parseInt(data.requests) || 0
// 累加总数
dayInputTokens += modelInputTokens
dayOutputTokens += modelOutputTokens
dayCacheCreateTokens += modelCacheCreateTokens
dayCacheReadTokens += modelCacheReadTokens
dayRequests += modelRequests
// 按模型计算费用
const modelUsage = {
input_tokens: modelInputTokens,
output_tokens: modelOutputTokens,
cache_creation_input_tokens: modelCacheCreateTokens,
cache_read_input_tokens: modelCacheReadTokens
}
const modelCostResult = CostCalculator.calculateCost(modelUsage, model)
dayCost += modelCostResult.costs.total
}
}
// 如果没有模型级别的数据,回退到原始方法
if (modelKeys.length === 0 && keys.length > 0) {
for (const key of keys) {
const data = await client.hgetall(key)
if (data) {
dayInputTokens += parseInt(data.inputTokens) || 0
dayOutputTokens += parseInt(data.outputTokens) || 0
dayRequests += parseInt(data.requests) || 0
dayCacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
dayCacheReadTokens += parseInt(data.cacheReadTokens) || 0
}
}
// 使用默认模型价格计算
const usage = {
input_tokens: dayInputTokens,
output_tokens: dayOutputTokens,
cache_creation_input_tokens: dayCacheCreateTokens,
cache_read_input_tokens: dayCacheReadTokens
}
const costResult = CostCalculator.calculateCost(usage, 'unknown')
dayCost = costResult.costs.total
}
trendData.push({
date: dateStr,
inputTokens: dayInputTokens,
outputTokens: dayOutputTokens,
requests: dayRequests,
cacheCreateTokens: dayCacheCreateTokens,
cacheReadTokens: dayCacheReadTokens,
totalTokens: dayInputTokens + dayOutputTokens + dayCacheCreateTokens + dayCacheReadTokens,
cost: dayCost,
formattedCost: CostCalculator.formatCost(dayCost)
})
}
}
// 按日期正序排列
if (granularity === 'hour') {
trendData.sort((a, b) => new Date(a.hour) - new Date(b.hour))
} else {
trendData.sort((a, b) => new Date(a.date) - new Date(b.date))
}
return res.json({ success: true, data: trendData, granularity })
} catch (error) {
logger.error('❌ Failed to get usage trend:', error)
return res.status(500).json({ error: 'Failed to get usage trend', message: error.message })
}
})
// 获取单个API Key的模型统计
router.get('/api-keys/:keyId/model-stats', authenticateAdmin, async (req, res) => {
try {
const { keyId } = req.params
const { period = 'monthly', startDate, endDate } = req.query
logger.info(
`📊 Getting model stats for API key: ${keyId}, period: ${period}, startDate: ${startDate}, endDate: ${endDate}`
)
const client = redis.getClientSafe()
const today = redis.getDateStringInTimezone()
const tzDate = redis.getDateInTimezone()
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
2,
'0'
)}`
let searchPatterns = []
if (period === 'custom' && startDate && endDate) {
// 自定义日期范围,生成多个日期的搜索模式
const start = new Date(startDate)
const end = new Date(endDate)
// 确保日期范围有效
if (start > end) {
return res.status(400).json({ error: 'Start date must be before or equal to end date' })
}
// 限制最大范围为365天
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
if (daysDiff > 365) {
return res.status(400).json({ error: 'Date range cannot exceed 365 days' })
}
// 生成日期范围内所有日期的搜索模式
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
const dateStr = redis.getDateStringInTimezone(d)
searchPatterns.push(`usage:${keyId}:model:daily:*:${dateStr}`)
}
logger.info(
`📊 Custom date range patterns: ${searchPatterns.length} days from ${startDate} to ${endDate}`
)
} else {
// 原有的预设期间逻辑
const pattern =
period === 'daily'
? `usage:${keyId}:model:daily:*:${today}`
: `usage:${keyId}:model:monthly:*:${currentMonth}`
searchPatterns = [pattern]
logger.info(`📊 Preset period pattern: ${pattern}`)
}
// 汇总所有匹配的数据
const modelStatsMap = new Map()
const modelStats = [] // 定义结果数组
for (const pattern of searchPatterns) {
const keys = await client.keys(pattern)
logger.info(`📊 Pattern ${pattern} found ${keys.length} keys`)
for (const key of keys) {
const match =
key.match(/usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/) ||
key.match(/usage:.+:model:monthly:(.+):\d{4}-\d{2}$/)
if (!match) {
logger.warn(`📊 Pattern mismatch for key: ${key}`)
continue
}
const model = match[1]
const data = await client.hgetall(key)
if (data && Object.keys(data).length > 0) {
// 累加同一模型的数据
if (!modelStatsMap.has(model)) {
modelStatsMap.set(model, {
requests: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
allTokens: 0
})
}
const stats = modelStatsMap.get(model)
stats.requests += parseInt(data.requests) || 0
stats.inputTokens += parseInt(data.inputTokens) || 0
stats.outputTokens += parseInt(data.outputTokens) || 0
stats.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
stats.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
stats.allTokens += parseInt(data.allTokens) || 0
}
}
}
// 将汇总的数据转换为最终结果
for (const [model, stats] of modelStatsMap) {
logger.info(`📊 Model ${model} aggregated data:`, stats)
const usage = {
input_tokens: stats.inputTokens,
output_tokens: stats.outputTokens,
cache_creation_input_tokens: stats.cacheCreateTokens,
cache_read_input_tokens: stats.cacheReadTokens
}
// 使用CostCalculator计算费用
const costData = CostCalculator.calculateCost(usage, model)
modelStats.push({
model,
requests: stats.requests,
inputTokens: stats.inputTokens,
outputTokens: stats.outputTokens,
cacheCreateTokens: stats.cacheCreateTokens,
cacheReadTokens: stats.cacheReadTokens,
allTokens: stats.allTokens,
// 添加费用信息
costs: costData.costs,
formatted: costData.formatted,
pricing: costData.pricing,
usingDynamicPricing: costData.usingDynamicPricing
})
}
// 如果没有找到模型级别的详细数据,尝试从汇总数据中生成展示
if (modelStats.length === 0) {
logger.info(
`📊 No detailed model stats found, trying to get aggregate data for API key ${keyId}`
)
// 尝试从API Keys列表中获取usage数据作为备选方案
try {
const apiKeys = await apiKeyService.getAllApiKeys()
const targetApiKey = apiKeys.find((key) => key.id === keyId)
if (targetApiKey && targetApiKey.usage) {
logger.info(
`📊 Found API key usage data from getAllApiKeys for ${keyId}:`,
targetApiKey.usage
)
// 从汇总数据创建展示条目
let usageData
if (period === 'custom' || period === 'daily') {
// 对于自定义或日统计使用daily数据或total数据
usageData = targetApiKey.usage.daily || targetApiKey.usage.total
} else {
// 对于月统计使用monthly数据或total数据
usageData = targetApiKey.usage.monthly || targetApiKey.usage.total
}
if (usageData && usageData.allTokens > 0) {
const usage = {
input_tokens: usageData.inputTokens || 0,
output_tokens: usageData.outputTokens || 0,
cache_creation_input_tokens: usageData.cacheCreateTokens || 0,
cache_read_input_tokens: usageData.cacheReadTokens || 0
}
// 对于汇总数据,使用默认模型计算费用
const costData = CostCalculator.calculateCost(usage, 'claude-3-5-sonnet-20241022')
modelStats.push({
model: '总体使用 (历史数据)',
requests: usageData.requests || 0,
inputTokens: usageData.inputTokens || 0,
outputTokens: usageData.outputTokens || 0,
cacheCreateTokens: usageData.cacheCreateTokens || 0,
cacheReadTokens: usageData.cacheReadTokens || 0,
allTokens: usageData.allTokens || 0,
// 添加费用信息
costs: costData.costs,
formatted: costData.formatted,
pricing: costData.pricing,
usingDynamicPricing: costData.usingDynamicPricing
})
logger.info('📊 Generated display data from API key usage stats')
} else {
logger.info(`📊 No usage data found for period ${period} in API key data`)
}
} else {
logger.info(`📊 API key ${keyId} not found or has no usage data`)
}
} catch (error) {
logger.error('❌ Error fetching API key usage data:', error)
}
}
// 按总token数降序排列
modelStats.sort((a, b) => b.allTokens - a.allTokens)
logger.info(`📊 Returning ${modelStats.length} model stats for API key ${keyId}:`, modelStats)
return res.json({ success: true, data: modelStats })
} catch (error) {
logger.error('❌ Failed to get API key model stats:', error)
return res
.status(500)
.json({ error: 'Failed to get API key model stats', message: error.message })
}
})
// 获取按账号分组的使用趋势
router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
try {
const { granularity = 'day', group = 'claude', days = 7, startDate, endDate } = req.query
const allowedGroups = ['claude', 'openai', 'gemini', 'droid']
if (!allowedGroups.includes(group)) {
return res.status(400).json({
success: false,
error: 'Invalid account group'
})
}
const groupLabels = {
claude: 'Claude账户',
openai: 'OpenAI账户',
gemini: 'Gemini账户',
droid: 'Droid账户'
}
// 拉取各平台账号列表
let accounts = []
if (group === 'claude') {
const [claudeAccounts, claudeConsoleAccounts] = await Promise.all([
claudeAccountService.getAllAccounts(),
claudeConsoleAccountService.getAllAccounts()
])
accounts = [
...claudeAccounts.map((account) => {
const id = String(account.id || '')
const shortId = id ? id.slice(0, 8) : '未知'
return {
id,
name: account.name || account.email || `Claude账号 ${shortId}`,
platform: 'claude'
}
}),
...claudeConsoleAccounts.map((account) => {
const id = String(account.id || '')
const shortId = id ? id.slice(0, 8) : '未知'
return {
id,
name: account.name || `Console账号 ${shortId}`,
platform: 'claude-console'
}
})
]
} else if (group === 'openai') {
const [openaiAccounts, openaiResponsesAccounts] = await Promise.all([
openaiAccountService.getAllAccounts(),
openaiResponsesAccountService.getAllAccounts(true)
])
accounts = [
...openaiAccounts.map((account) => {
const id = String(account.id || '')
const shortId = id ? id.slice(0, 8) : '未知'
return {
id,
name: account.name || account.email || `OpenAI账号 ${shortId}`,
platform: 'openai'
}
}),
...openaiResponsesAccounts.map((account) => {
const id = String(account.id || '')
const shortId = id ? id.slice(0, 8) : '未知'
return {
id,
name: account.name || `Responses账号 ${shortId}`,
platform: 'openai-responses'
}
})
]
} else if (group === 'gemini') {
const [geminiAccounts, geminiApiAccounts] = await Promise.all([
geminiAccountService.getAllAccounts(),
geminiApiAccountService.getAllAccounts(true)
])
accounts = [
...geminiAccounts.map((account) => {
const id = String(account.id || '')
const shortId = id ? id.slice(0, 8) : '未知'
return {
id,
name: account.name || account.email || `Gemini账号 ${shortId}`,
platform: 'gemini'
}
}),
...geminiApiAccounts.map((account) => {
const id = String(account.id || '')
const shortId = id ? id.slice(0, 8) : '未知'
return {
id,
name: account.name || `Gemini-API账号 ${shortId}`,
platform: 'gemini-api'
}
})
]
} else if (group === 'droid') {
const droidAccounts = await droidAccountService.getAllAccounts()
accounts = droidAccounts.map((account) => {
const id = String(account.id || '')
const shortId = id ? id.slice(0, 8) : '未知'
return {
id,
name: account.name || account.ownerEmail || account.ownerName || `Droid账号 ${shortId}`,
platform: 'droid'
}
})
}
if (!accounts || accounts.length === 0) {
return res.json({
success: true,
data: [],
granularity,
group,
groupLabel: groupLabels[group],
topAccounts: [],
totalAccounts: 0
})
}
const accountMap = new Map()
const accountIdSet = new Set()
for (const account of accounts) {
accountMap.set(account.id, {
name: account.name,
platform: account.platform
})
accountIdSet.add(account.id)
}
const fallbackModelByGroup = {
claude: 'claude-3-5-sonnet-20241022',
openai: 'gpt-4o-mini-2024-07-18',
gemini: 'gemini-1.5-flash'
}
const fallbackModel = fallbackModelByGroup[group] || 'unknown'
const client = redis.getClientSafe()
const trendData = []
const accountCostTotals = new Map()
const sumModelCosts = async (accountId, period, timeKey) => {
const modelPattern = `account_usage:model:${period}:${accountId}:*:${timeKey}`
const modelKeys = await client.keys(modelPattern)
let totalCost = 0
for (const modelKey of modelKeys) {
const modelData = await client.hgetall(modelKey)
if (!modelData) {
continue
}
const parts = modelKey.split(':')
if (parts.length < 5) {
continue
}
const modelName = parts[4]
const usage = {
input_tokens: parseInt(modelData.inputTokens) || 0,
output_tokens: parseInt(modelData.outputTokens) || 0,
cache_creation_input_tokens: parseInt(modelData.cacheCreateTokens) || 0,
cache_read_input_tokens: parseInt(modelData.cacheReadTokens) || 0
}
const costResult = CostCalculator.calculateCost(usage, modelName)
totalCost += costResult.costs.total
}
return totalCost
}
if (granularity === 'hour') {
let startTime
let endTime
if (startDate && endDate) {
startTime = new Date(startDate)
endTime = new Date(endDate)
} else {
endTime = new Date()
startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000)
}
const currentHour = new Date(startTime)
currentHour.setMinutes(0, 0, 0)
while (currentHour <= endTime) {
const tzCurrentHour = redis.getDateInTimezone(currentHour)
const dateStr = redis.getDateStringInTimezone(currentHour)
const hour = String(tzCurrentHour.getUTCHours()).padStart(2, '0')
const hourKey = `${dateStr}:${hour}`
const tzDateForLabel = redis.getDateInTimezone(currentHour)
const monthLabel = String(tzDateForLabel.getUTCMonth() + 1).padStart(2, '0')
const dayLabel = String(tzDateForLabel.getUTCDate()).padStart(2, '0')
const hourLabel = String(tzDateForLabel.getUTCHours()).padStart(2, '0')
const hourData = {
hour: currentHour.toISOString(),
label: `${monthLabel}/${dayLabel} ${hourLabel}:00`,
accounts: {}
}
const pattern = `account_usage:hourly:*:${hourKey}`
const keys = await client.keys(pattern)
for (const key of keys) {
const match = key.match(/account_usage:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/)
if (!match) {
continue
}
const accountId = match[1]
if (!accountIdSet.has(accountId)) {
continue
}
const data = await client.hgetall(key)
if (!data) {
continue
}
const inputTokens = parseInt(data.inputTokens) || 0
const outputTokens = parseInt(data.outputTokens) || 0
const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0
const cacheReadTokens = parseInt(data.cacheReadTokens) || 0
const allTokens =
parseInt(data.allTokens) ||
inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
const requests = parseInt(data.requests) || 0
let cost = await sumModelCosts(accountId, 'hourly', hourKey)
if (cost === 0 && allTokens > 0) {
const fallbackUsage = {
input_tokens: inputTokens,
output_tokens: outputTokens,
cache_creation_input_tokens: cacheCreateTokens,
cache_read_input_tokens: cacheReadTokens
}
const fallbackResult = CostCalculator.calculateCost(fallbackUsage, fallbackModel)
cost = fallbackResult.costs.total
}
const formattedCost = CostCalculator.formatCost(cost)
const accountInfo = accountMap.get(accountId)
hourData.accounts[accountId] = {
name: accountInfo ? accountInfo.name : `账号 ${accountId.slice(0, 8)}`,
cost,
formattedCost,
requests
}
accountCostTotals.set(accountId, (accountCostTotals.get(accountId) || 0) + cost)
}
trendData.push(hourData)
currentHour.setHours(currentHour.getHours() + 1)
}
} else {
const daysCount = parseInt(days) || 7
const today = new Date()
for (let i = 0; i < daysCount; i++) {
const date = new Date(today)
date.setDate(date.getDate() - i)
const dateStr = redis.getDateStringInTimezone(date)
const dayData = {
date: dateStr,
accounts: {}
}
const pattern = `account_usage:daily:*:${dateStr}`
const keys = await client.keys(pattern)
for (const key of keys) {
const match = key.match(/account_usage:daily:(.+?):\d{4}-\d{2}-\d{2}/)
if (!match) {
continue
}
const accountId = match[1]
if (!accountIdSet.has(accountId)) {
continue
}
const data = await client.hgetall(key)
if (!data) {
continue
}
const inputTokens = parseInt(data.inputTokens) || 0
const outputTokens = parseInt(data.outputTokens) || 0
const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0
const cacheReadTokens = parseInt(data.cacheReadTokens) || 0
const allTokens =
parseInt(data.allTokens) ||
inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
const requests = parseInt(data.requests) || 0
let cost = await sumModelCosts(accountId, 'daily', dateStr)
if (cost === 0 && allTokens > 0) {
const fallbackUsage = {
input_tokens: inputTokens,
output_tokens: outputTokens,
cache_creation_input_tokens: cacheCreateTokens,
cache_read_input_tokens: cacheReadTokens
}
const fallbackResult = CostCalculator.calculateCost(fallbackUsage, fallbackModel)
cost = fallbackResult.costs.total
}
const formattedCost = CostCalculator.formatCost(cost)
const accountInfo = accountMap.get(accountId)
dayData.accounts[accountId] = {
name: accountInfo ? accountInfo.name : `账号 ${accountId.slice(0, 8)}`,
cost,
formattedCost,
requests
}
accountCostTotals.set(accountId, (accountCostTotals.get(accountId) || 0) + cost)
}
trendData.push(dayData)
}
}
if (granularity === 'hour') {
trendData.sort((a, b) => new Date(a.hour) - new Date(b.hour))
} else {
trendData.sort((a, b) => new Date(a.date) - new Date(b.date))
}
const topAccounts = Array.from(accountCostTotals.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 20)
.map(([accountId]) => accountId)
return res.json({
success: true,
data: trendData,
granularity,
group,
groupLabel: groupLabels[group],
topAccounts,
totalAccounts: accountCostTotals.size
})
} catch (error) {
logger.error('❌ Failed to get account usage trend:', error)
return res
.status(500)
.json({ error: 'Failed to get account usage trend', message: error.message })
}
})
// 获取按API Key分组的使用趋势
router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => {
try {
const { granularity = 'day', days = 7, startDate, endDate } = req.query
logger.info(`📊 Getting API keys usage trend, granularity: ${granularity}, days: ${days}`)
const client = redis.getClientSafe()
const trendData = []
// 获取所有API Keys
const apiKeys = await apiKeyService.getAllApiKeys()
const apiKeyMap = new Map(apiKeys.map((key) => [key.id, key]))
if (granularity === 'hour') {
// 小时粒度统计
let endTime, startTime
if (startDate && endDate) {
// 自定义时间范围
startTime = new Date(startDate)
endTime = new Date(endDate)
} else {
// 默认近24小时
endTime = new Date()
startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000)
}
// 按小时遍历
const currentHour = new Date(startTime)
currentHour.setMinutes(0, 0, 0)
while (currentHour <= endTime) {
// 使用时区转换后的时间来生成键
const tzCurrentHour = redis.getDateInTimezone(currentHour)
const dateStr = redis.getDateStringInTimezone(currentHour)
const hour = String(tzCurrentHour.getUTCHours()).padStart(2, '0')
const hourKey = `${dateStr}:${hour}`
// 获取这个小时所有API Key的数据
const pattern = `usage:hourly:*:${hourKey}`
const keys = await client.keys(pattern)
// 格式化时间标签
const tzDateForLabel = redis.getDateInTimezone(currentHour)
const monthLabel = String(tzDateForLabel.getUTCMonth() + 1).padStart(2, '0')
const dayLabel = String(tzDateForLabel.getUTCDate()).padStart(2, '0')
const hourLabel = String(tzDateForLabel.getUTCHours()).padStart(2, '0')
const hourData = {
hour: currentHour.toISOString(), // 使用原始时间,不进行时区转换
label: `${monthLabel}/${dayLabel} ${hourLabel}:00`, // 添加格式化的标签
apiKeys: {}
}
// 先收集基础数据
const apiKeyDataMap = new Map()
for (const key of keys) {
const match = key.match(/usage:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/)
if (!match) {
continue
}
const apiKeyId = match[1]
const data = await client.hgetall(key)
if (data && apiKeyMap.has(apiKeyId)) {
const inputTokens = parseInt(data.inputTokens) || 0
const outputTokens = parseInt(data.outputTokens) || 0
const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0
const cacheReadTokens = parseInt(data.cacheReadTokens) || 0
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
apiKeyDataMap.set(apiKeyId, {
name: apiKeyMap.get(apiKeyId).name,
tokens: totalTokens,
requests: parseInt(data.requests) || 0,
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens
})
}
}
// 获取该小时的模型级别数据来计算准确费用
const modelPattern = `usage:*:model:hourly:*:${hourKey}`
const modelKeys = await client.keys(modelPattern)
const apiKeyCostMap = new Map()
for (const modelKey of modelKeys) {
const match = modelKey.match(/usage:(.+?):model:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/)
if (!match) {
continue
}
const apiKeyId = match[1]
const model = match[2]
const modelData = await client.hgetall(modelKey)
if (modelData && apiKeyDataMap.has(apiKeyId)) {
const usage = {
input_tokens: parseInt(modelData.inputTokens) || 0,
output_tokens: parseInt(modelData.outputTokens) || 0,
cache_creation_input_tokens: parseInt(modelData.cacheCreateTokens) || 0,
cache_read_input_tokens: parseInt(modelData.cacheReadTokens) || 0
}
const costResult = CostCalculator.calculateCost(usage, model)
const currentCost = apiKeyCostMap.get(apiKeyId) || 0
apiKeyCostMap.set(apiKeyId, currentCost + costResult.costs.total)
}
}
// 组合数据
for (const [apiKeyId, data] of apiKeyDataMap) {
const cost = apiKeyCostMap.get(apiKeyId) || 0
// 如果没有模型级别数据,使用默认模型计算(降级方案)
let finalCost = cost
let formattedCost = CostCalculator.formatCost(cost)
if (cost === 0 && data.tokens > 0) {
const usage = {
input_tokens: data.inputTokens,
output_tokens: data.outputTokens,
cache_creation_input_tokens: data.cacheCreateTokens,
cache_read_input_tokens: data.cacheReadTokens
}
const fallbackResult = CostCalculator.calculateCost(usage, 'claude-3-5-sonnet-20241022')
finalCost = fallbackResult.costs.total
formattedCost = fallbackResult.formatted.total
}
hourData.apiKeys[apiKeyId] = {
name: data.name,
tokens: data.tokens,
requests: data.requests,
cost: finalCost,
formattedCost
}
}
trendData.push(hourData)
currentHour.setHours(currentHour.getHours() + 1)
}
} else {
// 天粒度统计
const daysCount = parseInt(days) || 7
const today = new Date()
// 获取过去N天的数据
for (let i = 0; i < daysCount; i++) {
const date = new Date(today)
date.setDate(date.getDate() - i)
const dateStr = redis.getDateStringInTimezone(date)
// 获取这一天所有API Key的数据
const pattern = `usage:daily:*:${dateStr}`
const keys = await client.keys(pattern)
const dayData = {
date: dateStr,
apiKeys: {}
}
// 先收集基础数据
const apiKeyDataMap = new Map()
for (const key of keys) {
const match = key.match(/usage:daily:(.+?):\d{4}-\d{2}-\d{2}/)
if (!match) {
continue
}
const apiKeyId = match[1]
const data = await client.hgetall(key)
if (data && apiKeyMap.has(apiKeyId)) {
const inputTokens = parseInt(data.inputTokens) || 0
const outputTokens = parseInt(data.outputTokens) || 0
const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0
const cacheReadTokens = parseInt(data.cacheReadTokens) || 0
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
apiKeyDataMap.set(apiKeyId, {
name: apiKeyMap.get(apiKeyId).name,
tokens: totalTokens,
requests: parseInt(data.requests) || 0,
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens
})
}
}
// 获取该天的模型级别数据来计算准确费用
const modelPattern = `usage:*:model:daily:*:${dateStr}`
const modelKeys = await client.keys(modelPattern)
const apiKeyCostMap = new Map()
for (const modelKey of modelKeys) {
const match = modelKey.match(/usage:(.+?):model:daily:(.+?):\d{4}-\d{2}-\d{2}/)
if (!match) {
continue
}
const apiKeyId = match[1]
const model = match[2]
const modelData = await client.hgetall(modelKey)
if (modelData && apiKeyDataMap.has(apiKeyId)) {
const usage = {
input_tokens: parseInt(modelData.inputTokens) || 0,
output_tokens: parseInt(modelData.outputTokens) || 0,
cache_creation_input_tokens: parseInt(modelData.cacheCreateTokens) || 0,
cache_read_input_tokens: parseInt(modelData.cacheReadTokens) || 0
}
const costResult = CostCalculator.calculateCost(usage, model)
const currentCost = apiKeyCostMap.get(apiKeyId) || 0
apiKeyCostMap.set(apiKeyId, currentCost + costResult.costs.total)
}
}
// 组合数据
for (const [apiKeyId, data] of apiKeyDataMap) {
const cost = apiKeyCostMap.get(apiKeyId) || 0
// 如果没有模型级别数据,使用默认模型计算(降级方案)
let finalCost = cost
let formattedCost = CostCalculator.formatCost(cost)
if (cost === 0 && data.tokens > 0) {
const usage = {
input_tokens: data.inputTokens,
output_tokens: data.outputTokens,
cache_creation_input_tokens: data.cacheCreateTokens,
cache_read_input_tokens: data.cacheReadTokens
}
const fallbackResult = CostCalculator.calculateCost(usage, 'claude-3-5-sonnet-20241022')
finalCost = fallbackResult.costs.total
formattedCost = fallbackResult.formatted.total
}
dayData.apiKeys[apiKeyId] = {
name: data.name,
tokens: data.tokens,
requests: data.requests,
cost: finalCost,
formattedCost
}
}
trendData.push(dayData)
}
}
// 按时间正序排列
if (granularity === 'hour') {
trendData.sort((a, b) => new Date(a.hour) - new Date(b.hour))
} else {
trendData.sort((a, b) => new Date(a.date) - new Date(b.date))
}
// 计算每个API Key的总token数用于排序
const apiKeyTotals = new Map()
for (const point of trendData) {
for (const [apiKeyId, data] of Object.entries(point.apiKeys)) {
apiKeyTotals.set(apiKeyId, (apiKeyTotals.get(apiKeyId) || 0) + data.tokens)
}
}
// 获取前10个使用量最多的API Key
const topApiKeys = Array.from(apiKeyTotals.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([apiKeyId]) => apiKeyId)
return res.json({
success: true,
data: trendData,
granularity,
topApiKeys,
totalApiKeys: apiKeyTotals.size
})
} catch (error) {
logger.error('❌ Failed to get API keys usage trend:', error)
return res
.status(500)
.json({ error: 'Failed to get API keys usage trend', message: error.message })
}
})
// 计算总体使用费用
router.get('/usage-costs', authenticateAdmin, async (req, res) => {
try {
const { period = 'all' } = req.query // all, today, monthly, 7days
logger.info(`💰 Calculating usage costs for period: ${period}`)
// 模型名标准化函数与redis.js保持一致
const normalizeModelName = (model) => {
if (!model || model === 'unknown') {
return model
}
// 对于Bedrock模型去掉区域前缀进行统一
if (model.includes('.anthropic.') || model.includes('.claude')) {
// 匹配所有AWS区域格式region.anthropic.model-name-v1:0 -> claude-model-name
// 支持所有AWS区域格式us-east-1, eu-west-1, ap-southeast-1, ca-central-1等
let normalized = model.replace(/^[a-z0-9-]+\./, '') // 去掉任何区域前缀(更通用)
normalized = normalized.replace('anthropic.', '') // 去掉anthropic前缀
normalized = normalized.replace(/-v\d+:\d+$/, '') // 去掉版本后缀(如-v1:0, -v2:1等
return normalized
}
// 对于其他模型,去掉常见的版本后缀
return model.replace(/-v\d+:\d+$|:latest$/, '')
}
// 获取所有API Keys的使用统计
const apiKeys = await apiKeyService.getAllApiKeys()
const totalCosts = {
inputCost: 0,
outputCost: 0,
cacheCreateCost: 0,
cacheReadCost: 0,
totalCost: 0
}
const modelCosts = {}
// 按模型统计费用
const client = redis.getClientSafe()
const today = redis.getDateStringInTimezone()
const tzDate = redis.getDateInTimezone()
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
2,
'0'
)}`
let pattern
if (period === 'today') {
pattern = `usage:model:daily:*:${today}`
} else if (period === 'monthly') {
pattern = `usage:model:monthly:*:${currentMonth}`
} else if (period === '7days') {
// 最近7天汇总daily数据
const modelUsageMap = new Map()
// 获取最近7天的所有daily统计数据
for (let i = 0; i < 7; i++) {
const date = new Date()
date.setDate(date.getDate() - i)
const currentTzDate = redis.getDateInTimezone(date)
const dateStr = `${currentTzDate.getUTCFullYear()}-${String(
currentTzDate.getUTCMonth() + 1
).padStart(2, '0')}-${String(currentTzDate.getUTCDate()).padStart(2, '0')}`
const dayPattern = `usage:model:daily:*:${dateStr}`
const dayKeys = await client.keys(dayPattern)
for (const key of dayKeys) {
const modelMatch = key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/)
if (!modelMatch) {
continue
}
const rawModel = modelMatch[1]
const normalizedModel = normalizeModelName(rawModel)
const data = await client.hgetall(key)
if (data && Object.keys(data).length > 0) {
if (!modelUsageMap.has(normalizedModel)) {
modelUsageMap.set(normalizedModel, {
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0
})
}
const modelUsage = modelUsageMap.get(normalizedModel)
modelUsage.inputTokens += parseInt(data.inputTokens) || 0
modelUsage.outputTokens += parseInt(data.outputTokens) || 0
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
}
}
}
// 计算7天统计的费用
logger.info(`💰 Processing ${modelUsageMap.size} unique models for 7days cost calculation`)
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
}
const costResult = CostCalculator.calculateCost(usageData, model)
totalCosts.inputCost += costResult.costs.input
totalCosts.outputCost += costResult.costs.output
totalCosts.cacheCreateCost += costResult.costs.cacheWrite
totalCosts.cacheReadCost += costResult.costs.cacheRead
totalCosts.totalCost += costResult.costs.total
logger.info(
`💰 Model ${model} (7days): ${
usage.inputTokens + usage.outputTokens + usage.cacheCreateTokens + usage.cacheReadTokens
} tokens, cost: ${costResult.formatted.total}`
)
// 记录模型费用
modelCosts[model] = {
model,
requests: 0, // 7天汇总数据没有请求数统计
usage: usageData,
costs: costResult.costs,
formatted: costResult.formatted,
usingDynamicPricing: costResult.usingDynamicPricing
}
}
// 返回7天统计结果
return res.json({
success: true,
data: {
period,
totalCosts: {
...totalCosts,
formatted: {
inputCost: CostCalculator.formatCost(totalCosts.inputCost),
outputCost: CostCalculator.formatCost(totalCosts.outputCost),
cacheCreateCost: CostCalculator.formatCost(totalCosts.cacheCreateCost),
cacheReadCost: CostCalculator.formatCost(totalCosts.cacheReadCost),
totalCost: CostCalculator.formatCost(totalCosts.totalCost)
}
},
modelCosts: Object.values(modelCosts)
}
})
} else {
// 全部时间先尝试从Redis获取所有历史模型统计数据只使用monthly数据避免重复计算
const allModelKeys = await client.keys('usage:model:monthly:*:*')
logger.info(`💰 Total period calculation: found ${allModelKeys.length} monthly model keys`)
if (allModelKeys.length > 0) {
// 如果有详细的模型统计数据,使用模型级别的计算
const modelUsageMap = new Map()
for (const key of allModelKeys) {
// 解析模型名称只处理monthly数据
const modelMatch = key.match(/usage:model:monthly:(.+):(\d{4}-\d{2})$/)
if (!modelMatch) {
continue
}
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
})
}
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
}
}
// 使用模型级别的数据计算费用
logger.info(`💰 Processing ${modelUsageMap.size} unique models for total cost calculation`)
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
}
const costResult = CostCalculator.calculateCost(usageData, model)
totalCosts.inputCost += costResult.costs.input
totalCosts.outputCost += costResult.costs.output
totalCosts.cacheCreateCost += costResult.costs.cacheWrite
totalCosts.cacheReadCost += costResult.costs.cacheRead
totalCosts.totalCost += costResult.costs.total
logger.info(
`💰 Model ${model}: ${
usage.inputTokens +
usage.outputTokens +
usage.cacheCreateTokens +
usage.cacheReadTokens
} tokens, cost: ${costResult.formatted.total}`
)
// 记录模型费用
modelCosts[model] = {
model,
requests: 0, // 历史汇总数据没有请求数
usage: usageData,
costs: costResult.costs,
formatted: costResult.formatted,
usingDynamicPricing: costResult.usingDynamicPricing
}
}
} else {
// 如果没有详细的模型统计数据回退到API Key汇总数据
logger.warn('No detailed model statistics found, falling back to API Key aggregated data')
for (const apiKey of apiKeys) {
if (apiKey.usage && apiKey.usage.total) {
const usage = {
input_tokens: apiKey.usage.total.inputTokens || 0,
output_tokens: apiKey.usage.total.outputTokens || 0,
cache_creation_input_tokens: apiKey.usage.total.cacheCreateTokens || 0,
cache_read_input_tokens: apiKey.usage.total.cacheReadTokens || 0
}
// 使用加权平均价格计算(基于当前活跃模型的价格分布)
const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022')
totalCosts.inputCost += costResult.costs.input
totalCosts.outputCost += costResult.costs.output
totalCosts.cacheCreateCost += costResult.costs.cacheWrite
totalCosts.cacheReadCost += costResult.costs.cacheRead
totalCosts.totalCost += costResult.costs.total
}
}
}
return res.json({
success: true,
data: {
period,
totalCosts: {
...totalCosts,
formatted: {
inputCost: CostCalculator.formatCost(totalCosts.inputCost),
outputCost: CostCalculator.formatCost(totalCosts.outputCost),
cacheCreateCost: CostCalculator.formatCost(totalCosts.cacheCreateCost),
cacheReadCost: CostCalculator.formatCost(totalCosts.cacheReadCost),
totalCost: CostCalculator.formatCost(totalCosts.totalCost)
}
},
modelCosts: Object.values(modelCosts).sort((a, b) => b.costs.total - a.costs.total),
pricingServiceStatus: pricingService.getStatus()
}
})
}
// 对于今日或本月从Redis获取详细的模型统计
const keys = await client.keys(pattern)
for (const key of keys) {
const match = key.match(
period === 'today'
? /usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
: /usage:model:monthly:(.+):\d{4}-\d{2}$/
)
if (!match) {
continue
}
const model = match[1]
const data = await client.hgetall(key)
if (data && Object.keys(data).length > 0) {
const usage = {
input_tokens: parseInt(data.inputTokens) || 0,
output_tokens: parseInt(data.outputTokens) || 0,
cache_creation_input_tokens: parseInt(data.cacheCreateTokens) || 0,
cache_read_input_tokens: parseInt(data.cacheReadTokens) || 0
}
const costResult = CostCalculator.calculateCost(usage, model)
// 累加总费用
totalCosts.inputCost += costResult.costs.input
totalCosts.outputCost += costResult.costs.output
totalCosts.cacheCreateCost += costResult.costs.cacheWrite
totalCosts.cacheReadCost += costResult.costs.cacheRead
totalCosts.totalCost += costResult.costs.total
// 记录模型费用
modelCosts[model] = {
model,
requests: parseInt(data.requests) || 0,
usage,
costs: costResult.costs,
formatted: costResult.formatted,
usingDynamicPricing: costResult.usingDynamicPricing
}
}
}
return res.json({
success: true,
data: {
period,
totalCosts: {
...totalCosts,
formatted: {
inputCost: CostCalculator.formatCost(totalCosts.inputCost),
outputCost: CostCalculator.formatCost(totalCosts.outputCost),
cacheCreateCost: CostCalculator.formatCost(totalCosts.cacheCreateCost),
cacheReadCost: CostCalculator.formatCost(totalCosts.cacheReadCost),
totalCost: CostCalculator.formatCost(totalCosts.totalCost)
}
},
modelCosts: Object.values(modelCosts).sort((a, b) => b.costs.total - a.costs.total),
pricingServiceStatus: pricingService.getStatus()
}
})
} catch (error) {
logger.error('❌ Failed to calculate usage costs:', error)
return res
.status(500)
.json({ error: 'Failed to calculate usage costs', message: error.message })
}
})
// 获取 API Key 的请求记录时间线
router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res) => {
try {
const { keyId } = req.params
const {
page = 1,
pageSize = 50,
startDate,
endDate,
model,
accountId,
sortOrder = 'desc'
} = req.query
const pageNumber = Math.max(parseInt(page, 10) || 1, 1)
const pageSizeNumber = Math.min(Math.max(parseInt(pageSize, 10) || 50, 1), 200)
const normalizedSortOrder = sortOrder === 'asc' ? 'asc' : 'desc'
const startTime = startDate ? new Date(startDate) : null
const endTime = endDate ? new Date(endDate) : null
if (
(startDate && Number.isNaN(startTime?.getTime())) ||
(endDate && Number.isNaN(endTime?.getTime()))
) {
return res.status(400).json({ success: false, error: 'Invalid date range' })
}
if (startTime && endTime && startTime > endTime) {
return res
.status(400)
.json({ success: false, error: 'Start date must be before or equal to end date' })
}
const apiKeyInfo = await redis.getApiKey(keyId)
if (!apiKeyInfo || Object.keys(apiKeyInfo).length === 0) {
return res.status(404).json({ success: false, error: 'API key not found' })
}
const rawRecords = await redis.getUsageRecords(keyId, 5000)
const accountServices = [
{ type: 'claude', getter: (id) => claudeAccountService.getAccount(id) },
{ type: 'claude-console', getter: (id) => claudeConsoleAccountService.getAccount(id) },
{ type: 'ccr', getter: (id) => ccrAccountService.getAccount(id) },
{ type: 'openai', getter: (id) => openaiAccountService.getAccount(id) },
{ type: 'openai-responses', getter: (id) => openaiResponsesAccountService.getAccount(id) },
{ type: 'gemini', getter: (id) => geminiAccountService.getAccount(id) },
{ type: 'gemini-api', getter: (id) => geminiApiAccountService.getAccount(id) },
{ type: 'droid', getter: (id) => droidAccountService.getAccount(id) }
]
const accountCache = new Map()
const resolveAccountInfo = async (id, type) => {
if (!id) {
return null
}
const cacheKey = `${type || 'any'}:${id}`
if (accountCache.has(cacheKey)) {
return accountCache.get(cacheKey)
}
let servicesToTry = type
? accountServices.filter((svc) => svc.type === type)
: accountServices
// 若渠道改名或传入未知类型,回退尝试全量服务,避免漏解析历史账号
if (!servicesToTry.length) {
servicesToTry = accountServices
}
for (const service of servicesToTry) {
try {
const account = await service.getter(id)
if (account) {
const info = {
id,
name: account.name || account.email || id,
type: service.type,
status: account.status || account.isActive
}
accountCache.set(cacheKey, info)
return info
}
} catch (error) {
logger.debug(`⚠️ Failed to resolve account ${id} via ${service.type}: ${error.message}`)
}
}
accountCache.set(cacheKey, null)
return null
}
const toUsageObject = (record) => ({
input_tokens: record.inputTokens || 0,
output_tokens: record.outputTokens || 0,
cache_creation_input_tokens: record.cacheCreateTokens || 0,
cache_read_input_tokens: record.cacheReadTokens || 0,
cache_creation: record.cacheCreation || record.cache_creation || null
})
const withinRange = (record) => {
if (!record.timestamp) {
return false
}
const ts = new Date(record.timestamp)
if (Number.isNaN(ts.getTime())) {
return false
}
if (startTime && ts < startTime) {
return false
}
if (endTime && ts > endTime) {
return false
}
return true
}
const filteredRecords = rawRecords.filter((record) => {
if (!withinRange(record)) {
return false
}
if (model && record.model !== model) {
return false
}
if (accountId && record.accountId !== accountId) {
return false
}
return true
})
filteredRecords.sort((a, b) => {
const aTime = new Date(a.timestamp).getTime()
const bTime = new Date(b.timestamp).getTime()
if (Number.isNaN(aTime) || Number.isNaN(bTime)) {
return 0
}
return normalizedSortOrder === 'asc' ? aTime - bTime : bTime - aTime
})
const summary = {
totalRequests: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
totalTokens: 0,
totalCost: 0
}
const modelSet = new Set()
const accountOptionMap = new Map()
let earliestTimestamp = null
let latestTimestamp = null
for (const record of filteredRecords) {
const usage = toUsageObject(record)
const costData = CostCalculator.calculateCost(usage, record.model || 'unknown')
const computedCost =
typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0
const totalTokens =
record.totalTokens ||
usage.input_tokens +
usage.output_tokens +
usage.cache_creation_input_tokens +
usage.cache_read_input_tokens
summary.totalRequests += 1
summary.inputTokens += usage.input_tokens
summary.outputTokens += usage.output_tokens
summary.cacheCreateTokens += usage.cache_creation_input_tokens
summary.cacheReadTokens += usage.cache_read_input_tokens
summary.totalTokens += totalTokens
summary.totalCost += computedCost
if (record.model) {
modelSet.add(record.model)
}
if (record.accountId) {
const normalizedType = record.accountType || 'unknown'
if (!accountOptionMap.has(record.accountId)) {
accountOptionMap.set(record.accountId, {
id: record.accountId,
accountTypes: new Set([normalizedType])
})
} else {
accountOptionMap.get(record.accountId).accountTypes.add(normalizedType)
}
}
if (record.timestamp) {
const ts = new Date(record.timestamp)
if (!Number.isNaN(ts.getTime())) {
if (!earliestTimestamp || ts < earliestTimestamp) {
earliestTimestamp = ts
}
if (!latestTimestamp || ts > latestTimestamp) {
latestTimestamp = ts
}
}
}
}
const totalRecords = filteredRecords.length
const totalPages = totalRecords > 0 ? Math.ceil(totalRecords / pageSizeNumber) : 0
const safePage = totalPages > 0 ? Math.min(pageNumber, totalPages) : 1
const startIndex = (safePage - 1) * pageSizeNumber
const pageRecords =
totalRecords === 0 ? [] : filteredRecords.slice(startIndex, startIndex + pageSizeNumber)
const enrichedRecords = []
for (const record of pageRecords) {
const usage = toUsageObject(record)
const costData = CostCalculator.calculateCost(usage, record.model || 'unknown')
const computedCost =
typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0
const totalTokens =
record.totalTokens ||
usage.input_tokens +
usage.output_tokens +
usage.cache_creation_input_tokens +
usage.cache_read_input_tokens
const accountInfo = await resolveAccountInfo(record.accountId, record.accountType)
const resolvedAccountType = accountInfo?.type || record.accountType || 'unknown'
enrichedRecords.push({
timestamp: record.timestamp,
model: record.model || 'unknown',
accountId: record.accountId || null,
accountName: accountInfo?.name || null,
accountStatus: accountInfo?.status ?? null,
accountType: resolvedAccountType,
accountTypeName: accountTypeNames[resolvedAccountType] || '未知渠道',
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
cacheCreateTokens: usage.cache_creation_input_tokens,
cacheReadTokens: usage.cache_read_input_tokens,
ephemeral5mTokens: record.ephemeral5mTokens || 0,
ephemeral1hTokens: record.ephemeral1hTokens || 0,
totalTokens,
isLongContextRequest: record.isLongContext || record.isLongContextRequest || false,
cost: Number(computedCost.toFixed(6)),
costFormatted:
record.costFormatted ||
costData?.formatted?.total ||
CostCalculator.formatCost(computedCost),
costBreakdown: record.costBreakdown || {
input: costData?.costs?.input || 0,
output: costData?.costs?.output || 0,
cacheCreate: costData?.costs?.cacheWrite || 0,
cacheRead: costData?.costs?.cacheRead || 0,
total: costData?.costs?.total || computedCost
},
responseTime: record.responseTime || null
})
}
const accountOptions = []
for (const option of accountOptionMap.values()) {
const types = Array.from(option.accountTypes || [])
// 优先按历史出现的 accountType 解析,若失败则回退全量解析
let resolvedInfo = null
for (const type of types) {
resolvedInfo = await resolveAccountInfo(option.id, type)
if (resolvedInfo && resolvedInfo.name) {
break
}
}
if (!resolvedInfo) {
resolvedInfo = await resolveAccountInfo(option.id)
}
const chosenType = resolvedInfo?.type || types[0] || 'unknown'
const chosenTypeName = accountTypeNames[chosenType] || '未知渠道'
if (!resolvedInfo) {
logger.warn(`⚠️ 保留无法解析的账户筛选项: ${option.id}, types=${types.join(',') || 'none'}`)
}
accountOptions.push({
id: option.id,
name: resolvedInfo?.name || option.id,
accountType: chosenType,
accountTypeName: chosenTypeName,
rawTypes: types
})
}
return res.json({
success: true,
data: {
records: enrichedRecords,
pagination: {
currentPage: safePage,
pageSize: pageSizeNumber,
totalRecords,
totalPages,
hasNextPage: totalPages > 0 && safePage < totalPages,
hasPreviousPage: totalPages > 0 && safePage > 1
},
filters: {
startDate: startTime ? startTime.toISOString() : null,
endDate: endTime ? endTime.toISOString() : null,
model: model || null,
accountId: accountId || null,
sortOrder: normalizedSortOrder
},
apiKeyInfo: {
id: keyId,
name: apiKeyInfo.name || apiKeyInfo.label || keyId
},
summary: {
...summary,
totalCost: Number(summary.totalCost.toFixed(6)),
avgCost:
summary.totalRequests > 0
? Number((summary.totalCost / summary.totalRequests).toFixed(6))
: 0
},
availableFilters: {
models: Array.from(modelSet),
accounts: accountOptions,
dateRange: {
earliest: earliestTimestamp ? earliestTimestamp.toISOString() : null,
latest: latestTimestamp ? latestTimestamp.toISOString() : null
}
}
}
})
} catch (error) {
logger.error('❌ Failed to get API key usage records:', error)
return res
.status(500)
.json({ error: 'Failed to get API key usage records', message: error.message })
}
})
// 获取账户的请求记录时间线
router.get('/accounts/:accountId/usage-records', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const {
platform,
page = 1,
pageSize = 50,
startDate,
endDate,
model,
apiKeyId,
sortOrder = 'desc'
} = req.query
const pageNumber = Math.max(parseInt(page, 10) || 1, 1)
const pageSizeNumber = Math.min(Math.max(parseInt(pageSize, 10) || 50, 1), 200)
const normalizedSortOrder = sortOrder === 'asc' ? 'asc' : 'desc'
const startTime = startDate ? new Date(startDate) : null
const endTime = endDate ? new Date(endDate) : null
if (
(startDate && Number.isNaN(startTime?.getTime())) ||
(endDate && Number.isNaN(endTime?.getTime()))
) {
return res.status(400).json({ success: false, error: 'Invalid date range' })
}
if (startTime && endTime && startTime > endTime) {
return res
.status(400)
.json({ success: false, error: 'Start date must be before or equal to end date' })
}
const accountInfo = await resolveAccountByPlatform(accountId, platform)
if (!accountInfo) {
return res.status(404).json({ success: false, error: 'Account not found' })
}
const allApiKeys = await apiKeyService.getAllApiKeys(true)
const apiKeyNameCache = new Map(
allApiKeys.map((key) => [key.id, key.name || key.label || key.id])
)
let keysToUse = apiKeyId ? allApiKeys.filter((key) => key.id === apiKeyId) : allApiKeys
if (apiKeyId && keysToUse.length === 0) {
keysToUse = [{ id: apiKeyId }]
}
const toUsageObject = (record) => ({
input_tokens: record.inputTokens || 0,
output_tokens: record.outputTokens || 0,
cache_creation_input_tokens: record.cacheCreateTokens || 0,
cache_read_input_tokens: record.cacheReadTokens || 0,
cache_creation: record.cacheCreation || record.cache_creation || null
})
const withinRange = (record) => {
if (!record.timestamp) {
return false
}
const ts = new Date(record.timestamp)
if (Number.isNaN(ts.getTime())) {
return false
}
if (startTime && ts < startTime) {
return false
}
if (endTime && ts > endTime) {
return false
}
return true
}
const filteredRecords = []
const modelSet = new Set()
const apiKeyOptionMap = new Map()
let earliestTimestamp = null
let latestTimestamp = null
const batchSize = 10
for (let i = 0; i < keysToUse.length; i += batchSize) {
const batch = keysToUse.slice(i, i + batchSize)
const batchResults = await Promise.all(
batch.map(async (key) => {
try {
const records = await redis.getUsageRecords(key.id, 5000)
return { keyId: key.id, records: records || [] }
} catch (error) {
logger.debug(`⚠️ Failed to get usage records for key ${key.id}: ${error.message}`)
return { keyId: key.id, records: [] }
}
})
)
for (const { keyId, records } of batchResults) {
const apiKeyName = apiKeyNameCache.get(keyId) || (await getApiKeyName(keyId))
for (const record of records) {
if (record.accountId !== accountId) {
continue
}
if (!withinRange(record)) {
continue
}
if (model && record.model !== model) {
continue
}
const accountType = record.accountType || accountInfo.platform || 'unknown'
const normalizedModel = record.model || 'unknown'
modelSet.add(normalizedModel)
apiKeyOptionMap.set(keyId, { id: keyId, name: apiKeyName })
if (record.timestamp) {
const ts = new Date(record.timestamp)
if (!Number.isNaN(ts.getTime())) {
if (!earliestTimestamp || ts < earliestTimestamp) {
earliestTimestamp = ts
}
if (!latestTimestamp || ts > latestTimestamp) {
latestTimestamp = ts
}
}
}
filteredRecords.push({
...record,
model: normalizedModel,
accountType,
apiKeyId: keyId,
apiKeyName
})
}
}
}
filteredRecords.sort((a, b) => {
const aTime = new Date(a.timestamp).getTime()
const bTime = new Date(b.timestamp).getTime()
if (Number.isNaN(aTime) || Number.isNaN(bTime)) {
return 0
}
return normalizedSortOrder === 'asc' ? aTime - bTime : bTime - aTime
})
const summary = {
totalRequests: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
totalTokens: 0,
totalCost: 0
}
for (const record of filteredRecords) {
const usage = toUsageObject(record)
const costData = CostCalculator.calculateCost(usage, record.model || 'unknown')
const computedCost =
typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0
const totalTokens =
record.totalTokens ||
usage.input_tokens +
usage.output_tokens +
usage.cache_creation_input_tokens +
usage.cache_read_input_tokens
summary.totalRequests += 1
summary.inputTokens += usage.input_tokens
summary.outputTokens += usage.output_tokens
summary.cacheCreateTokens += usage.cache_creation_input_tokens
summary.cacheReadTokens += usage.cache_read_input_tokens
summary.totalTokens += totalTokens
summary.totalCost += computedCost
}
const totalRecords = filteredRecords.length
const totalPages = totalRecords > 0 ? Math.ceil(totalRecords / pageSizeNumber) : 0
const safePage = totalPages > 0 ? Math.min(pageNumber, totalPages) : 1
const startIndex = (safePage - 1) * pageSizeNumber
const pageRecords =
totalRecords === 0 ? [] : filteredRecords.slice(startIndex, startIndex + pageSizeNumber)
const enrichedRecords = []
for (const record of pageRecords) {
const usage = toUsageObject(record)
const costData = CostCalculator.calculateCost(usage, record.model || 'unknown')
const computedCost =
typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0
const totalTokens =
record.totalTokens ||
usage.input_tokens +
usage.output_tokens +
usage.cache_creation_input_tokens +
usage.cache_read_input_tokens
enrichedRecords.push({
timestamp: record.timestamp,
model: record.model || 'unknown',
apiKeyId: record.apiKeyId,
apiKeyName: record.apiKeyName,
accountId,
accountName: accountInfo.name || accountInfo.email || accountId,
accountType: record.accountType,
accountTypeName: accountTypeNames[record.accountType] || '未知渠道',
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
cacheCreateTokens: usage.cache_creation_input_tokens,
cacheReadTokens: usage.cache_read_input_tokens,
ephemeral5mTokens: record.ephemeral5mTokens || 0,
ephemeral1hTokens: record.ephemeral1hTokens || 0,
totalTokens,
isLongContextRequest: record.isLongContext || record.isLongContextRequest || false,
cost: Number(computedCost.toFixed(6)),
costFormatted:
record.costFormatted ||
costData?.formatted?.total ||
CostCalculator.formatCost(computedCost),
costBreakdown: record.costBreakdown || {
input: costData?.costs?.input || 0,
output: costData?.costs?.output || 0,
cacheCreate: costData?.costs?.cacheWrite || 0,
cacheRead: costData?.costs?.cacheRead || 0,
total: costData?.costs?.total || computedCost
},
responseTime: record.responseTime || null
})
}
return res.json({
success: true,
data: {
records: enrichedRecords,
pagination: {
currentPage: safePage,
pageSize: pageSizeNumber,
totalRecords,
totalPages,
hasNextPage: totalPages > 0 && safePage < totalPages,
hasPreviousPage: totalPages > 0 && safePage > 1
},
filters: {
startDate: startTime ? startTime.toISOString() : null,
endDate: endTime ? endTime.toISOString() : null,
model: model || null,
apiKeyId: apiKeyId || null,
platform: accountInfo.platform,
sortOrder: normalizedSortOrder
},
accountInfo: {
id: accountId,
name: accountInfo.name || accountInfo.email || accountId,
platform: accountInfo.platform || platform || 'unknown',
status: accountInfo.status ?? accountInfo.isActive ?? null
},
summary: {
...summary,
totalCost: Number(summary.totalCost.toFixed(6)),
avgCost:
summary.totalRequests > 0
? Number((summary.totalCost / summary.totalRequests).toFixed(6))
: 0
},
availableFilters: {
models: Array.from(modelSet),
apiKeys: Array.from(apiKeyOptionMap.values()),
dateRange: {
earliest: earliestTimestamp ? earliestTimestamp.toISOString() : null,
latest: latestTimestamp ? latestTimestamp.toISOString() : null
}
}
}
})
} catch (error) {
logger.error('❌ Failed to get account usage records:', error)
return res
.status(500)
.json({ error: 'Failed to get account usage records', message: error.message })
}
})
module.exports = router