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:
parent
1754b6ce4e
commit
82a6ec2f43
16 changed files with 1081 additions and 63 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
2.13.2.1
|
||||
2.13.3
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@
|
|||
"default-auth": {
|
||||
"enabled": true,
|
||||
"description": "默认 API Key 认证插件(内置)"
|
||||
},
|
||||
"model-usage-stats": {
|
||||
"enabled": false,
|
||||
"description": "模型用量统计插件"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 || {},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
74
src/plugins/model-usage-stats/api-routes.js
Normal file
74
src/plugins/model-usage-stats/api-routes.js
Normal 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;
|
||||
}
|
||||
92
src/plugins/model-usage-stats/index.js
Normal file
92
src/plugins/model-usage-stats/index.js
Normal 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
|
||||
};
|
||||
401
src/plugins/model-usage-stats/stats-manager.js
Normal file
401
src/plugins/model-usage-stats/stats-manager.js
Normal 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();
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
|
|||
129
static/model-usage-stats.html
Normal file
129
static/model-usage-stats.html
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue