From 82a6ec2f43a4cdea5154ca006f5b8a592078acae Mon Sep 17 00:00:00 2001 From: hex2077 Date: Thu, 9 Apr 2026 16:30:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugin):=20=E6=96=B0=E5=A2=9E=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E7=94=A8=E9=87=8F=E7=BB=9F=E8=AE=A1=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=B9=B6=E5=A2=9E=E5=BC=BA=20API=20Potluck=20=E7=9A=84=20token?= =?UTF-8?q?=20=E7=BB=9F=E8=AE=A1=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `model-usage-stats` 插件,提供模型级别的 token 用量统计和 API 接口 - 增强 API Potluck 插件,记录并展示 prompt、completion 和 total tokens 用量 - 更新插件管理器以支持禁用插件的路由拦截和静态文件访问控制 - 在前端页面中展示 token 用量统计数据 - 升级版本号至 2.13.3 --- .gitignore | 3 +- VERSION | 2 +- configs/plugins.json.example | 6 +- src/core/plugin-manager.js | 58 ++- src/handlers/request-handler.js | 15 +- src/plugins/ai-monitor/index.js | 27 +- src/plugins/api-potluck/api-routes.js | 10 +- src/plugins/api-potluck/index.js | 84 +++- src/plugins/api-potluck/key-manager.js | 147 ++++++- src/plugins/model-usage-stats/api-routes.js | 74 ++++ src/plugins/model-usage-stats/index.js | 92 ++++ .../model-usage-stats/stats-manager.js | 401 ++++++++++++++++++ src/utils/common.js | 14 +- static/model-usage-stats.html | 129 ++++++ static/potluck-user.html | 41 +- static/potluck.html | 41 +- 16 files changed, 1081 insertions(+), 63 deletions(-) create mode 100644 src/plugins/model-usage-stats/api-routes.js create mode 100644 src/plugins/model-usage-stats/index.js create mode 100644 src/plugins/model-usage-stats/stats-manager.js create mode 100644 static/model-usage-stats.html diff --git a/.gitignore b/.gitignore index c375cbc..3dfae3a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,6 @@ usage-cache.json *-auth-token.json api-potluck-keys.json api-potluck-data.json -# Codex credentials -configs/codex/ +model-usage-stats.json AGENTS.md diff --git a/VERSION b/VERSION index b24afe2..a1a4224 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.13.2.1 +2.13.3 diff --git a/configs/plugins.json.example b/configs/plugins.json.example index fe13d19..b8c62b9 100644 --- a/configs/plugins.json.example +++ b/configs/plugins.json.example @@ -7,6 +7,10 @@ "default-auth": { "enabled": true, "description": "默认 API Key 认证插件(内置)" + }, + "model-usage-stats": { + "enabled": false, + "description": "模型用量统计插件" } } -} \ No newline at end of file +} diff --git a/src/core/plugin-manager.js b/src/core/plugin-manager.js index a3b7b72..50b2b21 100644 --- a/src/core/plugin-manager.js +++ b/src/core/plugin-manager.js @@ -17,7 +17,7 @@ import path from 'path'; const PLUGINS_CONFIG_FILE = path.join(process.cwd(), 'configs', 'plugins.json'); // 默认禁用的插件列表 -const DEFAULT_DISABLED_PLUGINS = ['api-potluck', 'ai-monitor']; +const DEFAULT_DISABLED_PLUGINS = ['api-potluck', 'ai-monitor', 'model-usage-stats']; /** * 插件类型常量 @@ -385,9 +385,10 @@ class PluginManager { * @param {string} path - 请求路径 * @param {http.IncomingMessage} req - HTTP 请求 * @param {http.ServerResponse} res - HTTP 响应 + * @param {Object} [config] - 当前请求配置 * @returns {Promise} - 是否已处理 */ - async executeRoutes(method, path, req, res) { + async executeRoutes(method, path, req, res, config) { for (const plugin of this.getEnabledPlugins()) { if (!Array.isArray(plugin.routes)) continue; @@ -404,7 +405,7 @@ class PluginManager { if (pathMatch) { try { - const handled = await route.handler(method, path, req, res); + const handled = await route.handler(method, path, req, res, config); if (handled) return true; } catch (error) { logger.error(`[PluginManager] Route error in plugin "${plugin.name}":`, error.message); @@ -412,6 +413,35 @@ class PluginManager { } } } + + for (const plugin of this.plugins.values()) { + if (plugin._enabled || !Array.isArray(plugin.routes)) continue; + + for (const route of plugin.routes) { + const methodMatch = route.method === '*' || route.method.toUpperCase() === method; + if (!methodMatch) continue; + + let pathMatch = false; + if (route.path instanceof RegExp) { + pathMatch = route.path.test(path); + } else if (typeof route.path === 'string') { + pathMatch = path === route.path || path.startsWith(route.path + '/'); + } + + if (pathMatch) { + res.writeHead(503, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ + success: false, + error: { + message: `插件未启用:${plugin.name}`, + code: 'PLUGIN_DISABLED' + } + })); + return true; + } + } + } + return false; } @@ -421,7 +451,7 @@ class PluginManager { */ getStaticPaths() { const paths = []; - for (const plugin of this.getEnabledPlugins()) { + for (const plugin of this.plugins.values()) { if (Array.isArray(plugin.staticPaths)) { paths.push(...plugin.staticPaths); } @@ -439,6 +469,24 @@ class PluginManager { return staticPaths.some(sp => path === sp || path === '/' + sp); } + /** + * 获取静态路径所属插件 + * @param {string} path - 请求路径 + * @returns {Plugin|null} + */ + getPluginByStaticPath(path) { + for (const plugin of this.plugins.values()) { + if (!Array.isArray(plugin.staticPaths)) continue; + + const matched = plugin.staticPaths.some(sp => path === sp || path === '/' + sp); + if (matched) { + return plugin; + } + } + + return null; + } + /** * 执行钩子函数 * @param {string} hookName - 钩子名称 @@ -546,4 +594,4 @@ export function getPluginManager() { } // 导出类和实例 -export { PluginManager, pluginManager }; \ No newline at end of file +export { PluginManager, pluginManager }; diff --git a/src/handlers/request-handler.js b/src/handlers/request-handler.js index 6fb542e..6b63254 100644 --- a/src/handlers/request-handler.js +++ b/src/handlers/request-handler.js @@ -54,6 +54,7 @@ export function createRequestHandler(config, providerPoolManager) { return logger.runWithContext(requestId, async () => { // Deep copy the config for each request to allow dynamic modification const currentConfig = deepmerge({}, config); + currentConfig._pluginRequestId = requestId; // 计算当前请求的基础 URL const protocol = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'; @@ -82,13 +83,25 @@ export function createRequestHandler(config, providerPoolManager) { // 检查是否是插件静态文件 const pluginManager = getPluginManager(); const isPluginStatic = pluginManager.isPluginStaticPath(path); + const pluginStaticOwner = isPluginStatic ? pluginManager.getPluginByStaticPath(path) : null; + if (pluginStaticOwner && !pluginStaticOwner._enabled) { + res.writeHead(503, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ + success: false, + error: { + message: `插件未启用:${pluginStaticOwner.name}`, + code: 'PLUGIN_DISABLED' + } + })); + return; + } if (path.startsWith('/static/') || path === '/' || path === '/favicon.ico' || path === '/index.html' || path.startsWith('/app/') || path.startsWith('/components/') || path === '/login.html' || isPluginStatic) { const served = await serveStaticFiles(path, res); if (served) return; } // 执行插件路由 - const pluginRouteHandled = await pluginManager.executeRoutes(method, path, req, res); + const pluginRouteHandled = await pluginManager.executeRoutes(method, path, req, res, currentConfig); if (pluginRouteHandled) return; const uiHandled = await handleUIApiRequests(method, path, req, res, currentConfig, providerPoolManager); diff --git a/src/plugins/ai-monitor/index.js b/src/plugins/ai-monitor/index.js index 6e259ac..3a24473 100644 --- a/src/plugins/ai-monitor/index.js +++ b/src/plugins/ai-monitor/index.js @@ -41,37 +41,38 @@ const aiMonitorPlugin = { * 请求转换后的钩子 */ async onContentGenerated(config) { - const { originalRequestBody, processedRequestBody, fromProvider, toProvider, model, _monitorRequestId, isStream } = config; + const { originalRequestBody, processedRequestBody, fromProvider, toProvider, model, _monitorRequestId, _pluginRequestId, isStream } = config; if (!originalRequestBody) return; + const traceRequestId = _pluginRequestId || _monitorRequestId; setImmediate(() => { const hasConversion = JSON.stringify(originalRequestBody) !== JSON.stringify(processedRequestBody); - logger.info(`[AI Monitor][${_monitorRequestId}] >>> Req Protocol: ${fromProvider}${hasConversion ? ' -> ' + toProvider : ''} | Model: ${model}`); + logger.info(`[AI Monitor][${traceRequestId}] >>> Req Protocol: ${fromProvider}${hasConversion ? ' -> ' + toProvider : ''} | Model: ${model}`); if (hasConversion) { - logger.info(`[AI Monitor][${_monitorRequestId}] [Req Original]: ${JSON.stringify(originalRequestBody)}`); - logger.info(`[AI Monitor][${_monitorRequestId}] [Req Processed]: ${JSON.stringify(processedRequestBody)}`); + logger.info(`[AI Monitor][${traceRequestId}] [Req Original]: ${JSON.stringify(originalRequestBody)}`); + logger.info(`[AI Monitor][${traceRequestId}] [Req Processed]: ${JSON.stringify(processedRequestBody)}`); } else { - logger.info(`[AI Monitor][${_monitorRequestId}] [Req]: ${JSON.stringify(originalRequestBody)}`); + logger.info(`[AI Monitor][${traceRequestId}] [Req]: ${JSON.stringify(originalRequestBody)}`); } }); // 处理流式响应的聚合输出 - if (isStream && _monitorRequestId) { + if (isStream && traceRequestId) { setTimeout(() => { - const cache = aiMonitorPlugin.streamCache.get(_monitorRequestId); + const cache = aiMonitorPlugin.streamCache.get(traceRequestId); if (cache) { const hasConversion = JSON.stringify(cache.nativeChunks) !== JSON.stringify(cache.convertedChunks); - logger.info(`[AI Monitor][${_monitorRequestId}] <<< Stream Response Aggregated: ${hasConversion ? cache.toProvider + ' -> ' : ''}${cache.fromProvider}`); + logger.info(`[AI Monitor][${traceRequestId}] <<< Stream Response Aggregated: ${hasConversion ? cache.toProvider + ' -> ' : ''}${cache.fromProvider}`); if (hasConversion) { - logger.info(`[AI Monitor][${_monitorRequestId}] [Res Native Full]: ${JSON.stringify(cache.nativeChunks)}`); - logger.info(`[AI Monitor][${_monitorRequestId}] [Res Converted Full]: ${JSON.stringify(cache.convertedChunks)}`); + logger.info(`[AI Monitor][${traceRequestId}] [Res Native Full]: ${JSON.stringify(cache.nativeChunks)}`); + logger.info(`[AI Monitor][${traceRequestId}] [Res Converted Full]: ${JSON.stringify(cache.convertedChunks)}`); } else { - logger.info(`[AI Monitor][${_monitorRequestId}] [Res Full]: ${JSON.stringify(cache.nativeChunks)}`); + logger.info(`[AI Monitor][${traceRequestId}] [Res Full]: ${JSON.stringify(cache.nativeChunks)}`); } - aiMonitorPlugin.streamCache.delete(_monitorRequestId); + aiMonitorPlugin.streamCache.delete(traceRequestId); } }, 2000); // 等待流传输完成 } @@ -142,4 +143,4 @@ const aiMonitorPlugin = { } }; -export default aiMonitorPlugin; \ No newline at end of file +export default aiMonitorPlugin; diff --git a/src/plugins/api-potluck/api-routes.js b/src/plugins/api-potluck/api-routes.js index dd76120..2c8d9dd 100644 --- a/src/plugins/api-potluck/api-routes.js +++ b/src/plugins/api-potluck/api-routes.js @@ -420,9 +420,17 @@ export async function handlePotluckUserApiRoutes(method, path, req, res) { limit: keyData.dailyLimit, remaining: Math.max(0, keyData.dailyLimit - keyData.todayUsage), percent: usagePercent, - resetDate: keyData.lastResetDate + resetDate: keyData.lastResetDate, + promptTokens: keyData.todayPromptTokens || 0, + completionTokens: keyData.todayCompletionTokens || 0, + totalTokens: keyData.todayTotalTokens || 0 }, total: keyData.totalUsage, + tokens: { + prompt: keyData.totalPromptTokens || 0, + completion: keyData.totalCompletionTokens || 0, + total: keyData.totalTokens || 0 + }, lastUsedAt: keyData.lastUsedAt, createdAt: keyData.createdAt, usageHistory: keyData.usageHistory || {}, diff --git a/src/plugins/api-potluck/index.js b/src/plugins/api-potluck/index.js index 93c2bd9..48a9eb9 100644 --- a/src/plugins/api-potluck/index.js +++ b/src/plugins/api-potluck/index.js @@ -34,6 +34,61 @@ import logger from '../../utils/logger.js'; import { handlePotluckApiRoutes, handlePotluckUserApiRoutes } from './api-routes.js'; +const pendingUsage = new Map(); + +function toNumber(value) { + const num = Number(value); + return Number.isFinite(num) ? num : 0; +} + +function normalizeUsageCandidate(candidate) { + if (!candidate || typeof candidate !== 'object') { + return null; + } + + const usage = candidate.usage || candidate.message?.usage || candidate.usageMetadata || candidate.response?.usage || null; + const promptTokens = toNumber( + candidate.prompt_tokens ?? + usage?.prompt_tokens ?? + usage?.input_tokens ?? + usage?.promptTokenCount + ); + const completionTokens = toNumber( + candidate.completion_tokens ?? + usage?.completion_tokens ?? + usage?.output_tokens ?? + usage?.candidatesTokenCount + ); + const totalTokens = toNumber( + candidate.total_tokens ?? + usage?.total_tokens ?? + usage?.totalTokenCount + ) || promptTokens + completionTokens; + + return { + promptTokens, + completionTokens, + totalTokens + }; +} + +function mergeUsage(baseUsage, nextUsage) { + if (!nextUsage) return baseUsage; + return { + promptTokens: Math.max(baseUsage.promptTokens, nextUsage.promptTokens), + completionTokens: Math.max(baseUsage.completionTokens, nextUsage.completionTokens), + totalTokens: Math.max(baseUsage.totalTokens, nextUsage.totalTokens) + }; +} + +function extractUsage(...candidates) { + return candidates.reduce((usage, candidate) => mergeUsage(usage, normalizeUsageCandidate(candidate)), { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0 + }); +} + /** * 插件定义 */ @@ -146,24 +201,51 @@ const apiPotluckPlugin = { * 钩子函数 */ hooks: { + async onUnaryResponse({ requestId, nativeResponse, clientResponse }) { + if (!requestId) return; + pendingUsage.set(requestId, mergeUsage( + pendingUsage.get(requestId) || { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, + extractUsage(nativeResponse, clientResponse) + )); + }, + + async onStreamChunk({ requestId, nativeChunk, chunkToSend }) { + if (!requestId) return; + pendingUsage.set(requestId, mergeUsage( + pendingUsage.get(requestId) || { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, + extractUsage(nativeChunk, chunkToSend) + )); + }, + /** * 内容生成后钩子 - 记录用量 * @param {Object} hookContext - 钩子上下文,包含请求和模型信息 */ async onContentGenerated(hookContext) { + const requestId = hookContext._pluginRequestId || hookContext._monitorRequestId; + if (hookContext.potluckApiKey) { try { + const usage = requestId + ? (pendingUsage.get(requestId) || { promptTokens: 0, completionTokens: 0, totalTokens: 0 }) + : { promptTokens: 0, completionTokens: 0, totalTokens: 0 }; + // 传入提供商和模型信息 await incrementUsage( hookContext.potluckApiKey, hookContext.toProvider, - hookContext.model + hookContext.model, + usage ); } catch (e) { // 静默失败,不影响主流程 logger.error('[API Potluck Plugin] Failed to record usage:', e.message); } } + + if (requestId) { + pendingUsage.delete(requestId); + } } }, diff --git a/src/plugins/api-potluck/key-manager.js b/src/plugins/api-potluck/key-manager.js index b2f0ec1..e5df5a6 100644 --- a/src/plugins/api-potluck/key-manager.js +++ b/src/plugins/api-potluck/key-manager.js @@ -47,6 +47,92 @@ let isWriting = false; let persistTimer = null; let currentPersistInterval = DEFAULT_CONFIG.persistInterval; +function createUsageBucket() { + return { + requestCount: 0, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0 + }; +} + +function toNumber(value) { + const num = Number(value); + return Number.isFinite(num) ? num : 0; +} + +function normalizeUsageBucket(bucket) { + if (typeof bucket === 'number') { + return { + ...createUsageBucket(), + requestCount: bucket + }; + } + + return { + ...createUsageBucket(), + ...(bucket || {}), + requestCount: toNumber(bucket?.requestCount), + promptTokens: toNumber(bucket?.promptTokens), + completionTokens: toNumber(bucket?.completionTokens), + totalTokens: toNumber(bucket?.totalTokens) + }; +} + +function normalizeUsageMap(map = {}) { + const normalized = {}; + for (const [name, usage] of Object.entries(map || {})) { + normalized[name] = normalizeUsageBucket(usage); + } + return normalized; +} + +function normalizeUsageHistoryDay(day = {}) { + return { + summary: normalizeUsageBucket(day.summary || { + requestCount: day.requestCount + }), + providers: normalizeUsageMap(day.providers), + models: normalizeUsageMap(day.models) + }; +} + +function normalizeKeyData(keyData = {}) { + const normalized = { + ...keyData, + todayUsage: toNumber(keyData.todayUsage), + totalUsage: toNumber(keyData.totalUsage), + todayPromptTokens: toNumber(keyData.todayPromptTokens), + todayCompletionTokens: toNumber(keyData.todayCompletionTokens), + todayTotalTokens: toNumber(keyData.todayTotalTokens), + totalPromptTokens: toNumber(keyData.totalPromptTokens), + totalCompletionTokens: toNumber(keyData.totalCompletionTokens), + totalTokens: toNumber(keyData.totalTokens), + usageHistory: {} + }; + + for (const [date, day] of Object.entries(keyData.usageHistory || {})) { + normalized.usageHistory[date] = normalizeUsageHistoryDay(day); + } + + return normalized; +} + +function normalizeStore(store = {}) { + const normalized = { keys: {} }; + for (const [keyId, keyData] of Object.entries(store.keys || {})) { + normalized.keys[keyId] = normalizeKeyData(keyData); + } + return normalized; +} + +function addUsage(target, usage = {}) { + target.requestCount += toNumber(usage.requestCount); + target.promptTokens += toNumber(usage.promptTokens); + target.completionTokens += toNumber(usage.completionTokens); + target.totalTokens += toNumber(usage.totalTokens); +} + /** * 初始化:从文件加载数据到内存 */ @@ -55,7 +141,7 @@ function ensureLoaded() { try { if (existsSync(KEYS_STORE_FILE)) { const content = readFileSync(KEYS_STORE_FILE, 'utf8'); - keyStore = JSON.parse(content); + keyStore = normalizeStore(JSON.parse(content)); } else { keyStore = { keys: {} }; syncWriteToFile(); @@ -159,6 +245,9 @@ function checkAndResetDailyCount(keyData) { const today = getTodayDateString(); if (keyData.lastResetDate !== today) { keyData.todayUsage = 0; + keyData.todayPromptTokens = 0; + keyData.todayCompletionTokens = 0; + keyData.todayTotalTokens = 0; keyData.lastResetDate = today; } return keyData; @@ -186,6 +275,12 @@ export async function createKey(name = '', dailyLimit = null) { dailyLimit: actualDailyLimit, todayUsage: 0, totalUsage: 0, + todayPromptTokens: 0, + todayCompletionTokens: 0, + todayTotalTokens: 0, + totalPromptTokens: 0, + totalCompletionTokens: 0, + totalTokens: 0, lastResetDate: today, lastUsedAt: null, enabled: true @@ -256,7 +351,12 @@ export async function resetKeyUsage(keyId) { ensureLoaded(); if (!keyStore.keys[keyId]) return null; keyStore.keys[keyId].todayUsage = 0; + keyStore.keys[keyId].todayPromptTokens = 0; + keyStore.keys[keyId].todayCompletionTokens = 0; + keyStore.keys[keyId].todayTotalTokens = 0; keyStore.keys[keyId].lastResetDate = getTodayDateString(); + if (!keyStore.keys[keyId].usageHistory) keyStore.keys[keyId].usageHistory = {}; + keyStore.keys[keyId].usageHistory[getTodayDateString()] = normalizeUsageHistoryDay(); markDirty(); return keyStore.keys[keyId]; } @@ -348,8 +448,9 @@ export async function validateKey(apiKey) { * @param {string} apiKey - API Key * @param {string} provider - 使用的提供商 * @param {string} model - 使用的模型 + * @param {{promptTokens?: number, completionTokens?: number, totalTokens?: number}} usage - token 用量 */ -export async function incrementUsage(apiKey, provider = 'unknown', model = 'unknown') { +export async function incrementUsage(apiKey, provider = 'unknown', model = 'unknown', usage = {}) { ensureLoaded(); const keyData = keyStore.keys[apiKey]; if (!keyData) return null; @@ -365,13 +466,19 @@ export async function incrementUsage(apiKey, provider = 'unknown', model = 'unkn } keyData.totalUsage += 1; + keyData.todayPromptTokens += toNumber(usage.promptTokens); + keyData.todayCompletionTokens += toNumber(usage.completionTokens); + keyData.todayTotalTokens += toNumber(usage.totalTokens); + keyData.totalPromptTokens += toNumber(usage.promptTokens); + keyData.totalCompletionTokens += toNumber(usage.completionTokens); + keyData.totalTokens += toNumber(usage.totalTokens); keyData.lastUsedAt = new Date().toISOString(); // 记录个人按天统计 (每个 Key 独立) const today = getTodayDateString(); if (!keyData.usageHistory) keyData.usageHistory = {}; if (!keyData.usageHistory[today]) { - keyData.usageHistory[today] = { providers: {}, models: {} }; + keyData.usageHistory[today] = normalizeUsageHistoryDay(); } // 确保 provider 和 model 是字符串 @@ -379,8 +486,11 @@ export async function incrementUsage(apiKey, provider = 'unknown', model = 'unkn const mName = String(model || 'unknown'); const userHistory = keyData.usageHistory[today]; - userHistory.providers[pName] = (userHistory.providers[pName] || 0) + 1; - userHistory.models[mName] = (userHistory.models[mName] || 0) + 1; + userHistory.providers[pName] = normalizeUsageBucket(userHistory.providers[pName]); + userHistory.models[mName] = normalizeUsageBucket(userHistory.models[mName]); + addUsage(userHistory.summary, { requestCount: 1, ...usage }); + addUsage(userHistory.providers[pName], { requestCount: 1, ...usage }); + addUsage(userHistory.models[mName], { requestCount: 1, ...usage }); // 清理该 Key 的过期历史 (保留 7 天) const userDates = Object.keys(keyData.usageHistory).sort(); @@ -404,6 +514,8 @@ export async function getStats() { ensureLoaded(); const keys = Object.values(keyStore.keys); let enabledKeys = 0, todayTotalUsage = 0, totalUsage = 0; + let todayPromptTokens = 0, todayCompletionTokens = 0, todayTotalTokens = 0; + let totalPromptTokens = 0, totalCompletionTokens = 0, totalTokens = 0; const aggregatedHistory = {}; for (const key of keys) { @@ -411,25 +523,34 @@ export async function getStats() { if (key.enabled) enabledKeys++; todayTotalUsage += key.todayUsage; totalUsage += key.totalUsage; + todayPromptTokens += key.todayPromptTokens || 0; + todayCompletionTokens += key.todayCompletionTokens || 0; + todayTotalTokens += key.todayTotalTokens || 0; + totalPromptTokens += key.totalPromptTokens || 0; + totalCompletionTokens += key.totalCompletionTokens || 0; + totalTokens += key.totalTokens || 0; // 汇总每个 Key 的历史数据 if (key.usageHistory) { Object.entries(key.usageHistory).forEach(([date, history]) => { if (!aggregatedHistory[date]) { - aggregatedHistory[date] = { providers: {}, models: {} }; + aggregatedHistory[date] = normalizeUsageHistoryDay(); } + addUsage(aggregatedHistory[date].summary, history.summary); // 汇总提供商 if (history.providers) { - Object.entries(history.providers).forEach(([p, count]) => { - aggregatedHistory[date].providers[p] = (aggregatedHistory[date].providers[p] || 0) + count; + Object.entries(history.providers).forEach(([p, usage]) => { + aggregatedHistory[date].providers[p] = normalizeUsageBucket(aggregatedHistory[date].providers[p]); + addUsage(aggregatedHistory[date].providers[p], usage); }); } // 汇总模型 if (history.models) { - Object.entries(history.models).forEach(([m, count]) => { - aggregatedHistory[date].models[m] = (aggregatedHistory[date].models[m] || 0) + count; + Object.entries(history.models).forEach(([m, usage]) => { + aggregatedHistory[date].models[m] = normalizeUsageBucket(aggregatedHistory[date].models[m]); + addUsage(aggregatedHistory[date].models[m], usage); }); } }); @@ -442,6 +563,12 @@ export async function getStats() { disabledKeys: keys.length - enabledKeys, todayTotalUsage, totalUsage, + todayPromptTokens, + todayCompletionTokens, + todayTotalTokens, + totalPromptTokens, + totalCompletionTokens, + totalTokens, usageHistory: aggregatedHistory }; } diff --git a/src/plugins/model-usage-stats/api-routes.js b/src/plugins/model-usage-stats/api-routes.js new file mode 100644 index 0000000..28c64a3 --- /dev/null +++ b/src/plugins/model-usage-stats/api-routes.js @@ -0,0 +1,74 @@ +import logger from '../../utils/logger.js'; +import { checkAuth } from '../../ui-modules/auth.js'; +import { isAuthorized } from '../../utils/common.js'; +import { getStats, resetStats } from './stats-manager.js'; + +function sendJson(res, statusCode, data) { + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(data)); +} + +async function checkAdminAuth(req, config) { + try { + if (await checkAuth(req)) { + return true; + } + + if (config?.REQUIRED_API_KEY) { + const requestUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`); + return isAuthorized(req, requestUrl, config.REQUIRED_API_KEY); + } + + return false; + } catch (error) { + logger.error('[Model Usage Stats] Auth check error:', error.message); + return false; + } +} + +export async function handleModelUsageStatsRoutes(method, path, req, res, config) { + if (!path.startsWith('/api/model-usage-stats')) { + return false; + } + + const isAuthed = await checkAdminAuth(req, config); + if (!isAuthed) { + sendJson(res, 401, { + success: false, + error: { + message: '未授权:请提供后台登录 Token 或有效 API Key', + code: 'UNAUTHORIZED' + } + }); + return true; + } + + try { + if (method === 'GET' && path === '/api/model-usage-stats') { + const stats = await getStats(); + sendJson(res, 200, { success: true, data: stats }); + return true; + } + + if ((method === 'POST' || method === 'DELETE') && path === '/api/model-usage-stats/reset') { + const stats = await resetStats(); + sendJson(res, 200, { + success: true, + message: '模型统计已重置', + data: stats + }); + return true; + } + } catch (error) { + logger.error('[Model Usage Stats] Route error:', error.message); + sendJson(res, 500, { + success: false, + error: { + message: error.message + } + }); + return true; + } + + return false; +} diff --git a/src/plugins/model-usage-stats/index.js b/src/plugins/model-usage-stats/index.js new file mode 100644 index 0000000..70b5d9b --- /dev/null +++ b/src/plugins/model-usage-stats/index.js @@ -0,0 +1,92 @@ +import logger from '../../utils/logger.js'; +import { handleModelUsageStatsRoutes } from './api-routes.js'; +import { + finalizeRequest, + getStats, + recordStreamChunkUsage, + recordUnaryUsage, + resetStats, + setConfigGetter +} from './stats-manager.js'; + +const modelUsageStatsPlugin = { + name: 'model-usage-stats', + version: '1.0.0', + description: '模型用量统计插件
接口:/api/model-usage-stats
页面:model-usage-stats.html', + type: 'middleware', + _builtin: true, + _priority: 9000, + + async init(config) { + setConfigGetter(() => ({ + persistInterval: config.MODEL_USAGE_STATS_PERSIST_INTERVAL || 5000 + })); + logger.info('[Model Usage Stats] Initialized'); + }, + + async middleware(req, res, requestUrl, config) { + const aiPaths = ['/v1/chat/completions', '/v1/responses', '/v1/messages', '/v1beta/models']; + const isAiPath = aiPaths.some(path => requestUrl.pathname.includes(path)); + + if (isAiPath && req.method === 'POST' && !config._monitorRequestId) { + config._monitorRequestId = Date.now() + Math.random().toString(36).substring(2, 10); + } + + return { handled: false }; + }, + + routes: [ + { + method: '*', + path: '/api/model-usage-stats', + handler: handleModelUsageStatsRoutes + } + ], + + staticPaths: ['model-usage-stats.html'], + + hooks: { + async onUnaryResponse({ requestId, model, fromProvider, toProvider, nativeResponse, clientResponse }) { + recordUnaryUsage({ + requestId, + model, + provider: toProvider, + fromProvider, + nativeResponse, + clientResponse + }); + }, + + async onStreamChunk({ requestId, model, fromProvider, toProvider, nativeChunk, chunkToSend }) { + recordStreamChunkUsage({ + requestId, + model, + provider: toProvider, + fromProvider, + nativeChunk, + clientChunk: chunkToSend + }); + }, + + async onContentGenerated(config) { + await finalizeRequest({ + requestId: config._monitorRequestId, + model: config.model, + provider: config.toProvider, + fromProvider: config.fromProvider, + isStream: config.isStream + }); + } + }, + + exports: { + getStats, + resetStats + } +}; + +export default modelUsageStatsPlugin; +export { + getStats, + resetStats +}; diff --git a/src/plugins/model-usage-stats/stats-manager.js b/src/plugins/model-usage-stats/stats-manager.js new file mode 100644 index 0000000..2d50fb0 --- /dev/null +++ b/src/plugins/model-usage-stats/stats-manager.js @@ -0,0 +1,401 @@ +import { promises as fs } from 'fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import path from 'path'; +import logger from '../../utils/logger.js'; + +const STATS_STORE_FILE = path.join(process.cwd(), 'configs', 'model-usage-stats.json'); +const DEFAULT_CONFIG = { + persistInterval: 5000 +}; + +let configGetter = null; +let statsStore = null; +let isDirty = false; +let isWriting = false; +let persistTimer = null; +let currentPersistInterval = DEFAULT_CONFIG.persistInterval; +let mutationVersion = 0; +let persistPromise = null; + +const pendingRequests = new Map(); + +function getTraceRequestId(requestId) { + return requestId || 'N/A'; +} + +function getTracePrefix(requestId) { + return `[Model Usage Stats][${getTraceRequestId(requestId)}]`; +} + +function createEmptyUsage() { + return { + requestCount: 0, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cachedTokens: 0, + lastUsedAt: null + }; +} + +function createDefaultStore() { + return { + updatedAt: null, + summary: createEmptyUsage(), + providers: {} + }; +} + +function normalizeUsageBlock(block = {}) { + return { + ...createEmptyUsage(), + ...block + }; +} + +function normalizeStore(store) { + const normalizedStore = { + updatedAt: store?.updatedAt || null, + summary: normalizeUsageBlock(store?.summary), + providers: {} + }; + + for (const [provider, providerStore] of Object.entries(store?.providers || {})) { + normalizedStore.providers[provider] = { + summary: normalizeUsageBlock(providerStore?.summary), + models: {} + }; + + for (const [model, modelStore] of Object.entries(providerStore?.models || {})) { + normalizedStore.providers[provider].models[model] = normalizeUsageBlock(modelStore); + } + } + + return normalizedStore; +} + +function getConfig() { + if (typeof configGetter === 'function') { + return configGetter(); + } + return DEFAULT_CONFIG; +} + +function ensureProviderStore(provider) { + ensureLoaded(); + if (!statsStore.providers[provider]) { + statsStore.providers[provider] = { + summary: createEmptyUsage(), + models: {} + }; + } + return statsStore.providers[provider]; +} + +function ensureModelStore(provider, model) { + const providerStore = ensureProviderStore(provider); + if (!providerStore.models[model]) { + providerStore.models[model] = createEmptyUsage(); + } + return providerStore.models[model]; +} + +function ensureLoaded() { + if (statsStore !== null) return; + + try { + if (existsSync(STATS_STORE_FILE)) { + const content = readFileSync(STATS_STORE_FILE, 'utf8'); + statsStore = normalizeStore(JSON.parse(content)); + logger.info(`[Model Usage Stats] Loaded stats store: providers=${Object.keys(statsStore.providers).length}, requests=${statsStore.summary.requestCount}, totalTokens=${statsStore.summary.totalTokens}`); + } else { + statsStore = createDefaultStore(); + syncWriteToFile(); + logger.info('[Model Usage Stats] Created new stats store'); + } + } catch (error) { + logger.error('[Model Usage Stats] Failed to load stats store:', error.message); + statsStore = createDefaultStore(); + } + + const config = getConfig(); + currentPersistInterval = config.persistInterval || DEFAULT_CONFIG.persistInterval; + + if (!persistTimer) { + persistTimer = setInterval(() => { + persistIfDirty(); + cleanupPendingRequests(); + }, currentPersistInterval); + if (persistTimer.unref) { + persistTimer.unref(); + } + process.on('beforeExit', () => persistIfDirty()); + process.on('SIGINT', () => { persistIfDirty(); process.exit(0); }); + process.on('SIGTERM', () => { persistIfDirty(); process.exit(0); }); + } +} + +function syncWriteToFile() { + try { + const dir = path.dirname(STATS_STORE_FILE); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(STATS_STORE_FILE, JSON.stringify(statsStore, null, 2), 'utf8'); + logger.info('[Model Usage Stats] Sync persisted stats store'); + } catch (error) { + logger.error('[Model Usage Stats] Sync write failed:', error.message); + } +} + +async function persistIfDirty() { + ensureLoaded(); + if (!isDirty || statsStore === null) return; + if (persistPromise) { + await persistPromise; + return; + } + + persistPromise = (async () => { + isWriting = true; + + try { + const dir = path.dirname(STATS_STORE_FILE); + if (!existsSync(dir)) { + await fs.mkdir(dir, { recursive: true }); + } + + while (isDirty) { + const versionAtStart = mutationVersion; + const snapshot = JSON.stringify(statsStore, null, 2); + const tempFile = STATS_STORE_FILE + '.tmp'; + await fs.writeFile(tempFile, snapshot, 'utf8'); + await fs.rename(tempFile, STATS_STORE_FILE); + + if (mutationVersion === versionAtStart) { + isDirty = false; + logger.info(`[Model Usage Stats] Persisted stats store: version=${versionAtStart}, requests=${statsStore.summary.requestCount}, totalTokens=${statsStore.summary.totalTokens}`); + } + } + } catch (error) { + logger.error('[Model Usage Stats] Persist failed:', error.message); + } finally { + isWriting = false; + persistPromise = null; + } + })(); + + await persistPromise; +} + +function markDirty() { + ensureLoaded(); + statsStore.updatedAt = new Date().toISOString(); + mutationVersion += 1; + isDirty = true; +} + +function cleanupPendingRequests() { + const now = Date.now(); + let removedCount = 0; + for (const [requestId, state] of pendingRequests.entries()) { + if (now - state.updatedAt > 10 * 60 * 1000) { + pendingRequests.delete(requestId); + removedCount += 1; + logger.warn(`${getTracePrefix(requestId)} Dropped stale pending request: Provider: ${state.provider} | Model: ${state.model}`); + } + } + if (removedCount > 0) { + logger.warn(`[Model Usage Stats] Cleaned stale pending requests: count=${removedCount}`); + } +} + +function toNumber(value) { + return Number.isFinite(Number(value)) ? Number(value) : 0; +} + +function normalizeUsageCandidate(candidate) { + if (!candidate || typeof candidate !== 'object') { + return null; + } + + const usage = candidate.usage || candidate.message?.usage || candidate.usageMetadata || candidate.response?.usage || null; + const promptTokens = toNumber( + candidate.prompt_tokens ?? + usage?.prompt_tokens ?? + usage?.input_tokens ?? + usage?.promptTokenCount ?? + usage?.inputTokenCount + ); + const completionTokens = toNumber( + candidate.completion_tokens ?? + usage?.completion_tokens ?? + usage?.output_tokens ?? + usage?.candidatesTokenCount ?? + usage?.outputTokenCount + ); + const totalTokens = toNumber( + candidate.total_tokens ?? + usage?.total_tokens ?? + usage?.totalTokenCount + ); + const cachedTokens = toNumber( + candidate.cached_tokens ?? + usage?.cached_tokens ?? + usage?.cache_read_input_tokens ?? + usage?.cachedContentTokenCount + ); + + const hasUsage = promptTokens > 0 || completionTokens > 0 || totalTokens > 0 || cachedTokens > 0; + if (!hasUsage) { + return null; + } + + return { + promptTokens, + completionTokens, + totalTokens: totalTokens || promptTokens + completionTokens, + cachedTokens + }; +} + +function mergeUsage(baseUsage, nextUsage) { + if (!nextUsage) { + return baseUsage; + } + + return { + promptTokens: Math.max(baseUsage.promptTokens, nextUsage.promptTokens), + completionTokens: Math.max(baseUsage.completionTokens, nextUsage.completionTokens), + totalTokens: Math.max(baseUsage.totalTokens, nextUsage.totalTokens || (nextUsage.promptTokens + nextUsage.completionTokens)), + cachedTokens: Math.max(baseUsage.cachedTokens, nextUsage.cachedTokens) + }; +} + +function extractUsage(...candidates) { + return candidates.reduce((usage, candidate) => { + const normalized = normalizeUsageCandidate(candidate); + return mergeUsage(usage, normalized); + }, { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cachedTokens: 0 + }); +} + +function getPendingRequest(requestId, meta = {}) { + ensureLoaded(); + + if (!pendingRequests.has(requestId)) { + pendingRequests.set(requestId, { + requestId, + model: meta.model || 'unknown', + provider: meta.provider || 'unknown', + fromProvider: meta.fromProvider || null, + isStream: Boolean(meta.isStream), + hasResponse: false, + usage: { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cachedTokens: 0 + }, + updatedAt: Date.now() + }); + } + + const state = pendingRequests.get(requestId); + state.model = meta.model || state.model; + state.provider = meta.provider || state.provider; + state.fromProvider = meta.fromProvider || state.fromProvider; + state.isStream = meta.isStream ?? state.isStream; + state.updatedAt = Date.now(); + + return state; +} + +function applyUsage(target, usage, timestamp) { + target.requestCount += 1; + target.promptTokens += usage.promptTokens; + target.completionTokens += usage.completionTokens; + target.totalTokens += usage.totalTokens || (usage.promptTokens + usage.completionTokens); + target.cachedTokens += usage.cachedTokens; + target.lastUsedAt = timestamp; +} + +export function setConfigGetter(getter) { + configGetter = getter; +} + +export function recordUnaryUsage({ requestId, model, provider, fromProvider, nativeResponse, clientResponse }) { + if (!requestId) return; + const state = getPendingRequest(requestId, { model, provider, fromProvider, isStream: false }); + const prevTotalTokens = state.usage.totalTokens; + const prevCachedTokens = state.usage.cachedTokens; + state.hasResponse = true; + state.usage = mergeUsage(state.usage, extractUsage(nativeResponse, clientResponse)); + if (state.usage.totalTokens > prevTotalTokens || state.usage.cachedTokens > prevCachedTokens) { + logger.info(`${getTracePrefix(requestId)} <<< Unary Usage Captured: Provider: ${state.provider} | Model: ${state.model} | Prompt: ${state.usage.promptTokens} | Completion: ${state.usage.completionTokens} | Total: ${state.usage.totalTokens} | Cached: ${state.usage.cachedTokens}`); + } +} + +export function recordStreamChunkUsage({ requestId, model, provider, fromProvider, nativeChunk, clientChunk }) { + if (!requestId) return; + const state = getPendingRequest(requestId, { model, provider, fromProvider, isStream: true }); + const prevTotalTokens = state.usage.totalTokens; + const prevCachedTokens = state.usage.cachedTokens; + state.hasResponse = true; + state.usage = mergeUsage(state.usage, extractUsage(nativeChunk, clientChunk)); + if (state.usage.totalTokens > prevTotalTokens || state.usage.cachedTokens > prevCachedTokens) { + logger.info(`${getTracePrefix(requestId)} <<< Stream Usage Captured: Provider: ${state.provider} | Model: ${state.model} | Prompt: ${state.usage.promptTokens} | Completion: ${state.usage.completionTokens} | Total: ${state.usage.totalTokens} | Cached: ${state.usage.cachedTokens}`); + } +} + +export async function finalizeRequest({ requestId, model, provider, fromProvider, isStream }) { + if (!requestId) { + logger.warn(`${getTracePrefix(null)} Skip finalize: missing requestId`); + return false; + } + + const state = getPendingRequest(requestId, { model, provider, fromProvider, isStream }); + pendingRequests.delete(requestId); + + if (!state.hasResponse) { + logger.warn(`${getTracePrefix(requestId)} Skip finalize: no response captured. Provider: ${state.provider} | Model: ${state.model}`); + return false; + } + + const timestamp = new Date().toISOString(); + const normalizedProvider = state.provider || provider || 'unknown'; + const normalizedModel = state.model || model || 'unknown'; + const usage = { + promptTokens: state.usage.promptTokens, + completionTokens: state.usage.completionTokens, + totalTokens: state.usage.totalTokens || (state.usage.promptTokens + state.usage.completionTokens), + cachedTokens: state.usage.cachedTokens + }; + + applyUsage(statsStore.summary, usage, timestamp); + applyUsage(ensureProviderStore(normalizedProvider).summary, usage, timestamp); + applyUsage(ensureModelStore(normalizedProvider, normalizedModel), usage, timestamp); + logger.info(`${getTracePrefix(requestId)} >>> Request Finalized: Provider: ${normalizedProvider} | Model: ${normalizedModel} | Prompt: ${usage.promptTokens} | Completion: ${usage.completionTokens} | Total: ${usage.totalTokens} | Cached: ${usage.cachedTokens} | Stream: ${Boolean(state.isStream)}`); + markDirty(); + await persistIfDirty(); + return true; +} + +export async function getStats() { + ensureLoaded(); + return JSON.parse(JSON.stringify(statsStore)); +} + +export async function resetStats() { + ensureLoaded(); + statsStore = createDefaultStore(); + pendingRequests.clear(); + markDirty(); + await persistIfDirty(); + logger.warn('[Model Usage Stats] Stats store reset'); + return getStats(); +} diff --git a/src/utils/common.js b/src/utils/common.js index e605d3f..b65929d 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -289,6 +289,10 @@ export async function handleUnifiedResponse(res, responsePayload, isStream, stat } } +function getPluginHookRequestId(config) { + return config?._monitorRequestId || config?._pluginRequestId || null; +} + export async function handleStreamRequest(res, service, model, requestBody, fromProvider, toProvider, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME, providerPoolManager, pooluuid, customName, retryContext = null) { let fullResponseText = ''; let fullResponseJson = ''; @@ -363,7 +367,8 @@ export async function handleStreamRequest(res, service, model, requestBody, from : nativeChunk; // 监控钩子:流式响应分块 - if (CONFIG?._monitorRequestId) { + const hookRequestId = getPluginHookRequestId(CONFIG); + if (hookRequestId) { try { const pluginManager = getPluginManager(); await pluginManager.executeHook('onStreamChunk', { @@ -372,7 +377,7 @@ export async function handleStreamRequest(res, service, model, requestBody, from fromProvider, toProvider, model, - requestId: CONFIG._monitorRequestId + requestId: hookRequestId }); } catch (e) {} } @@ -672,7 +677,8 @@ export async function handleUnaryRequest(res, service, model, requestBody, fromP } // 监控钩子:非流式响应 - if (CONFIG?._monitorRequestId) { + const hookRequestId = getPluginHookRequestId(CONFIG); + if (hookRequestId) { try { const pluginManager = getPluginManager(); await pluginManager.executeHook('onUnaryResponse', { @@ -681,7 +687,7 @@ export async function handleUnaryRequest(res, service, model, requestBody, fromP fromProvider, toProvider, model, - requestId: CONFIG._monitorRequestId + requestId: hookRequestId }); } catch (e) {} } diff --git a/static/model-usage-stats.html b/static/model-usage-stats.html new file mode 100644 index 0000000..28b64a7 --- /dev/null +++ b/static/model-usage-stats.html @@ -0,0 +1,129 @@ + + + + + + + 模型用量统计 + + + + + + +
+
+
+ 统计面板 +

模型用量统计面板

+
通过 /api/model-usage-stats 查看系统已累计的请求数、Prompt Tokens、Completion Tokens 和 Provider / Model 维度分布。
+
+ 支持后台 Token 与 API Key + 基于插件持久化统计 + 支持搜索、排序、刷新与重置 +
+
+ +
+
+
总请求数
0
累计成功落库的模型调用次数
+
Prompt Tokens
0
输入 token 的累计值
+
Completion Tokens
0
输出 token 的累计值
+
总 Tokens
0
等待数据
+
+
+

Provider 分布

+

Top Models

+
+
+
+

Provider 视图

+
+ + +
+
+
+
+
+
+

模型明细

+
+ + +
+
+
+ + + +
ProviderModel请求数PromptCompletionTotal最近使用
+
+
+

暂无统计数据

先发起几次模型请求,再回来查看这里的可视化结果。

+ +
+ + + + diff --git a/static/potluck-user.html b/static/potluck-user.html index a2aa147..8df07b3 100644 --- a/static/potluck-user.html +++ b/static/potluck-user.html @@ -389,6 +389,10 @@
+
+
最后使用
+
从未
+
每日用量
@@ -406,9 +410,13 @@
累计调用
0
-
-
最后使用
-
从未
+
+
今日 Tokens
+
0
+
+
+
累计 Tokens
+
0
@@ -466,6 +474,9 @@ const API_BASE = '/api/potluckuser'; let currentApiKey = ''; let isLoggedIn = false; + const formatNumber = (num) => new Intl.NumberFormat('zh-CN').format(Number(num || 0)); + const usageCount = (entry) => typeof entry === 'number' ? entry : Number(entry?.requestCount || 0); + const usageTokens = (entry) => typeof entry === 'number' ? 0 : Number(entry?.totalTokens || 0); // 初始化主题 function setupTheme() { @@ -564,6 +575,10 @@ // 累计调用 document.getElementById('statTotal').textContent = data.total || 0; + + // Token 用量 + document.getElementById('statTodayTokens').textContent = formatNumber(data.usage?.totalTokens || 0); + document.getElementById('statTotalTokens').textContent = formatNumber(data.tokens?.total || 0); // 最后使用时间 if (data.lastUsedAt) { @@ -594,18 +609,20 @@ const aggregatedProviders = {}; const aggregatedModels = {}; let totalCalls = 0; + let totalTokens = 0; // 汇总最近 7 天的数据 Object.values(usageHistory).forEach(day => { if (day.providers) { - Object.entries(day.providers).forEach(([p, count]) => { - aggregatedProviders[p] = (aggregatedProviders[p] || 0) + count; - totalCalls += count; + Object.entries(day.providers).forEach(([p, usage]) => { + aggregatedProviders[p] = (aggregatedProviders[p] || 0) + usageCount(usage); + totalCalls += usageCount(usage); + totalTokens += usageTokens(usage); }); } if (day.models) { - Object.entries(day.models).forEach(([m, count]) => { - aggregatedModels[m] = (aggregatedModels[m] || 0) + count; + Object.entries(day.models).forEach(([m, usage]) => { + aggregatedModels[m] = (aggregatedModels[m] || 0) + usageCount(usage); }); } }); @@ -617,11 +634,11 @@ // 渲染提供商分布 renderDistribution('providerDistribution', aggregatedProviders, totalCalls); - document.getElementById('providerTotalCount').textContent = `${totalCalls} 次`; + document.getElementById('providerTotalCount').textContent = `${formatNumber(totalCalls)} 次 / ${formatNumber(totalTokens)} Tokens`; // 渲染模型分布 renderDistribution('modelDistribution', aggregatedModels, totalCalls); - document.getElementById('modelTotalCount').textContent = `${totalCalls} 次`; + document.getElementById('modelTotalCount').textContent = `${formatNumber(totalCalls)} 次`; } function renderDistribution(elementId, data, total) { @@ -637,7 +654,7 @@
${escapeHtml(name)} - ${count} 次 (${percent}%) + ${formatNumber(count)} 次 (${percent}%)
@@ -653,7 +670,7 @@
其他 - ${otherCount} 次 (${otherPercent}%) + ${formatNumber(otherCount)} 次 (${otherPercent}%)
diff --git a/static/potluck.html b/static/potluck.html index 62096bb..dbe4ec4 100644 --- a/static/potluck.html +++ b/static/potluck.html @@ -612,6 +612,14 @@
累计调用
0
+
+
今日总 Tokens
+
0
+
+
+
累计 Tokens
+
0
+
@@ -786,6 +794,9 @@ const API_BASE = '/api/potluck'; function getToken() { return localStorage.getItem('authToken'); } + function formatNumber(num) { return new Intl.NumberFormat('zh-CN').format(Number(num || 0)); } + function usageCount(entry) { return typeof entry === 'number' ? entry : Number(entry?.requestCount || 0); } + function usageTokens(entry) { return typeof entry === 'number' ? 0 : Number(entry?.totalTokens || 0); } async function apiRequest(url, options = {}) { const token = getToken(); @@ -805,6 +816,8 @@ document.getElementById('enabledKeys').textContent = stats.enabledKeys; document.getElementById('todayUsage').textContent = stats.todayTotalUsage; document.getElementById('totalUsage').textContent = stats.totalUsage; + document.getElementById('todayTokens').textContent = formatNumber(stats.todayTotalTokens); + document.getElementById('totalTokens').textContent = formatNumber(stats.totalTokens); // 渲染使用历史分布 renderUsageHistory(stats.usageHistory); @@ -827,18 +840,20 @@ const aggregatedProviders = {}; const aggregatedModels = {}; let totalCalls = 0; + let totalTokens = 0; // 汇总最近 7 天的数据 Object.values(usageHistory).forEach(day => { if (day.providers) { - Object.entries(day.providers).forEach(([p, count]) => { - aggregatedProviders[p] = (aggregatedProviders[p] || 0) + count; - totalCalls += count; + Object.entries(day.providers).forEach(([p, usage]) => { + aggregatedProviders[p] = (aggregatedProviders[p] || 0) + usageCount(usage); + totalCalls += usageCount(usage); + totalTokens += usageTokens(usage); }); } if (day.models) { - Object.entries(day.models).forEach(([m, count]) => { - aggregatedModels[m] = (aggregatedModels[m] || 0) + count; + Object.entries(day.models).forEach(([m, usage]) => { + aggregatedModels[m] = (aggregatedModels[m] || 0) + usageCount(usage); }); } }); @@ -850,11 +865,11 @@ // 渲染提供商分布 renderDistribution('providerDistribution', aggregatedProviders, totalCalls); - document.getElementById('providerTotalCount').textContent = `${totalCalls} 次`; + document.getElementById('providerTotalCount').textContent = `${formatNumber(totalCalls)} 次 / ${formatNumber(totalTokens)} Tokens`; // 渲染模型分布 (模型总数与提供商总数一致) renderDistribution('modelDistribution', aggregatedModels, totalCalls); - document.getElementById('modelTotalCount').textContent = `${totalCalls} 次`; + document.getElementById('modelTotalCount').textContent = `${formatNumber(totalCalls)} 次`; } function renderDistribution(elementId, data, total) { @@ -870,7 +885,7 @@
${escapeHtml(name)} - ${count} 次 (${percent}%) + ${formatNumber(count)} 次 (${percent}%)
@@ -886,7 +901,7 @@
其他 (${sorted.length - 6} 个) - ${otherCount} 次 (${otherPercent}%) + ${formatNumber(otherCount)} 次 (${otherPercent}%)
@@ -985,8 +1000,8 @@ if (key.usageHistory) { Object.values(key.usageHistory).forEach(day => { if (day.providers) { - Object.entries(day.providers).forEach(([p, count]) => { - providers[p] = (providers[p] || 0) + count; + Object.entries(day.providers).forEach(([p, usage]) => { + providers[p] = (providers[p] || 0) + usageCount(usage); }); } }); @@ -996,7 +1011,7 @@ .slice(0, 3); const providerBadges = topProviders.map(([name, count]) => - `${escapeHtml(name)}: ${count}` + `${escapeHtml(name)}: ${formatNumber(count)}` ).join(''); return `
@@ -1010,11 +1025,13 @@
今日/限额
${key.todayUsage}/${key.dailyLimit}
+
${formatNumber(key.todayTotalTokens || 0)} Tokens
累计
${key.totalUsage}
+
${formatNumber(key.totalTokens || 0)} Tokens
最后调用