diff --git a/VERSION b/VERSION
index 62242964..3e3c866e 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.1.220
+1.1.222
diff --git a/package-lock.json b/package-lock.json
index 039664e6..551062b7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -890,6 +890,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -2998,6 +2999,7 @@
"integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -3079,6 +3081,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3534,6 +3537,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001737",
"electron-to-chromium": "^1.5.211",
@@ -4421,6 +4425,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -4477,6 +4482,7 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -7575,6 +7581,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -9014,6 +9021,7 @@
"resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz",
"integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.2",
diff --git a/src/handlers/geminiHandlers.js b/src/handlers/geminiHandlers.js
index cfdf35e0..87295d31 100644
--- a/src/handlers/geminiHandlers.js
+++ b/src/handlers/geminiHandlers.js
@@ -449,9 +449,8 @@ async function handleMessages(req, res) {
// 添加代理配置
if (proxyConfig) {
- const proxyHelper = new ProxyHelper()
- axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
- axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
+ axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
+ axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
}
try {
@@ -732,9 +731,8 @@ async function handleModels(req, res) {
headers: { 'Content-Type': 'application/json' }
}
if (proxyConfig) {
- const proxyHelper = new ProxyHelper()
- axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
- axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
+ axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
+ axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
}
const response = await axios(axiosConfig)
models = (response.data.models || []).map((m) => ({
@@ -1234,9 +1232,8 @@ async function handleCountTokens(req, res) {
}
if (proxyConfig) {
- const proxyHelper = new ProxyHelper()
- axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
- axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
+ axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
+ axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
}
try {
@@ -1963,9 +1960,8 @@ async function handleStandardGenerateContent(req, res) {
}
if (proxyConfig) {
- const proxyHelper = new ProxyHelper()
- axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
- axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
+ axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
+ axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
}
try {
@@ -2246,9 +2242,8 @@ async function handleStandardStreamGenerateContent(req, res) {
}
if (proxyConfig) {
- const proxyHelper = new ProxyHelper()
- axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
- axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
+ axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
+ axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
}
try {
diff --git a/src/routes/admin/usageStats.js b/src/routes/admin/usageStats.js
index f44a1b4d..18d6b436 100644
--- a/src/routes/admin/usageStats.js
+++ b/src/routes/admin/usageStats.js
@@ -1,8 +1,10 @@
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')
@@ -148,7 +150,6 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
accountData = await geminiAccountService.getAccount(accountId)
break
case 'gemini-api': {
- const geminiApiAccountService = require('../../services/geminiApiAccountService')
accountData = await geminiApiAccountService.getAccount(accountId)
break
}
@@ -369,7 +370,9 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => {
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}`)
+ logger.info(
+ ` System timezone offset: ${require('../../../config/config').system.timezoneOffset || 8}`
+ )
} else {
// 默认最近24小时
endTime = new Date()
@@ -890,7 +893,6 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
})
]
} else if (group === 'gemini') {
- const geminiApiAccountService = require('../../services/geminiApiAccountService')
const [geminiAccounts, geminiApiAccounts] = await Promise.all([
geminiAccountService.getAllAccounts(),
geminiApiAccountService.getAllAccounts(true)
@@ -1818,4 +1820,330 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => {
}
})
+// 获取 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 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 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)
+ }
+
+ const servicesToTry = type
+ ? accountServices.filter((svc) => svc.type === type)
+ : 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 key = `${record.accountId}:${record.accountType || 'unknown'}`
+ if (!accountOptionMap.has(key)) {
+ accountOptionMap.set(key, {
+ id: record.accountId,
+ accountType: record.accountType || 'unknown'
+ })
+ }
+ }
+
+ 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 info = await resolveAccountInfo(option.id, option.accountType)
+ const resolvedType = info?.type || option.accountType || 'unknown'
+ accountOptions.push({
+ id: option.id,
+ name: info?.name || option.id,
+ accountType: resolvedType,
+ accountTypeName: accountTypeNames[resolvedType] || '未知渠道'
+ })
+ }
+
+ 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 })
+ }
+})
+
module.exports = router
diff --git a/src/routes/admin/utils.js b/src/routes/admin/utils.js
index 47b68123..49c02015 100644
--- a/src/routes/admin/utils.js
+++ b/src/routes/admin/utils.js
@@ -33,7 +33,9 @@ function mapExpiryField(updates, accountType, accountId) {
if ('expiresAt' in mappedUpdates) {
mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt
delete mappedUpdates.expiresAt
- logger.info(`Mapping expiresAt to subscriptionExpiresAt for ${accountType} account ${accountId}`)
+ logger.info(
+ `Mapping expiresAt to subscriptionExpiresAt for ${accountType} account ${accountId}`
+ )
}
return mappedUpdates
}
diff --git a/web/admin-spa/src/components/apikeys/RecordDetailModal.vue b/web/admin-spa/src/components/apikeys/RecordDetailModal.vue
new file mode 100644
index 00000000..6a4b6482
--- /dev/null
+++ b/web/admin-spa/src/components/apikeys/RecordDetailModal.vue
@@ -0,0 +1,211 @@
+
+
+ 请求详情
+
+ {{ record?.model || '未知模型' }}
+ 基本信息
+
+
+ Token 使用
+
+
+ 费用详情
+