feat(api-potluck): 添加缓存 tokens 统计支持

- 在 Claude 转换器中增加缓存 tokens 字段提取
- 扩展 API 路由返回缓存 tokens 数据
- 在 key-manager 中新增缓存 tokens 统计逻辑
- 更新前端页面展示缓存 tokens 统计信息
- 统一所有相关模块的缓存 tokens 处理逻辑
This commit is contained in:
hex2077 2026-04-11 19:31:21 +08:00
parent 51c0af2f15
commit 1a56e422c4
8 changed files with 73 additions and 22 deletions

View file

@ -315,6 +315,10 @@ export class ClaudeConverter extends BaseConverter {
prompt_tokens: claudeResponse.usage?.input_tokens || 0,
completion_tokens: claudeResponse.usage?.output_tokens || 0,
total_tokens: (claudeResponse.usage?.input_tokens || 0) + (claudeResponse.usage?.output_tokens || 0),
cached_tokens: claudeResponse.usage?.cache_read_input_tokens || 0,
prompt_tokens_details: {
cached_tokens: claudeResponse.usage?.cache_read_input_tokens || 0
}
},
};
}

View file

@ -423,13 +423,15 @@ export async function handlePotluckUserApiRoutes(method, path, req, res) {
resetDate: keyData.lastResetDate,
promptTokens: keyData.todayPromptTokens || 0,
completionTokens: keyData.todayCompletionTokens || 0,
totalTokens: keyData.todayTotalTokens || 0
totalTokens: keyData.todayTotalTokens || 0,
cachedTokens: keyData.todayCachedTokens || 0
},
total: keyData.totalUsage,
tokens: {
prompt: keyData.totalPromptTokens || 0,
completion: keyData.totalCompletionTokens || 0,
total: keyData.totalTokens || 0
total: keyData.totalTokens || 0,
cached: keyData.totalCachedTokens || 0
},
lastUsedAt: keyData.lastUsedAt,
createdAt: keyData.createdAt,

View file

@ -65,12 +65,20 @@ function normalizeUsageCandidate(candidate) {
candidate.total_tokens ??
usage?.total_tokens ??
usage?.totalTokenCount
) || promptTokens + completionTokens;
);
const cachedTokens = toNumber(
candidate.cached_tokens ??
usage?.cached_tokens ??
usage?.cache_read_input_tokens ??
usage?.cachedContentTokenCount
);
return {
promptTokens,
completionTokens,
totalTokens
totalTokens: totalTokens || (promptTokens + completionTokens),
cachedTokens
};
}
@ -79,7 +87,8 @@ function mergeUsage(baseUsage, nextUsage) {
return {
promptTokens: Math.max(baseUsage.promptTokens, nextUsage.promptTokens),
completionTokens: Math.max(baseUsage.completionTokens, nextUsage.completionTokens),
totalTokens: Math.max(baseUsage.totalTokens, nextUsage.totalTokens)
totalTokens: Math.max(baseUsage.totalTokens, nextUsage.totalTokens),
cachedTokens: Math.max(baseUsage.cachedTokens || 0, nextUsage.cachedTokens || 0)
};
}
@ -87,7 +96,8 @@ function extractUsage(...candidates) {
return candidates.reduce((usage, candidate) => mergeUsage(usage, normalizeUsageCandidate(candidate)), {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0
totalTokens: 0,
cachedTokens: 0
});
}

View file

@ -52,7 +52,8 @@ function createUsageBucket() {
requestCount: 0,
promptTokens: 0,
completionTokens: 0,
totalTokens: 0
totalTokens: 0,
cachedTokens: 0
};
}
@ -75,7 +76,8 @@ function normalizeUsageBucket(bucket) {
requestCount: toNumber(bucket?.requestCount),
promptTokens: toNumber(bucket?.promptTokens),
completionTokens: toNumber(bucket?.completionTokens),
totalTokens: toNumber(bucket?.totalTokens)
totalTokens: toNumber(bucket?.totalTokens),
cachedTokens: toNumber(bucket?.cachedTokens)
};
}
@ -105,9 +107,11 @@ function normalizeKeyData(keyData = {}) {
todayPromptTokens: toNumber(keyData.todayPromptTokens),
todayCompletionTokens: toNumber(keyData.todayCompletionTokens),
todayTotalTokens: toNumber(keyData.todayTotalTokens),
todayCachedTokens: toNumber(keyData.todayCachedTokens),
totalPromptTokens: toNumber(keyData.totalPromptTokens),
totalCompletionTokens: toNumber(keyData.totalCompletionTokens),
totalTokens: toNumber(keyData.totalTokens),
totalCachedTokens: toNumber(keyData.totalCachedTokens),
usageHistory: {}
};
@ -131,6 +135,7 @@ function addUsage(target, usage = {}) {
target.promptTokens += toNumber(usage.promptTokens);
target.completionTokens += toNumber(usage.completionTokens);
target.totalTokens += toNumber(usage.totalTokens);
target.cachedTokens += toNumber(usage.cachedTokens);
}
/**
@ -172,7 +177,7 @@ function syncWriteToFile() {
try {
const dir = path.dirname(KEYS_STORE_FILE);
if (!existsSync(dir)) {
require('fs').mkdirSync(dir, { recursive: true });
mkdirSync(dir, { recursive: true });
}
writeFileSync(KEYS_STORE_FILE, JSON.stringify(keyStore, null, 2), 'utf8');
} catch (error) {
@ -248,6 +253,7 @@ function checkAndResetDailyCount(keyData) {
keyData.todayPromptTokens = 0;
keyData.todayCompletionTokens = 0;
keyData.todayTotalTokens = 0;
keyData.todayCachedTokens = 0;
keyData.lastResetDate = today;
}
return keyData;
@ -255,7 +261,6 @@ function checkAndResetDailyCount(keyData) {
/**
* 创建新的 API Key
* @param {string} name - Key 名称
* @param {number} [dailyLimit] - 每日限额不传则使用配置的默认值
*/
@ -278,9 +283,11 @@ export async function createKey(name = '', dailyLimit = null) {
todayPromptTokens: 0,
todayCompletionTokens: 0,
todayTotalTokens: 0,
todayCachedTokens: 0,
totalPromptTokens: 0,
totalCompletionTokens: 0,
totalTokens: 0,
totalCachedTokens: 0,
lastResetDate: today,
lastUsedAt: null,
enabled: true
@ -354,6 +361,7 @@ export async function resetKeyUsage(keyId) {
keyStore.keys[keyId].todayPromptTokens = 0;
keyStore.keys[keyId].todayCompletionTokens = 0;
keyStore.keys[keyId].todayTotalTokens = 0;
keyStore.keys[keyId].todayCachedTokens = 0;
keyStore.keys[keyId].lastResetDate = getTodayDateString();
if (!keyStore.keys[keyId].usageHistory) keyStore.keys[keyId].usageHistory = {};
keyStore.keys[keyId].usageHistory[getTodayDateString()] = normalizeUsageHistoryDay();
@ -448,7 +456,7 @@ 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 用量
* @param {{promptTokens?: number, completionTokens?: number, totalTokens?: number, cachedTokens?: number}} usage - token 用量
*/
export async function incrementUsage(apiKey, provider = 'unknown', model = 'unknown', usage = {}) {
ensureLoaded();
@ -469,9 +477,11 @@ export async function incrementUsage(apiKey, provider = 'unknown', model = 'unkn
keyData.todayPromptTokens += toNumber(usage.promptTokens);
keyData.todayCompletionTokens += toNumber(usage.completionTokens);
keyData.todayTotalTokens += toNumber(usage.totalTokens);
keyData.todayCachedTokens += toNumber(usage.cachedTokens);
keyData.totalPromptTokens += toNumber(usage.promptTokens);
keyData.totalCompletionTokens += toNumber(usage.completionTokens);
keyData.totalTokens += toNumber(usage.totalTokens);
keyData.totalCachedTokens += toNumber(usage.cachedTokens);
keyData.lastUsedAt = new Date().toISOString();
// 记录个人按天统计 (每个 Key 独立)
@ -514,8 +524,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;
let todayPromptTokens = 0, todayCompletionTokens = 0, todayTotalTokens = 0, todayCachedTokens = 0;
let totalPromptTokens = 0, totalCompletionTokens = 0, totalTokens = 0, totalCachedTokens = 0;
const aggregatedHistory = {};
for (const key of keys) {
@ -526,9 +536,11 @@ export async function getStats() {
todayPromptTokens += key.todayPromptTokens || 0;
todayCompletionTokens += key.todayCompletionTokens || 0;
todayTotalTokens += key.todayTotalTokens || 0;
todayCachedTokens += key.todayCachedTokens || 0;
totalPromptTokens += key.totalPromptTokens || 0;
totalCompletionTokens += key.totalCompletionTokens || 0;
totalTokens += key.totalTokens || 0;
totalCachedTokens += key.totalCachedTokens || 0;
// 汇总每个 Key 的历史数据
if (key.usageHistory) {
@ -566,9 +578,11 @@ export async function getStats() {
todayPromptTokens,
todayCompletionTokens,
todayTotalTokens,
todayCachedTokens,
totalPromptTokens,
totalCompletionTokens,
totalTokens,
totalCachedTokens,
usageHistory: aggregatedHistory
};
}
@ -576,7 +590,7 @@ export async function getStats() {
/**
* 批量更新所有 Key 的每日限额
* @param {number} newLimit - 的每日限额
* @param {number} newLimit - s的每日限额
* @returns {Promise<{total: number, updated: number}>}
*/
export async function applyDailyLimitToAllKeys(newLimit) {

View file

@ -254,7 +254,7 @@ function normalizeUsageCandidate(candidate) {
return {
promptTokens,
completionTokens,
totalTokens: totalTokens || promptTokens + completionTokens,
totalTokens: totalTokens || (promptTokens + completionTokens),
cachedTokens
};
}

File diff suppressed because one or more lines are too long

View file

@ -414,10 +414,18 @@
<div class="label">今日 Tokens</div>
<div class="value" id="statTodayTokens">0</div>
</div>
<div class="stat-card cyan">
<div class="label">今日缓存 Tokens</div>
<div class="value" id="statTodayCachedTokens">0</div>
</div>
<div class="stat-card purple">
<div class="label">累计 Tokens</div>
<div class="value" id="statTotalTokens">0</div>
</div>
<div class="stat-card cyan">
<div class="label">累计缓存 Tokens</div>
<div class="value" id="statTotalCachedTokens">0</div>
</div>
</div>
<!-- 我的使用分布统计区域 -->
@ -596,7 +604,9 @@
// Token 用量
document.getElementById('statTodayTokens').textContent = formatTokenCompact(data.usage?.totalTokens || 0);
document.getElementById('statTodayCachedTokens').textContent = formatTokenCompact(data.usage?.cachedTokens || 0);
document.getElementById('statTotalTokens').textContent = formatTokenCompact(data.tokens?.total || 0);
document.getElementById('statTotalCachedTokens').textContent = formatTokenCompact(data.tokens?.cached || 0);
// 最后使用时间
if (data.lastUsedAt) {

View file

@ -616,10 +616,18 @@
<div class="label">今日总 Tokens</div>
<div class="value" id="todayTokens">0</div>
</div>
<div class="stat-card cyan">
<div class="label">今日缓存 Tokens</div>
<div class="value" id="todayCachedTokens">0</div>
</div>
<div class="stat-card pink">
<div class="label">累计 Tokens</div>
<div class="value" id="totalTokens">0</div>
</div>
<div class="stat-card cyan">
<div class="label">累计缓存 Tokens</div>
<div class="value" id="totalCachedTokens">0</div>
</div>
</div>
<!-- 使用分布统计区域 -->
@ -835,7 +843,9 @@
document.getElementById('todayUsage').textContent = stats.todayTotalUsage;
document.getElementById('totalUsage').textContent = stats.totalUsage;
document.getElementById('todayTokens').textContent = formatTokenCompact(stats.todayTotalTokens);
document.getElementById('todayCachedTokens').textContent = formatTokenCompact(stats.todayCachedTokens || 0);
document.getElementById('totalTokens').textContent = formatTokenCompact(stats.totalTokens);
document.getElementById('totalCachedTokens').textContent = formatTokenCompact(stats.totalCachedTokens || 0);
// 渲染使用历史分布
renderUsageHistory(stats.usageHistory);
@ -1043,13 +1053,13 @@
<div class="key-stat">
<div class="label">今日/限额</div>
<div class="value ${valueClass}">${key.todayUsage}/${key.dailyLimit}</div>
<div class="value muted">${formatTokenCompact(key.todayTotalTokens || 0)} Tokens</div>
<div class="value muted">${formatTokenCompact(key.todayTotalTokens || 0)} Tokens ${key.todayCachedTokens ? `(含 ${formatTokenCompact(key.todayCachedTokens)} 缓存)` : ''}</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">${formatTokenCompact(key.totalTokens || 0)} Tokens</div>
<div class="value muted">${formatTokenCompact(key.totalTokens || 0)} Tokens ${key.totalCachedTokens ? `(含 ${formatTokenCompact(key.totalCachedTokens)} 缓存)` : ''}</div>
</div>
<div class="key-stat">
<div class="label">最后调用</div>