|
|
const express = require('express') |
|
|
const redis = require('../models/redis') |
|
|
const logger = require('../utils/logger') |
|
|
const apiKeyService = require('../services/apiKeyService') |
|
|
const CostCalculator = require('../utils/costCalculator') |
|
|
const claudeAccountService = require('../services/claudeAccountService') |
|
|
const openaiAccountService = require('../services/openaiAccountService') |
|
|
|
|
|
const router = express.Router() |
|
|
|
|
|
|
|
|
router.get('/', (req, res) => { |
|
|
res.redirect(301, '/admin-next/api-stats') |
|
|
}) |
|
|
|
|
|
|
|
|
router.post('/api/get-key-id', async (req, res) => { |
|
|
try { |
|
|
const { apiKey } = req.body |
|
|
|
|
|
if (!apiKey) { |
|
|
return res.status(400).json({ |
|
|
error: 'API Key is required', |
|
|
message: 'Please provide your API Key' |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) { |
|
|
return res.status(400).json({ |
|
|
error: 'Invalid API key format', |
|
|
message: 'API key format is invalid' |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
const validation = await apiKeyService.validateApiKeyForStats(apiKey) |
|
|
|
|
|
if (!validation.valid) { |
|
|
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' |
|
|
logger.security(`🔒 Invalid API key in get-key-id: ${validation.error} from ${clientIP}`) |
|
|
return res.status(401).json({ |
|
|
error: 'Invalid API key', |
|
|
message: validation.error |
|
|
}) |
|
|
} |
|
|
|
|
|
const { keyData } = validation |
|
|
|
|
|
return res.json({ |
|
|
success: true, |
|
|
data: { |
|
|
id: keyData.id |
|
|
} |
|
|
}) |
|
|
} catch (error) { |
|
|
logger.error('❌ Failed to get API key ID:', error) |
|
|
return res.status(500).json({ |
|
|
error: 'Internal server error', |
|
|
message: 'Failed to retrieve API key ID' |
|
|
}) |
|
|
} |
|
|
}) |
|
|
|
|
|
|
|
|
router.post('/api/user-stats', async (req, res) => { |
|
|
try { |
|
|
const { apiKey, apiId } = req.body |
|
|
|
|
|
let keyData |
|
|
let keyId |
|
|
|
|
|
if (apiId) { |
|
|
|
|
|
if ( |
|
|
typeof apiId !== 'string' || |
|
|
!apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i) |
|
|
) { |
|
|
return res.status(400).json({ |
|
|
error: 'Invalid API ID format', |
|
|
message: 'API ID must be a valid UUID' |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
keyData = await redis.getApiKey(apiId) |
|
|
|
|
|
if (!keyData || Object.keys(keyData).length === 0) { |
|
|
logger.security(`🔒 API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`) |
|
|
return res.status(404).json({ |
|
|
error: 'API key not found', |
|
|
message: 'The specified API key does not exist' |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
if (keyData.isActive !== 'true') { |
|
|
return res.status(403).json({ |
|
|
error: 'API key is disabled', |
|
|
message: 'This API key has been disabled' |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) { |
|
|
return res.status(403).json({ |
|
|
error: 'API key has expired', |
|
|
message: 'This API key has expired' |
|
|
}) |
|
|
} |
|
|
|
|
|
keyId = apiId |
|
|
|
|
|
|
|
|
const usage = await redis.getUsageStats(keyId) |
|
|
|
|
|
|
|
|
const dailyCost = await redis.getDailyCost(keyId) |
|
|
const costStats = await redis.getCostStats(keyId) |
|
|
|
|
|
|
|
|
|
|
|
let restrictedModels = [] |
|
|
try { |
|
|
restrictedModels = keyData.restrictedModels ? JSON.parse(keyData.restrictedModels) : [] |
|
|
} catch (e) { |
|
|
restrictedModels = [] |
|
|
} |
|
|
|
|
|
|
|
|
let allowedClients = [] |
|
|
try { |
|
|
allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : [] |
|
|
} catch (e) { |
|
|
allowedClients = [] |
|
|
} |
|
|
|
|
|
|
|
|
keyData = { |
|
|
...keyData, |
|
|
tokenLimit: parseInt(keyData.tokenLimit) || 0, |
|
|
concurrencyLimit: parseInt(keyData.concurrencyLimit) || 0, |
|
|
rateLimitWindow: parseInt(keyData.rateLimitWindow) || 0, |
|
|
rateLimitRequests: parseInt(keyData.rateLimitRequests) || 0, |
|
|
dailyCostLimit: parseFloat(keyData.dailyCostLimit) || 0, |
|
|
totalCostLimit: parseFloat(keyData.totalCostLimit) || 0, |
|
|
dailyCost: dailyCost || 0, |
|
|
totalCost: costStats.total || 0, |
|
|
enableModelRestriction: keyData.enableModelRestriction === 'true', |
|
|
restrictedModels, |
|
|
enableClientRestriction: keyData.enableClientRestriction === 'true', |
|
|
allowedClients, |
|
|
permissions: keyData.permissions || 'all', |
|
|
|
|
|
expirationMode: keyData.expirationMode || 'fixed', |
|
|
isActivated: keyData.isActivated === 'true', |
|
|
activationDays: parseInt(keyData.activationDays || 0), |
|
|
activatedAt: keyData.activatedAt || null, |
|
|
usage |
|
|
} |
|
|
} else if (apiKey) { |
|
|
|
|
|
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) { |
|
|
logger.security(`🔒 Invalid API key format in user stats query from ${req.ip || 'unknown'}`) |
|
|
return res.status(400).json({ |
|
|
error: 'Invalid API key format', |
|
|
message: 'API key format is invalid' |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
const validation = await apiKeyService.validateApiKeyForStats(apiKey) |
|
|
|
|
|
if (!validation.valid) { |
|
|
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' |
|
|
logger.security( |
|
|
`🔒 Invalid API key in user stats query: ${validation.error} from ${clientIP}` |
|
|
) |
|
|
return res.status(401).json({ |
|
|
error: 'Invalid API key', |
|
|
message: validation.error |
|
|
}) |
|
|
} |
|
|
|
|
|
const { keyData: validatedKeyData } = validation |
|
|
keyData = validatedKeyData |
|
|
keyId = keyData.id |
|
|
} else { |
|
|
logger.security(`🔒 Missing API key or ID in user stats query from ${req.ip || 'unknown'}`) |
|
|
return res.status(400).json({ |
|
|
error: 'API Key or ID is required', |
|
|
message: 'Please provide your API Key or API ID' |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
logger.api( |
|
|
`📊 User stats query from key: ${keyData.name} (${keyId}) from ${req.ip || 'unknown'}` |
|
|
) |
|
|
|
|
|
|
|
|
const fullKeyData = keyData |
|
|
|
|
|
|
|
|
let totalCost = 0 |
|
|
let formattedCost = '$0.000000' |
|
|
|
|
|
try { |
|
|
const client = redis.getClientSafe() |
|
|
|
|
|
|
|
|
const allModelKeys = await client.keys(`usage:${keyId}:model:monthly:*:*`) |
|
|
const modelUsageMap = new Map() |
|
|
|
|
|
for (const key of allModelKeys) { |
|
|
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 |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
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) |
|
|
totalCost += costResult.costs.total |
|
|
} |
|
|
|
|
|
|
|
|
if (modelUsageMap.size === 0 && fullKeyData.usage?.total?.allTokens > 0) { |
|
|
const usage = fullKeyData.usage.total |
|
|
const costUsage = { |
|
|
input_tokens: usage.inputTokens || 0, |
|
|
output_tokens: usage.outputTokens || 0, |
|
|
cache_creation_input_tokens: usage.cacheCreateTokens || 0, |
|
|
cache_read_input_tokens: usage.cacheReadTokens || 0 |
|
|
} |
|
|
|
|
|
const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022') |
|
|
totalCost = costResult.costs.total |
|
|
} |
|
|
|
|
|
formattedCost = CostCalculator.formatCost(totalCost) |
|
|
} catch (error) { |
|
|
logger.warn(`Failed to calculate detailed cost for key ${keyId}:`, error) |
|
|
|
|
|
if (fullKeyData.usage?.total?.allTokens > 0) { |
|
|
const usage = fullKeyData.usage.total |
|
|
const costUsage = { |
|
|
input_tokens: usage.inputTokens || 0, |
|
|
output_tokens: usage.outputTokens || 0, |
|
|
cache_creation_input_tokens: usage.cacheCreateTokens || 0, |
|
|
cache_read_input_tokens: usage.cacheReadTokens || 0 |
|
|
} |
|
|
|
|
|
const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022') |
|
|
totalCost = costResult.costs.total |
|
|
formattedCost = costResult.formatted.total |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
let currentWindowRequests = 0 |
|
|
let currentWindowTokens = 0 |
|
|
let currentWindowCost = 0 |
|
|
let currentDailyCost = 0 |
|
|
let windowStartTime = null |
|
|
let windowEndTime = null |
|
|
let windowRemainingSeconds = null |
|
|
|
|
|
try { |
|
|
|
|
|
if (fullKeyData.rateLimitWindow > 0) { |
|
|
const client = redis.getClientSafe() |
|
|
const requestCountKey = `rate_limit:requests:${keyId}` |
|
|
const tokenCountKey = `rate_limit:tokens:${keyId}` |
|
|
const costCountKey = `rate_limit:cost:${keyId}` |
|
|
const windowStartKey = `rate_limit:window_start:${keyId}` |
|
|
|
|
|
currentWindowRequests = parseInt((await client.get(requestCountKey)) || '0') |
|
|
currentWindowTokens = parseInt((await client.get(tokenCountKey)) || '0') |
|
|
currentWindowCost = parseFloat((await client.get(costCountKey)) || '0') |
|
|
|
|
|
|
|
|
const windowStart = await client.get(windowStartKey) |
|
|
if (windowStart) { |
|
|
const now = Date.now() |
|
|
windowStartTime = parseInt(windowStart) |
|
|
const windowDuration = fullKeyData.rateLimitWindow * 60 * 1000 |
|
|
windowEndTime = windowStartTime + windowDuration |
|
|
|
|
|
|
|
|
if (now < windowEndTime) { |
|
|
windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000)) |
|
|
} else { |
|
|
|
|
|
windowStartTime = null |
|
|
windowEndTime = null |
|
|
windowRemainingSeconds = 0 |
|
|
|
|
|
currentWindowRequests = 0 |
|
|
currentWindowTokens = 0 |
|
|
currentWindowCost = 0 |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
currentDailyCost = (await redis.getDailyCost(keyId)) || 0 |
|
|
} catch (error) { |
|
|
logger.warn(`Failed to get current usage for key ${keyId}:`, error) |
|
|
} |
|
|
|
|
|
const boundAccountDetails = {} |
|
|
|
|
|
const accountDetailTasks = [] |
|
|
|
|
|
if (fullKeyData.claudeAccountId) { |
|
|
accountDetailTasks.push( |
|
|
(async () => { |
|
|
try { |
|
|
const overview = await claudeAccountService.getAccountOverview( |
|
|
fullKeyData.claudeAccountId |
|
|
) |
|
|
|
|
|
if (overview && overview.accountType === 'dedicated') { |
|
|
boundAccountDetails.claude = overview |
|
|
} |
|
|
} catch (error) { |
|
|
logger.warn(`⚠️ Failed to load Claude account overview for key ${keyId}:`, error) |
|
|
} |
|
|
})() |
|
|
) |
|
|
} |
|
|
|
|
|
if (fullKeyData.openaiAccountId) { |
|
|
accountDetailTasks.push( |
|
|
(async () => { |
|
|
try { |
|
|
const overview = await openaiAccountService.getAccountOverview( |
|
|
fullKeyData.openaiAccountId |
|
|
) |
|
|
|
|
|
if (overview && overview.accountType === 'dedicated') { |
|
|
boundAccountDetails.openai = overview |
|
|
} |
|
|
} catch (error) { |
|
|
logger.warn(`⚠️ Failed to load OpenAI account overview for key ${keyId}:`, error) |
|
|
} |
|
|
})() |
|
|
) |
|
|
} |
|
|
|
|
|
if (accountDetailTasks.length > 0) { |
|
|
await Promise.allSettled(accountDetailTasks) |
|
|
} |
|
|
|
|
|
|
|
|
const responseData = { |
|
|
id: keyId, |
|
|
name: fullKeyData.name, |
|
|
description: fullKeyData.description || keyData.description || '', |
|
|
isActive: true, |
|
|
createdAt: fullKeyData.createdAt || keyData.createdAt, |
|
|
expiresAt: fullKeyData.expiresAt || keyData.expiresAt, |
|
|
|
|
|
expirationMode: fullKeyData.expirationMode || 'fixed', |
|
|
isActivated: fullKeyData.isActivated === true || fullKeyData.isActivated === 'true', |
|
|
activationDays: parseInt(fullKeyData.activationDays || 0), |
|
|
activatedAt: fullKeyData.activatedAt || null, |
|
|
permissions: fullKeyData.permissions, |
|
|
|
|
|
|
|
|
usage: { |
|
|
total: { |
|
|
...(fullKeyData.usage?.total || { |
|
|
requests: 0, |
|
|
tokens: 0, |
|
|
allTokens: 0, |
|
|
inputTokens: 0, |
|
|
outputTokens: 0, |
|
|
cacheCreateTokens: 0, |
|
|
cacheReadTokens: 0 |
|
|
}), |
|
|
cost: totalCost, |
|
|
formattedCost |
|
|
} |
|
|
}, |
|
|
|
|
|
|
|
|
limits: { |
|
|
tokenLimit: fullKeyData.tokenLimit || 0, |
|
|
concurrencyLimit: fullKeyData.concurrencyLimit || 0, |
|
|
rateLimitWindow: fullKeyData.rateLimitWindow || 0, |
|
|
rateLimitRequests: fullKeyData.rateLimitRequests || 0, |
|
|
rateLimitCost: parseFloat(fullKeyData.rateLimitCost) || 0, |
|
|
dailyCostLimit: fullKeyData.dailyCostLimit || 0, |
|
|
totalCostLimit: fullKeyData.totalCostLimit || 0, |
|
|
weeklyOpusCostLimit: parseFloat(fullKeyData.weeklyOpusCostLimit) || 0, |
|
|
|
|
|
currentWindowRequests, |
|
|
currentWindowTokens, |
|
|
currentWindowCost, |
|
|
currentDailyCost, |
|
|
currentTotalCost: totalCost, |
|
|
weeklyOpusCost: (await redis.getWeeklyOpusCost(keyId)) || 0, |
|
|
|
|
|
windowStartTime, |
|
|
windowEndTime, |
|
|
windowRemainingSeconds |
|
|
}, |
|
|
|
|
|
|
|
|
accounts: { |
|
|
claudeAccountId: |
|
|
fullKeyData.claudeAccountId && fullKeyData.claudeAccountId !== '' |
|
|
? fullKeyData.claudeAccountId |
|
|
: null, |
|
|
geminiAccountId: |
|
|
fullKeyData.geminiAccountId && fullKeyData.geminiAccountId !== '' |
|
|
? fullKeyData.geminiAccountId |
|
|
: null, |
|
|
openaiAccountId: |
|
|
fullKeyData.openaiAccountId && fullKeyData.openaiAccountId !== '' |
|
|
? fullKeyData.openaiAccountId |
|
|
: null, |
|
|
details: Object.keys(boundAccountDetails).length > 0 ? boundAccountDetails : null |
|
|
}, |
|
|
|
|
|
|
|
|
restrictions: { |
|
|
enableModelRestriction: fullKeyData.enableModelRestriction || false, |
|
|
restrictedModels: fullKeyData.restrictedModels || [], |
|
|
enableClientRestriction: fullKeyData.enableClientRestriction || false, |
|
|
allowedClients: fullKeyData.allowedClients || [] |
|
|
} |
|
|
} |
|
|
|
|
|
return res.json({ |
|
|
success: true, |
|
|
data: responseData |
|
|
}) |
|
|
} catch (error) { |
|
|
logger.error('❌ Failed to process user stats query:', error) |
|
|
return res.status(500).json({ |
|
|
error: 'Internal server error', |
|
|
message: 'Failed to retrieve API key statistics' |
|
|
}) |
|
|
} |
|
|
}) |
|
|
|
|
|
|
|
|
router.post('/api/batch-stats', async (req, res) => { |
|
|
try { |
|
|
const { apiIds } = req.body |
|
|
|
|
|
|
|
|
if (!apiIds || !Array.isArray(apiIds) || apiIds.length === 0) { |
|
|
return res.status(400).json({ |
|
|
error: 'Invalid input', |
|
|
message: 'API IDs array is required' |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
if (apiIds.length > 30) { |
|
|
return res.status(400).json({ |
|
|
error: 'Too many keys', |
|
|
message: 'Maximum 30 API keys can be queried at once' |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
const uuidRegex = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i |
|
|
const invalidIds = apiIds.filter((id) => !uuidRegex.test(id)) |
|
|
if (invalidIds.length > 0) { |
|
|
return res.status(400).json({ |
|
|
error: 'Invalid API ID format', |
|
|
message: `Invalid API IDs: ${invalidIds.join(', ')}` |
|
|
}) |
|
|
} |
|
|
|
|
|
const individualStats = [] |
|
|
const aggregated = { |
|
|
totalKeys: apiIds.length, |
|
|
activeKeys: 0, |
|
|
usage: { |
|
|
requests: 0, |
|
|
inputTokens: 0, |
|
|
outputTokens: 0, |
|
|
cacheCreateTokens: 0, |
|
|
cacheReadTokens: 0, |
|
|
allTokens: 0, |
|
|
cost: 0, |
|
|
formattedCost: '$0.000000' |
|
|
}, |
|
|
dailyUsage: { |
|
|
requests: 0, |
|
|
inputTokens: 0, |
|
|
outputTokens: 0, |
|
|
cacheCreateTokens: 0, |
|
|
cacheReadTokens: 0, |
|
|
allTokens: 0, |
|
|
cost: 0, |
|
|
formattedCost: '$0.000000' |
|
|
}, |
|
|
monthlyUsage: { |
|
|
requests: 0, |
|
|
inputTokens: 0, |
|
|
outputTokens: 0, |
|
|
cacheCreateTokens: 0, |
|
|
cacheReadTokens: 0, |
|
|
allTokens: 0, |
|
|
cost: 0, |
|
|
formattedCost: '$0.000000' |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const results = await Promise.allSettled( |
|
|
apiIds.map(async (apiId) => { |
|
|
const keyData = await redis.getApiKey(apiId) |
|
|
|
|
|
if (!keyData || Object.keys(keyData).length === 0) { |
|
|
return { error: 'Not found', apiId } |
|
|
} |
|
|
|
|
|
|
|
|
if (keyData.isActive !== 'true') { |
|
|
return { error: 'Disabled', apiId } |
|
|
} |
|
|
|
|
|
|
|
|
if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) { |
|
|
return { error: 'Expired', apiId } |
|
|
} |
|
|
|
|
|
|
|
|
const usage = await redis.getUsageStats(apiId) |
|
|
|
|
|
|
|
|
const costStats = await redis.getCostStats(apiId) |
|
|
|
|
|
return { |
|
|
apiId, |
|
|
name: keyData.name, |
|
|
description: keyData.description || '', |
|
|
isActive: true, |
|
|
createdAt: keyData.createdAt, |
|
|
usage: usage.total || {}, |
|
|
dailyStats: { |
|
|
...usage.daily, |
|
|
cost: costStats.daily |
|
|
}, |
|
|
monthlyStats: { |
|
|
...usage.monthly, |
|
|
cost: costStats.monthly |
|
|
}, |
|
|
totalCost: costStats.total |
|
|
} |
|
|
}) |
|
|
) |
|
|
|
|
|
|
|
|
results.forEach((result) => { |
|
|
if (result.status === 'fulfilled' && result.value && !result.value.error) { |
|
|
const stats = result.value |
|
|
aggregated.activeKeys++ |
|
|
|
|
|
|
|
|
if (stats.usage) { |
|
|
aggregated.usage.requests += stats.usage.requests || 0 |
|
|
aggregated.usage.inputTokens += stats.usage.inputTokens || 0 |
|
|
aggregated.usage.outputTokens += stats.usage.outputTokens || 0 |
|
|
aggregated.usage.cacheCreateTokens += stats.usage.cacheCreateTokens || 0 |
|
|
aggregated.usage.cacheReadTokens += stats.usage.cacheReadTokens || 0 |
|
|
aggregated.usage.allTokens += stats.usage.allTokens || 0 |
|
|
} |
|
|
|
|
|
|
|
|
aggregated.usage.cost += stats.totalCost || 0 |
|
|
|
|
|
|
|
|
aggregated.dailyUsage.requests += stats.dailyStats.requests || 0 |
|
|
aggregated.dailyUsage.inputTokens += stats.dailyStats.inputTokens || 0 |
|
|
aggregated.dailyUsage.outputTokens += stats.dailyStats.outputTokens || 0 |
|
|
aggregated.dailyUsage.cacheCreateTokens += stats.dailyStats.cacheCreateTokens || 0 |
|
|
aggregated.dailyUsage.cacheReadTokens += stats.dailyStats.cacheReadTokens || 0 |
|
|
aggregated.dailyUsage.allTokens += stats.dailyStats.allTokens || 0 |
|
|
aggregated.dailyUsage.cost += stats.dailyStats.cost || 0 |
|
|
|
|
|
|
|
|
aggregated.monthlyUsage.requests += stats.monthlyStats.requests || 0 |
|
|
aggregated.monthlyUsage.inputTokens += stats.monthlyStats.inputTokens || 0 |
|
|
aggregated.monthlyUsage.outputTokens += stats.monthlyStats.outputTokens || 0 |
|
|
aggregated.monthlyUsage.cacheCreateTokens += stats.monthlyStats.cacheCreateTokens || 0 |
|
|
aggregated.monthlyUsage.cacheReadTokens += stats.monthlyStats.cacheReadTokens || 0 |
|
|
aggregated.monthlyUsage.allTokens += stats.monthlyStats.allTokens || 0 |
|
|
aggregated.monthlyUsage.cost += stats.monthlyStats.cost || 0 |
|
|
|
|
|
|
|
|
individualStats.push({ |
|
|
apiId: stats.apiId, |
|
|
name: stats.name, |
|
|
isActive: true, |
|
|
usage: stats.usage, |
|
|
dailyUsage: { |
|
|
...stats.dailyStats, |
|
|
formattedCost: CostCalculator.formatCost(stats.dailyStats.cost || 0) |
|
|
}, |
|
|
monthlyUsage: { |
|
|
...stats.monthlyStats, |
|
|
formattedCost: CostCalculator.formatCost(stats.monthlyStats.cost || 0) |
|
|
} |
|
|
}) |
|
|
} |
|
|
}) |
|
|
|
|
|
|
|
|
aggregated.usage.formattedCost = CostCalculator.formatCost(aggregated.usage.cost) |
|
|
aggregated.dailyUsage.formattedCost = CostCalculator.formatCost(aggregated.dailyUsage.cost) |
|
|
aggregated.monthlyUsage.formattedCost = CostCalculator.formatCost(aggregated.monthlyUsage.cost) |
|
|
|
|
|
logger.api(`📊 Batch stats query for ${apiIds.length} keys from ${req.ip || 'unknown'}`) |
|
|
|
|
|
return res.json({ |
|
|
success: true, |
|
|
data: { |
|
|
aggregated, |
|
|
individual: individualStats |
|
|
} |
|
|
}) |
|
|
} catch (error) { |
|
|
logger.error('❌ Failed to process batch stats query:', error) |
|
|
return res.status(500).json({ |
|
|
error: 'Internal server error', |
|
|
message: 'Failed to retrieve batch statistics' |
|
|
}) |
|
|
} |
|
|
}) |
|
|
|
|
|
|
|
|
router.post('/api/batch-model-stats', async (req, res) => { |
|
|
try { |
|
|
const { apiIds, period = 'daily' } = req.body |
|
|
|
|
|
|
|
|
if (!apiIds || !Array.isArray(apiIds) || apiIds.length === 0) { |
|
|
return res.status(400).json({ |
|
|
error: 'Invalid input', |
|
|
message: 'API IDs array is required' |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
if (apiIds.length > 30) { |
|
|
return res.status(400).json({ |
|
|
error: 'Too many keys', |
|
|
message: 'Maximum 30 API keys can be queried at once' |
|
|
}) |
|
|
} |
|
|
|
|
|
const client = redis.getClientSafe() |
|
|
const tzDate = redis.getDateInTimezone() |
|
|
const today = redis.getDateStringInTimezone() |
|
|
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}` |
|
|
|
|
|
const modelUsageMap = new Map() |
|
|
|
|
|
|
|
|
await Promise.all( |
|
|
apiIds.map(async (apiId) => { |
|
|
const pattern = |
|
|
period === 'daily' |
|
|
? `usage:${apiId}:model:daily:*:${today}` |
|
|
: `usage:${apiId}:model:monthly:*:${currentMonth}` |
|
|
|
|
|
const keys = await client.keys(pattern) |
|
|
|
|
|
for (const key of keys) { |
|
|
const match = key.match( |
|
|
period === 'daily' |
|
|
? /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) { |
|
|
if (!modelUsageMap.has(model)) { |
|
|
modelUsageMap.set(model, { |
|
|
requests: 0, |
|
|
inputTokens: 0, |
|
|
outputTokens: 0, |
|
|
cacheCreateTokens: 0, |
|
|
cacheReadTokens: 0, |
|
|
allTokens: 0 |
|
|
}) |
|
|
} |
|
|
|
|
|
const modelUsage = modelUsageMap.get(model) |
|
|
modelUsage.requests += parseInt(data.requests) || 0 |
|
|
modelUsage.inputTokens += parseInt(data.inputTokens) || 0 |
|
|
modelUsage.outputTokens += parseInt(data.outputTokens) || 0 |
|
|
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0 |
|
|
modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0 |
|
|
modelUsage.allTokens += parseInt(data.allTokens) || 0 |
|
|
} |
|
|
} |
|
|
}) |
|
|
) |
|
|
|
|
|
|
|
|
const modelStats = [] |
|
|
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 costData = CostCalculator.calculateCost(usageData, model) |
|
|
|
|
|
modelStats.push({ |
|
|
model, |
|
|
requests: usage.requests, |
|
|
inputTokens: usage.inputTokens, |
|
|
outputTokens: usage.outputTokens, |
|
|
cacheCreateTokens: usage.cacheCreateTokens, |
|
|
cacheReadTokens: usage.cacheReadTokens, |
|
|
allTokens: usage.allTokens, |
|
|
costs: costData.costs, |
|
|
formatted: costData.formatted, |
|
|
pricing: costData.pricing |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
modelStats.sort((a, b) => b.allTokens - a.allTokens) |
|
|
|
|
|
logger.api(`📊 Batch model stats query for ${apiIds.length} keys, period: ${period}`) |
|
|
|
|
|
return res.json({ |
|
|
success: true, |
|
|
data: modelStats, |
|
|
period |
|
|
}) |
|
|
} catch (error) { |
|
|
logger.error('❌ Failed to process batch model stats query:', error) |
|
|
return res.status(500).json({ |
|
|
error: 'Internal server error', |
|
|
message: 'Failed to retrieve batch model statistics' |
|
|
}) |
|
|
} |
|
|
}) |
|
|
|
|
|
|
|
|
router.post('/api/user-model-stats', async (req, res) => { |
|
|
try { |
|
|
const { apiKey, apiId, period = 'monthly' } = req.body |
|
|
|
|
|
let keyData |
|
|
let keyId |
|
|
|
|
|
if (apiId) { |
|
|
|
|
|
if ( |
|
|
typeof apiId !== 'string' || |
|
|
!apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i) |
|
|
) { |
|
|
return res.status(400).json({ |
|
|
error: 'Invalid API ID format', |
|
|
message: 'API ID must be a valid UUID' |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
keyData = await redis.getApiKey(apiId) |
|
|
|
|
|
if (!keyData || Object.keys(keyData).length === 0) { |
|
|
logger.security(`🔒 API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`) |
|
|
return res.status(404).json({ |
|
|
error: 'API key not found', |
|
|
message: 'The specified API key does not exist' |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
if (keyData.isActive !== 'true') { |
|
|
return res.status(403).json({ |
|
|
error: 'API key is disabled', |
|
|
message: 'This API key has been disabled' |
|
|
}) |
|
|
} |
|
|
|
|
|
keyId = apiId |
|
|
|
|
|
|
|
|
const usage = await redis.getUsageStats(keyId) |
|
|
keyData.usage = { total: usage.total } |
|
|
} else if (apiKey) { |
|
|
|
|
|
|
|
|
const validation = await apiKeyService.validateApiKey(apiKey) |
|
|
|
|
|
if (!validation.valid) { |
|
|
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' |
|
|
logger.security( |
|
|
`🔒 Invalid API key in user model stats query: ${validation.error} from ${clientIP}` |
|
|
) |
|
|
return res.status(401).json({ |
|
|
error: 'Invalid API key', |
|
|
message: validation.error |
|
|
}) |
|
|
} |
|
|
|
|
|
const { keyData: validatedKeyData } = validation |
|
|
keyData = validatedKeyData |
|
|
keyId = keyData.id |
|
|
} else { |
|
|
logger.security( |
|
|
`🔒 Missing API key or ID in user model stats query from ${req.ip || 'unknown'}` |
|
|
) |
|
|
return res.status(400).json({ |
|
|
error: 'API Key or ID is required', |
|
|
message: 'Please provide your API Key or API ID' |
|
|
}) |
|
|
} |
|
|
|
|
|
logger.api( |
|
|
`📊 User model stats query from key: ${keyData.name} (${keyId}) for period: ${period}` |
|
|
) |
|
|
|
|
|
|
|
|
const client = redis.getClientSafe() |
|
|
|
|
|
const tzDate = redis.getDateInTimezone() |
|
|
const today = redis.getDateStringInTimezone() |
|
|
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}` |
|
|
|
|
|
const pattern = |
|
|
period === 'daily' |
|
|
? `usage:${keyId}:model:daily:*:${today}` |
|
|
: `usage:${keyId}:model:monthly:*:${currentMonth}` |
|
|
|
|
|
const keys = await client.keys(pattern) |
|
|
const modelStats = [] |
|
|
|
|
|
for (const key of keys) { |
|
|
const match = key.match( |
|
|
period === 'daily' |
|
|
? /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 costData = CostCalculator.calculateCost(usage, model) |
|
|
|
|
|
modelStats.push({ |
|
|
model, |
|
|
requests: parseInt(data.requests) || 0, |
|
|
inputTokens: usage.input_tokens, |
|
|
outputTokens: usage.output_tokens, |
|
|
cacheCreateTokens: usage.cache_creation_input_tokens, |
|
|
cacheReadTokens: usage.cache_read_input_tokens, |
|
|
allTokens: parseInt(data.allTokens) || 0, |
|
|
costs: costData.costs, |
|
|
formatted: costData.formatted, |
|
|
pricing: costData.pricing |
|
|
}) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if (modelStats.length === 0) { |
|
|
logger.info(`📊 No model stats found for key ${keyId} in period ${period}`) |
|
|
} |
|
|
|
|
|
|
|
|
modelStats.sort((a, b) => b.allTokens - a.allTokens) |
|
|
|
|
|
return res.json({ |
|
|
success: true, |
|
|
data: modelStats, |
|
|
period |
|
|
}) |
|
|
} catch (error) { |
|
|
logger.error('❌ Failed to process user model stats query:', error) |
|
|
return res.status(500).json({ |
|
|
error: 'Internal server error', |
|
|
message: 'Failed to retrieve model statistics' |
|
|
}) |
|
|
} |
|
|
}) |
|
|
|
|
|
module.exports = router |
|
|
|