feat(usage): track cached tokens + correct input/output/cache cost (#2209)

Normalize every provider to one cache-inclusive convention via
canonicalizeUsage() before persist, and price cached + cache_creation as
subsets of prompt_tokens in calculateCostFromTokens() to stop
double-counting. usageRepo now delegates cost math to a single source.
Surface Cached tokens/cost across dashboard (overview, tokens, cost,
details). Merge Claude message_start cache with message_delta output so
cache counts survive. Compatible LLM nodes now allow multiple API-key
connections (key pool).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
hodtien
2026-07-03 15:07:37 +07:00
committed by decolua
parent 960f8a0379
commit 54e3245ace
17 changed files with 558 additions and 71 deletions

View File

@@ -51,10 +51,11 @@ function getLocalDateKey(timestamp) {
}
function addToCounter(target, key, values) {
if (!target[key]) target[key] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0 };
if (!target[key]) target[key] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0 };
target[key].requests += values.requests || 1;
target[key].promptTokens += values.promptTokens || 0;
target[key].completionTokens += values.completionTokens || 0;
target[key].cachedTokens += values.cachedTokens || 0;
target[key].cost += values.cost || 0;
if (values.meta) Object.assign(target[key], values.meta);
}
@@ -62,12 +63,14 @@ function addToCounter(target, key, values) {
function aggregateEntryToDay(day, entry) {
const promptTokens = entry.tokens?.prompt_tokens || entry.tokens?.input_tokens || 0;
const completionTokens = entry.tokens?.completion_tokens || entry.tokens?.output_tokens || 0;
const cachedTokens = entry.tokens?.cached_tokens || entry.tokens?.cache_read_input_tokens || 0;
const cost = entry.cost || 0;
const vals = { promptTokens, completionTokens, cost };
const vals = { promptTokens, completionTokens, cachedTokens, cost };
day.requests = (day.requests || 0) + 1;
day.promptTokens = (day.promptTokens || 0) + promptTokens;
day.completionTokens = (day.completionTokens || 0) + completionTokens;
day.cachedTokens = (day.cachedTokens || 0) + cachedTokens;
day.cost = (day.cost || 0) + cost;
day.byProvider ||= {};
@@ -135,33 +138,11 @@ async function calculateCost(provider, model, tokens) {
const pricing = await getPricingForModel(provider, model);
if (!pricing) return 0;
let cost = 0;
const inputTokens = tokens.prompt_tokens || tokens.input_tokens || 0;
const cachedTokens = tokens.cached_tokens || tokens.cache_read_input_tokens || 0;
const nonCachedInput = Math.max(0, inputTokens - cachedTokens);
cost += nonCachedInput * (pricing.input / 1000000);
if (cachedTokens > 0) {
const cachedRate = pricing.cached || pricing.input;
cost += cachedTokens * (cachedRate / 1000000);
}
const outputTokens = tokens.completion_tokens || tokens.output_tokens || 0;
cost += outputTokens * (pricing.output / 1000000);
const reasoningTokens = tokens.reasoning_tokens || 0;
if (reasoningTokens > 0) {
const rate = pricing.reasoning || pricing.output;
cost += reasoningTokens * (rate / 1000000);
}
const cacheCreationTokens = tokens.cache_creation_input_tokens || 0;
if (cacheCreationTokens > 0) {
const rate = pricing.cache_creation || pricing.input;
cost += cacheCreationTokens * (rate / 1000000);
}
return cost;
// Delegate the actual math to the single source of truth (avoids the two
// copies drifting apart — see open-sse/providers/pricing.js for the
// cache-inclusive prompt_tokens convention this assumes).
const { calculateCostFromTokens } = await import("open-sse/providers/pricing.js");
return calculateCostFromTokens(tokens, pricing);
} catch (e) {
console.error("Error calculating cost:", e);
return 0;
@@ -398,6 +379,7 @@ export async function getUsageStats(period = "all") {
timestamp: r.timestamp, model: r.model, provider: r.provider || "",
promptTokens: t.prompt_tokens || t.input_tokens || 0,
completionTokens: t.completion_tokens || t.output_tokens || 0,
cachedTokens: t.cached_tokens || t.cache_read_input_tokens || 0,
status: r.status || "ok",
};
})
@@ -413,7 +395,7 @@ export async function getUsageStats(period = "all") {
const stats = {
totalRequests: 0,
totalPromptTokens: 0, totalCompletionTokens: 0, totalCost: 0,
totalPromptTokens: 0, totalCompletionTokens: 0, totalCachedTokens: 0, totalCost: 0,
byProvider: {}, byModel: {}, byAccount: {}, byApiKey: {}, byEndpoint: {},
last10Minutes: [],
pending: pendingRequests,
@@ -474,13 +456,15 @@ export async function getUsageStats(period = "all") {
const day = parseJson(dr.data, {});
stats.totalPromptTokens += day.promptTokens || 0;
stats.totalCompletionTokens += day.completionTokens || 0;
stats.totalCachedTokens += day.cachedTokens || 0;
stats.totalCost += day.cost || 0;
for (const [prov, p] of Object.entries(day.byProvider || {})) {
if (!stats.byProvider[prov]) stats.byProvider[prov] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0 };
if (!stats.byProvider[prov]) stats.byProvider[prov] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0 };
stats.byProvider[prov].requests += p.requests || 0;
stats.byProvider[prov].promptTokens += p.promptTokens || 0;
stats.byProvider[prov].completionTokens += p.completionTokens || 0;
stats.byProvider[prov].cachedTokens += p.cachedTokens || 0;
stats.byProvider[prov].cost += p.cost || 0;
}
@@ -490,11 +474,12 @@ export async function getUsageStats(period = "all") {
const statsKey = provider ? `${rawModel} (${provider})` : rawModel;
const providerDisplayName = providerNodeNameMap[provider] || provider;
if (!stats.byModel[statsKey]) {
stats.byModel[statsKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel, provider: providerDisplayName, lastUsed: dateKey };
stats.byModel[statsKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, rawModel, provider: providerDisplayName, lastUsed: dateKey };
}
stats.byModel[statsKey].requests += m.requests || 0;
stats.byModel[statsKey].promptTokens += m.promptTokens || 0;
stats.byModel[statsKey].completionTokens += m.completionTokens || 0;
stats.byModel[statsKey].cachedTokens += m.cachedTokens || 0;
stats.byModel[statsKey].cost += m.cost || 0;
if (dateKey > (stats.byModel[statsKey].lastUsed || "")) stats.byModel[statsKey].lastUsed = dateKey;
}
@@ -506,11 +491,12 @@ export async function getUsageStats(period = "all") {
const providerDisplayName = providerNodeNameMap[provider] || provider;
const accountKey = `${rawModel} (${provider} - ${accountName})`;
if (!stats.byAccount[accountKey]) {
stats.byAccount[accountKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel, provider: providerDisplayName, connectionId: connId, accountName, lastUsed: dateKey };
stats.byAccount[accountKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, rawModel, provider: providerDisplayName, connectionId: connId, accountName, lastUsed: dateKey };
}
stats.byAccount[accountKey].requests += a.requests || 0;
stats.byAccount[accountKey].promptTokens += a.promptTokens || 0;
stats.byAccount[accountKey].completionTokens += a.completionTokens || 0;
stats.byAccount[accountKey].cachedTokens += a.cachedTokens || 0;
stats.byAccount[accountKey].cost += a.cost || 0;
if (dateKey > (stats.byAccount[accountKey].lastUsed || "")) stats.byAccount[accountKey].lastUsed = dateKey;
}
@@ -525,11 +511,12 @@ export async function getUsageStats(period = "all") {
const apiKeyMasked = maskApiKey(apiKeyVal);
const apiKeyKey = apiKeyMasked || "local-no-key";
if (!stats.byApiKey[akKey]) {
stats.byApiKey[akKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel, provider: providerDisplayName, apiKeyMasked, keyName, apiKeyKey, lastUsed: dateKey };
stats.byApiKey[akKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, rawModel, provider: providerDisplayName, apiKeyMasked, keyName, apiKeyKey, lastUsed: dateKey };
}
stats.byApiKey[akKey].requests += ak.requests || 0;
stats.byApiKey[akKey].promptTokens += ak.promptTokens || 0;
stats.byApiKey[akKey].completionTokens += ak.completionTokens || 0;
stats.byApiKey[akKey].cachedTokens += ak.cachedTokens || 0;
stats.byApiKey[akKey].cost += ak.cost || 0;
if (dateKey > (stats.byApiKey[akKey].lastUsed || "")) stats.byApiKey[akKey].lastUsed = dateKey;
}
@@ -540,11 +527,12 @@ export async function getUsageStats(period = "all") {
const provider = ep.provider || "";
const providerDisplayName = providerNodeNameMap[provider] || provider;
if (!stats.byEndpoint[epKey]) {
stats.byEndpoint[epKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, endpoint, rawModel, provider: providerDisplayName, lastUsed: dateKey };
stats.byEndpoint[epKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, endpoint, rawModel, provider: providerDisplayName, lastUsed: dateKey };
}
stats.byEndpoint[epKey].requests += ep.requests || 0;
stats.byEndpoint[epKey].promptTokens += ep.promptTokens || 0;
stats.byEndpoint[epKey].completionTokens += ep.completionTokens || 0;
stats.byEndpoint[epKey].cachedTokens += ep.cachedTokens || 0;
stats.byEndpoint[epKey].cost += ep.cost || 0;
if (dateKey > (stats.byEndpoint[epKey].lastUsed || "")) stats.byEndpoint[epKey].lastUsed = dateKey;
}
@@ -595,26 +583,30 @@ export async function getUsageStats(period = "all") {
const tokens = parseJson(r.tokens, {}) || {};
const promptTokens = tokens.prompt_tokens || 0;
const completionTokens = tokens.completion_tokens || 0;
const cachedTokens = tokens.cached_tokens || tokens.cache_read_input_tokens || 0;
const entryCost = r.cost || 0;
const providerDisplayName = providerNodeNameMap[r.provider] || r.provider;
stats.totalPromptTokens += promptTokens;
stats.totalCompletionTokens += completionTokens;
stats.totalCachedTokens += cachedTokens;
stats.totalCost += entryCost;
if (!stats.byProvider[r.provider]) stats.byProvider[r.provider] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0 };
if (!stats.byProvider[r.provider]) stats.byProvider[r.provider] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0 };
stats.byProvider[r.provider].requests++;
stats.byProvider[r.provider].promptTokens += promptTokens;
stats.byProvider[r.provider].completionTokens += completionTokens;
stats.byProvider[r.provider].cachedTokens += cachedTokens;
stats.byProvider[r.provider].cost += entryCost;
const modelKey = r.provider ? `${r.model} (${r.provider})` : r.model;
if (!stats.byModel[modelKey]) {
stats.byModel[modelKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, lastUsed: r.timestamp };
stats.byModel[modelKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, lastUsed: r.timestamp };
}
stats.byModel[modelKey].requests++;
stats.byModel[modelKey].promptTokens += promptTokens;
stats.byModel[modelKey].completionTokens += completionTokens;
stats.byModel[modelKey].cachedTokens += cachedTokens;
stats.byModel[modelKey].cost += entryCost;
if (new Date(r.timestamp) > new Date(stats.byModel[modelKey].lastUsed)) stats.byModel[modelKey].lastUsed = r.timestamp;
@@ -622,11 +614,12 @@ export async function getUsageStats(period = "all") {
const accountName = connectionMap[r.connectionId] || `Account ${r.connectionId.slice(0, 8)}...`;
const accountKey = `${r.model} (${r.provider} - ${accountName})`;
if (!stats.byAccount[accountKey]) {
stats.byAccount[accountKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, connectionId: r.connectionId, accountName, lastUsed: r.timestamp };
stats.byAccount[accountKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, connectionId: r.connectionId, accountName, lastUsed: r.timestamp };
}
stats.byAccount[accountKey].requests++;
stats.byAccount[accountKey].promptTokens += promptTokens;
stats.byAccount[accountKey].completionTokens += completionTokens;
stats.byAccount[accountKey].cachedTokens += cachedTokens;
stats.byAccount[accountKey].cost += entryCost;
if (new Date(r.timestamp) > new Date(stats.byAccount[accountKey].lastUsed)) stats.byAccount[accountKey].lastUsed = r.timestamp;
}
@@ -637,27 +630,27 @@ export async function getUsageStats(period = "all") {
const apiKeyMasked = maskApiKey(r.apiKey);
const akKey = `${apiKeyMasked}|${r.model}|${r.provider || "unknown"}`;
if (!stats.byApiKey[akKey]) {
stats.byApiKey[akKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, apiKeyMasked, keyName, apiKeyKey: apiKeyMasked, lastUsed: r.timestamp };
stats.byApiKey[akKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, apiKeyMasked, keyName, apiKeyKey: apiKeyMasked, lastUsed: r.timestamp };
}
const ake = stats.byApiKey[akKey];
ake.requests++; ake.promptTokens += promptTokens; ake.completionTokens += completionTokens; ake.cost += entryCost;
ake.requests++; ake.promptTokens += promptTokens; ake.completionTokens += completionTokens; ake.cachedTokens += cachedTokens; ake.cost += entryCost;
if (new Date(r.timestamp) > new Date(ake.lastUsed)) ake.lastUsed = r.timestamp;
} else {
if (!stats.byApiKey["local-no-key"]) {
stats.byApiKey["local-no-key"] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, apiKeyMasked: null, keyName: "Local (No API Key)", apiKeyKey: "local-no-key", lastUsed: r.timestamp };
stats.byApiKey["local-no-key"] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, apiKeyMasked: null, keyName: "Local (No API Key)", apiKeyKey: "local-no-key", lastUsed: r.timestamp };
}
const ake = stats.byApiKey["local-no-key"];
ake.requests++; ake.promptTokens += promptTokens; ake.completionTokens += completionTokens; ake.cost += entryCost;
ake.requests++; ake.promptTokens += promptTokens; ake.completionTokens += completionTokens; ake.cachedTokens += cachedTokens; ake.cost += entryCost;
if (new Date(r.timestamp) > new Date(ake.lastUsed)) ake.lastUsed = r.timestamp;
}
const endpoint = r.endpoint || "Unknown";
const epKey = `${endpoint}|${r.model}|${r.provider || "unknown"}`;
if (!stats.byEndpoint[epKey]) {
stats.byEndpoint[epKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, endpoint, rawModel: r.model, provider: providerDisplayName, lastUsed: r.timestamp };
stats.byEndpoint[epKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, endpoint, rawModel: r.model, provider: providerDisplayName, lastUsed: r.timestamp };
}
const epe = stats.byEndpoint[epKey];
epe.requests++; epe.promptTokens += promptTokens; epe.completionTokens += completionTokens; epe.cost += entryCost;
epe.requests++; epe.promptTokens += promptTokens; epe.completionTokens += completionTokens; epe.cachedTokens += cachedTokens; epe.cost += entryCost;
if (new Date(r.timestamp) > new Date(epe.lastUsed)) epe.lastUsed = r.timestamp;
}
}