mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
2507 lines
86 KiB
JavaScript
2507 lines
86 KiB
JavaScript
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
|