feat(usage-stats): 添加重置 Token 统计功能

- 在模型用量统计插件中新增 `resetTokenStats` 方法,可重置所有 token 计数
- 在 API Potluck 插件中新增 `resetKeyTokenStats` 和 `resetAllTokenStats` 方法
- 为两个插件添加对应的 API 路由 (`POST /reset-tokens`)
- 在前端页面添加重置 Token 统计按钮
- 更新版本号至 2.13.7
This commit is contained in:
hex2077 2026-04-11 20:10:49 +08:00
parent 0654d9330b
commit d511ba1ba7
9 changed files with 196 additions and 8 deletions

View file

@ -1 +1 @@
2.13.6
2.13.7

View file

@ -10,6 +10,7 @@ import {
deleteKey,
updateKeyLimit,
resetKeyUsage,
resetKeyTokenStats,
toggleKey,
updateKeyName,
regenerateKey,
@ -17,7 +18,8 @@ import {
validateKey,
KEY_PREFIX,
applyDailyLimitToAllKeys,
getAllKeyIds
getAllKeyIds,
resetAllTokenStats
} from './key-manager.js';
import logger from '../../utils/logger.js';
@ -131,6 +133,18 @@ export async function handlePotluckApiRoutes(method, path, req, res) {
return true;
}
// POST /api/potluck/stats/reset-tokens - 重置全部 Key 的 Token 统计
if (method === 'POST' && path === '/api/potluck/stats/reset-tokens') {
const result = await resetAllTokenStats();
const stats = await getStats();
sendJson(res, 200, {
success: true,
message: `已重置 ${result.updated}/${result.total} 个 Key 的 Token 统计`,
data: stats
});
return true;
}
// GET /api/potluck/keys - 获取所有 Key 列表
if (method === 'GET' && path === '/api/potluck/keys') {
const keys = await listKeys();
@ -243,6 +257,21 @@ export async function handlePotluckApiRoutes(method, path, req, res) {
return true;
}
// POST /api/potluck/keys/:keyId/reset-tokens - 重置 Token 统计
if (method === 'POST' && subPath === '/reset-tokens') {
const keyData = await resetKeyTokenStats(keyId);
if (!keyData) {
sendJson(res, 404, { success: false, error: { message: '未找到 Key' } });
return true;
}
sendJson(res, 200, {
success: true,
message: 'Token 统计重置成功',
data: keyData
});
return true;
}
// POST /api/potluck/keys/:keyId/toggle - 切换启用/禁用状态
if (method === 'POST' && subPath === '/toggle') {
const keyData = await toggleKey(keyId);

View file

@ -15,11 +15,13 @@ import {
deleteKey,
updateKeyLimit,
resetKeyUsage,
resetKeyTokenStats,
toggleKey,
updateKeyName,
validateKey,
incrementUsage,
getStats,
resetAllTokenStats,
KEY_PREFIX,
setConfigGetter
} from './key-manager.js';
@ -47,6 +49,13 @@ function normalizeUsageCandidate(candidate) {
}
const usage = candidate.usage || candidate.message?.usage || candidate.usageMetadata || candidate.response?.usage || null;
const reasoningTokens = toNumber(
candidate.completion_tokens_details?.reasoning_tokens ??
candidate.output_tokens_details?.reasoning_tokens ??
usage?.completion_tokens_details?.reasoning_tokens ??
usage?.output_tokens_details?.reasoning_tokens ??
usage?.thoughtsTokenCount
);
const promptTokens = toNumber(
candidate.prompt_tokens ??
usage?.prompt_tokens ??
@ -60,7 +69,7 @@ function normalizeUsageCandidate(candidate) {
usage?.output_tokens ??
usage?.candidatesTokenCount ??
usage?.outputTokenCount
);
) + reasoningTokens;
const totalTokens = toNumber(
candidate.total_tokens ??
usage?.total_tokens ??
@ -286,11 +295,13 @@ const apiPotluckPlugin = {
deleteKey,
updateKeyLimit,
resetKeyUsage,
resetKeyTokenStats,
toggleKey,
updateKeyName,
validateKey,
incrementUsage,
getStats,
resetAllTokenStats,
KEY_PREFIX,
extractPotluckKey,
isPotluckRequest
@ -307,11 +318,13 @@ export {
deleteKey,
updateKeyLimit,
resetKeyUsage,
resetKeyTokenStats,
toggleKey,
updateKeyName,
validateKey,
incrementUsage,
getStats,
resetAllTokenStats,
KEY_PREFIX,
extractPotluckKey,
isPotluckRequest

View file

@ -138,6 +138,31 @@ function addUsage(target, usage = {}) {
target.cachedTokens += toNumber(usage.cachedTokens);
}
function resetUsageBucketTokens(bucket) {
if (!bucket || typeof bucket !== 'object') return;
bucket.promptTokens = 0;
bucket.completionTokens = 0;
bucket.totalTokens = 0;
bucket.cachedTokens = 0;
}
function resetUsageHistoryTokens(usageHistory) {
if (!usageHistory || typeof usageHistory !== 'object') return;
for (const day of Object.values(usageHistory)) {
if (!day || typeof day !== 'object') continue;
resetUsageBucketTokens(day.summary);
for (const usage of Object.values(day.providers || {})) {
resetUsageBucketTokens(usage);
}
for (const usage of Object.values(day.models || {})) {
resetUsageBucketTokens(usage);
}
}
}
/**
* 初始化从文件加载数据到内存
*/
@ -369,6 +394,59 @@ export async function resetKeyUsage(keyId) {
return keyStore.keys[keyId];
}
/**
* 重置单个 Key Token 统计保留调用次数
*/
export async function resetKeyTokenStats(keyId) {
ensureLoaded();
const keyData = keyStore.keys[keyId];
if (!keyData) return null;
keyData.todayPromptTokens = 0;
keyData.todayCompletionTokens = 0;
keyData.todayTotalTokens = 0;
keyData.todayCachedTokens = 0;
keyData.totalPromptTokens = 0;
keyData.totalCompletionTokens = 0;
keyData.totalTokens = 0;
keyData.totalCachedTokens = 0;
resetUsageHistoryTokens(keyData.usageHistory);
markDirty();
await persistIfDirty();
logger.info(`[API Potluck] Reset token stats for key: ${keyId.substring(0, 12)}...`);
return keyData;
}
/**
* 重置所有 Key Token 统计保留调用次数
*/
export async function resetAllTokenStats() {
ensureLoaded();
let updated = 0;
for (const keyData of Object.values(keyStore.keys)) {
keyData.todayPromptTokens = 0;
keyData.todayCompletionTokens = 0;
keyData.todayTotalTokens = 0;
keyData.todayCachedTokens = 0;
keyData.totalPromptTokens = 0;
keyData.totalCompletionTokens = 0;
keyData.totalTokens = 0;
keyData.totalCachedTokens = 0;
resetUsageHistoryTokens(keyData.usageHistory);
updated++;
}
if (updated > 0) {
markDirty();
await persistIfDirty();
}
logger.info(`[API Potluck] Reset token stats for all keys: ${updated}`);
return { total: Object.keys(keyStore.keys).length, updated };
}
/**
* 切换 Key 的启用/禁用状态
*/

View file

@ -1,7 +1,7 @@
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';
import { getStats, resetStats, resetTokenStats } from './stats-manager.js';
function sendJson(res, statusCode, data) {
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
@ -59,6 +59,16 @@ export async function handleModelUsageStatsRoutes(method, path, req, res, config
});
return true;
}
if ((method === 'POST' || method === 'DELETE') && path === '/api/model-usage-stats/reset-tokens') {
const stats = await resetTokenStats();
sendJson(res, 200, {
success: true,
message: '模型 Token 统计已重置',
data: stats
});
return true;
}
} catch (error) {
logger.error('[Model Usage Stats] Route error:', error.message);
sendJson(res, 500, {

View file

@ -6,6 +6,7 @@ import {
recordStreamChunkUsage,
recordUnaryUsage,
resetStats,
resetTokenStats,
setConfigGetter
} from './stats-manager.js';
@ -81,12 +82,14 @@ const modelUsageStatsPlugin = {
exports: {
getStats,
resetStats
resetStats,
resetTokenStats
}
};
export default modelUsageStatsPlugin;
export {
getStats,
resetStats
resetStats,
resetTokenStats
};

View file

@ -220,6 +220,13 @@ function normalizeUsageCandidate(candidate) {
}
const usage = candidate.usage || candidate.message?.usage || candidate.usageMetadata || candidate.response?.usage || null;
const reasoningTokens = toNumber(
candidate.completion_tokens_details?.reasoning_tokens ??
candidate.output_tokens_details?.reasoning_tokens ??
usage?.completion_tokens_details?.reasoning_tokens ??
usage?.output_tokens_details?.reasoning_tokens ??
usage?.thoughtsTokenCount
);
const promptTokens = toNumber(
candidate.prompt_tokens ??
usage?.prompt_tokens ??
@ -233,7 +240,7 @@ function normalizeUsageCandidate(candidate) {
usage?.output_tokens ??
usage?.candidatesTokenCount ??
usage?.outputTokenCount
);
) + reasoningTokens;
const totalTokens = toNumber(
candidate.total_tokens ??
usage?.total_tokens ??
@ -324,6 +331,14 @@ function applyUsage(target, usage, timestamp) {
target.lastUsedAt = timestamp;
}
function resetUsageBlockTokens(block) {
if (!block || typeof block !== 'object') return;
block.promptTokens = 0;
block.completionTokens = 0;
block.totalTokens = 0;
block.cachedTokens = 0;
}
export function setConfigGetter(getter) {
configGetter = getter;
}
@ -399,3 +414,23 @@ export async function resetStats() {
logger.warn('[Model Usage Stats] Stats store reset');
return getStats();
}
export async function resetTokenStats() {
ensureLoaded();
resetUsageBlockTokens(statsStore.summary);
for (const providerStore of Object.values(statsStore.providers || {})) {
resetUsageBlockTokens(providerStore.summary);
for (const modelStore of Object.values(providerStore.models || {})) {
resetUsageBlockTokens(modelStore);
}
}
pendingRequests.clear();
markDirty();
await persistIfDirty();
logger.warn('[Model Usage Stats] Token stats reset');
return getStats();
}

View file

@ -70,6 +70,7 @@
<h2 class="section-title" style="margin:0"><i class="fas fa-layer-group"></i>Provider 视图</h2>
<div class="row">
<button class="btn btn-secondary" id="refreshBtn" type="button"><i class="fas fa-rotate-right"></i><span>刷新</span></button>
<button class="btn btn-secondary" id="resetTokensBtn" type="button"><i class="fas fa-eraser"></i><span>重置 Token</span></button>
<button class="btn btn-danger" id="resetBtn" type="button"><i class="fas fa-trash-can"></i><span>重置统计</span></button>
</div>
</div>
@ -123,7 +124,8 @@
function render(data){rows=flatten(data);el('emptyState').style.display=rows.length?'none':'block';renderSummary(data);const providers=Object.entries(data.providers||{}).map(([name,p])=>({name,totalTokens:Number(p.summary?.totalTokens||0)})).sort((a,b)=>b.totalTokens-a.totalTokens).slice(0,8);const topModels=rows.slice().sort((a,b)=>Number(b.totalTokens||0)-Number(a.totalTokens||0)).slice(0,8);bars('providerBars',providers,i=>i.totalTokens,i=>i.name);bars('topModelBars',topModels,i=>Number(i.totalTokens||0),i=>`${i.provider} / ${i.model}`);renderProviders(data);renderTable()}
async function loadData(){try{saveCredential();status('正在加载统计数据...');const payload=await request(API_BASE);render(payload.data||payload);badge('已连接',true);status(`已加载 ${fmt(rows.length)} 条模型统计。`,'success')}catch(error){console.error(error);badge('连接失败',false);status(error.message,'error')}}
async function resetData(){if(!confirm('确认重置全部模型统计吗?此操作会清空已落库的累计数据。'))return;try{status('正在重置统计数据...');const payload=await request(`${API_BASE}/reset`,{method:'POST'});render(payload.data||payload);status('统计数据已重置。','success')}catch(error){console.error(error);status(error.message,'error')}}
el('connectBtn').addEventListener('click',loadData);el('refreshBtn').addEventListener('click',loadData);el('resetBtn').addEventListener('click',resetData);el('clearBtn').addEventListener('click',clearCredential);el('searchInput').addEventListener('input',renderTable);el('sortSelect').addEventListener('change',renderTable);el('credentialValue').addEventListener('keydown',e=>{if(e.key==='Enter')loadData()});
async function resetTokenData(){if(!confirm('确认重置模型 Token 统计吗?这会清空 Prompt / Completion / Cached / Total Tokens但保留请求次数与最近使用时间。'))return;try{status('正在重置 Token 统计...');const payload=await request(`${API_BASE}/reset-tokens`,{method:'POST'});render(payload.data||payload);status('模型 Token 统计已重置。','success')}catch(error){console.error(error);status(error.message,'error')}}
el('connectBtn').addEventListener('click',loadData);el('refreshBtn').addEventListener('click',loadData);el('resetBtn').addEventListener('click',resetData);el('resetTokensBtn').addEventListener('click',resetTokenData);el('clearBtn').addEventListener('click',clearCredential);el('searchInput').addEventListener('input',renderTable);el('sortSelect').addEventListener('change',renderTable);el('credentialValue').addEventListener('keydown',e=>{if(e.key==='Enter')loadData()});
restoreCredential();if(el('credentialValue').value.trim())loadData();
</script>
</body>

View file

@ -678,6 +678,9 @@
</select>
</div>
<div class="btn-limit-wrapper">
<button class="btn btn-secondary btn-sm" onclick="resetAllTokenStats()">
<i class="fas fa-eraser"></i> <span class="btn-limit-text">重置全部 Token</span>
</button>
<button class="btn btn-secondary btn-sm" onclick="showApplyLimitModal()">
<i class="fas fa-cog"></i> <span class="btn-limit-text">批量应用限额</span>
</button>
@ -1072,6 +1075,7 @@
</div>
<div class="key-actions">
<button class="btn btn-secondary btn-sm" onclick="resetUsage('${key.id}')" title="重置今日用量"><i class="fas fa-redo"></i> <span class="btn-text">重置</span></button>
<button class="btn btn-secondary btn-sm" onclick="resetTokenStats('${key.id}')" title="重置 Token 统计"><i class="fas fa-eraser"></i> <span class="btn-text">Token</span></button>
<button class="btn btn-secondary btn-sm" onclick="openEditLimit('${key.id}', ${key.dailyLimit})" title="修改限额"><i class="fas fa-sliders-h"></i> <span class="btn-text">限额</span></button>
<button class="btn btn-secondary btn-sm" onclick="toggleKey('${key.id}')" title="${key.enabled ? '禁用' : '启用'}">${key.enabled ? '<i class="fas fa-toggle-on"></i>' : '<i class="fas fa-toggle-off"></i>'} <span class="btn-text">${key.enabled ? '禁用' : '启用'}</span></button>
<button class="btn btn-danger btn-sm" onclick="deleteKey('${key.id}')" title="删除"><i class="fas fa-trash"></i> <span class="btn-text">删除</span></button>
@ -1138,6 +1142,20 @@
else { showToast(result?.error?.message || '操作失败', 'error'); }
}
async function resetTokenStats(keyId) {
if (!confirm('确定要重置该 Key 的 Token 统计吗?这会清空该 Key 的今日/累计 Token 与历史 Token 统计,但保留调用次数。')) return;
const result = await apiRequest(`${API_BASE}/keys/${encodeURIComponent(keyId)}/reset-tokens`, { method: 'POST' });
if (result && result.success) { showToast('Token 统计已重置', 'success'); loadData(); }
else { showToast(result?.error?.message || '操作失败', 'error'); }
}
async function resetAllTokenStats() {
if (!confirm('确定要重置全部 Key 的 Token 统计吗?这会清空所有 Key 的今日/累计 Token 与历史 Token 统计,但保留调用次数。')) return;
const result = await apiRequest(`${API_BASE}/stats/reset-tokens`, { method: 'POST' });
if (result && result.success) { showToast('全部 Token 统计已重置', 'success'); loadData(); }
else { showToast(result?.error?.message || '操作失败', 'error'); }
}
function openEditLimit(keyId, currentLimit) {
document.getElementById('editKeyId').value = keyId;
document.getElementById('newLimit').value = currentLimit;