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, prompt_tokens: claudeResponse.usage?.input_tokens || 0,
completion_tokens: claudeResponse.usage?.output_tokens || 0, completion_tokens: claudeResponse.usage?.output_tokens || 0,
total_tokens: (claudeResponse.usage?.input_tokens || 0) + (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, resetDate: keyData.lastResetDate,
promptTokens: keyData.todayPromptTokens || 0, promptTokens: keyData.todayPromptTokens || 0,
completionTokens: keyData.todayCompletionTokens || 0, completionTokens: keyData.todayCompletionTokens || 0,
totalTokens: keyData.todayTotalTokens || 0 totalTokens: keyData.todayTotalTokens || 0,
cachedTokens: keyData.todayCachedTokens || 0
}, },
total: keyData.totalUsage, total: keyData.totalUsage,
tokens: { tokens: {
prompt: keyData.totalPromptTokens || 0, prompt: keyData.totalPromptTokens || 0,
completion: keyData.totalCompletionTokens || 0, completion: keyData.totalCompletionTokens || 0,
total: keyData.totalTokens || 0 total: keyData.totalTokens || 0,
cached: keyData.totalCachedTokens || 0
}, },
lastUsedAt: keyData.lastUsedAt, lastUsedAt: keyData.lastUsedAt,
createdAt: keyData.createdAt, createdAt: keyData.createdAt,

View file

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

View file

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

View file

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

View file

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