feat(plugin): 新增模型用量统计插件并增强 API Potluck 的 token 统计功能

- 新增 `model-usage-stats` 插件,提供模型级别的 token 用量统计和 API 接口
- 增强 API Potluck 插件,记录并展示 prompt、completion 和 total tokens 用量
- 更新插件管理器以支持禁用插件的路由拦截和静态文件访问控制
- 在前端页面中展示 token 用量统计数据
- 升级版本号至 2.13.3
This commit is contained in:
hex2077 2026-04-09 16:30:02 +08:00
parent 1754b6ce4e
commit 82a6ec2f43
16 changed files with 1081 additions and 63 deletions

3
.gitignore vendored
View file

@ -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

View file

@ -1 +1 @@
2.13.2.1
2.13.3

View file

@ -7,6 +7,10 @@
"default-auth": {
"enabled": true,
"description": "默认 API Key 认证插件(内置)"
},
"model-usage-stats": {
"enabled": false,
"description": "模型用量统计插件"
}
}
}
}

View file

@ -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<boolean>} - 是否已处理
*/
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 };
export { PluginManager, pluginManager };

View file

@ -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);

View file

@ -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;
export default aiMonitorPlugin;

View file

@ -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 || {},

View file

@ -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);
}
}
},

View file

@ -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
};
}

View file

@ -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;
}

View file

@ -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: '模型用量统计插件<br>接口:<code>/api/model-usage-stats</code><br>页面:<a href="model-usage-stats.html" target="_blank">model-usage-stats.html</a>',
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
};

View file

@ -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();
}

View file

@ -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) {}
}

File diff suppressed because one or more lines are too long

View file

@ -389,6 +389,10 @@
<!-- 统计卡片 -->
<div class="stats-grid">
<div class="stat-card orange">
<div class="label">最后使用</div>
<div class="value" id="statLastUsed">从未</div>
</div>
<div class="stat-card purple">
<div class="label">每日用量</div>
<div class="value">
@ -406,9 +410,13 @@
<div class="label">累计调用</div>
<div class="value" id="statTotal">0</div>
</div>
<div class="stat-card orange">
<div class="label">最后使用</div>
<div class="value" id="statLastUsed">从未</div>
<div class="stat-card green">
<div class="label">今日 Tokens</div>
<div class="value" id="statTodayTokens">0</div>
</div>
<div class="stat-card purple">
<div class="label">累计 Tokens</div>
<div class="value" id="statTotalTokens">0</div>
</div>
</div>
@ -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 @@
<div class="distribution-item">
<div class="dist-info">
<span class="dist-name">${escapeHtml(name)}</span>
<span class="dist-count">${count} 次 (${percent}%)</span>
<span class="dist-count">${formatNumber(count)} 次 (${percent}%)</span>
</div>
<div class="dist-bar">
<div class="dist-fill" style="width: ${percent}%"></div>
@ -653,7 +670,7 @@
<div class="distribution-item">
<div class="dist-info">
<span class="dist-name" style="font-style: italic;">其他</span>
<span class="dist-count">${otherCount} 次 (${otherPercent}%)</span>
<span class="dist-count">${formatNumber(otherCount)} 次 (${otherPercent}%)</span>
</div>
<div class="dist-bar">
<div class="dist-fill" style="width: ${otherPercent}%; background: #94a3b8;"></div>

View file

@ -612,6 +612,14 @@
<div class="label">累计调用</div>
<div class="value" id="totalUsage">0</div>
</div>
<div class="stat-card green">
<div class="label">今日总 Tokens</div>
<div class="value" id="todayTokens">0</div>
</div>
<div class="stat-card pink">
<div class="label">累计 Tokens</div>
<div class="value" id="totalTokens">0</div>
</div>
</div>
<!-- 使用分布统计区域 -->
@ -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 @@
<div class="distribution-item">
<div class="dist-info">
<span class="dist-name">${escapeHtml(name)}</span>
<span class="dist-count">${count} 次 (${percent}%)</span>
<span class="dist-count">${formatNumber(count)} 次 (${percent}%)</span>
</div>
<div class="dist-bar">
<div class="dist-fill" style="width: ${percent}%"></div>
@ -886,7 +901,7 @@
<div class="distribution-item">
<div class="dist-info">
<span class="dist-name" style="font-style: italic;">其他 (${sorted.length - 6} 个)</span>
<span class="dist-count">${otherCount} 次 (${otherPercent}%)</span>
<span class="dist-count">${formatNumber(otherCount)} 次 (${otherPercent}%)</span>
</div>
<div class="dist-bar">
<div class="dist-fill" style="width: ${otherPercent}%; background: #94a3b8;"></div>
@ -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]) =>
`<span class="dist-badge">${escapeHtml(name)}: <b>${count}</b></span>`
`<span class="dist-badge">${escapeHtml(name)}: <b>${formatNumber(count)}</b></span>`
).join('');
return `<div class="key-card ${key.enabled ? '' : 'disabled'}">
@ -1010,11 +1025,13 @@
<div class="key-stat">
<div class="label">今日/限额</div>
<div class="value ${valueClass}">${key.todayUsage}/${key.dailyLimit}</div>
<div class="value muted">${formatNumber(key.todayTotalTokens || 0)} Tokens</div>
<div class="progress-bar"><div class="fill ${progressClass}" style="width:${Math.min(usagePercent, 100)}%"></div></div>
</div>
<div class="key-stat">
<div class="label">累计</div>
<div class="value">${key.totalUsage}</div>
<div class="value muted">${formatNumber(key.totalTokens || 0)} Tokens</div>
</div>
<div class="key-stat">
<div class="label">最后调用</div>