feat(usage-stats): 添加重置 Token 统计功能
- 在模型用量统计插件中新增 `resetTokenStats` 方法,可重置所有 token 计数 - 在 API Potluck 插件中新增 `resetKeyTokenStats` 和 `resetAllTokenStats` 方法 - 为两个插件添加对应的 API 路由 (`POST /reset-tokens`) - 在前端页面添加重置 Token 统计按钮 - 更新版本号至 2.13.7
This commit is contained in:
parent
0654d9330b
commit
d511ba1ba7
9 changed files with 196 additions and 8 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
2.13.6
|
||||
2.13.7
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 的启用/禁用状态
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue