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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user