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:
@@ -8,7 +8,7 @@ const fmtCost = (n) => `$${(n || 0).toFixed(2)}`;
|
||||
|
||||
export default function OverviewCards({ stats }) {
|
||||
return (
|
||||
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-4 sm:gap-4">
|
||||
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 sm:gap-4">
|
||||
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
|
||||
<span className="text-text-muted text-sm uppercase font-semibold">Total Requests</span>
|
||||
<span className="truncate text-2xl font-bold">{fmt(stats.totalRequests)}</span>
|
||||
@@ -17,6 +17,10 @@ export default function OverviewCards({ stats }) {
|
||||
<span className="text-text-muted text-sm uppercase font-semibold">Total Input Tokens</span>
|
||||
<span className="truncate text-2xl font-bold text-primary">{fmt(stats.totalPromptTokens)}</span>
|
||||
</Card>
|
||||
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
|
||||
<span className="text-text-muted text-sm uppercase font-semibold">Cached Tokens</span>
|
||||
<span className="truncate text-2xl font-bold text-info">{fmt(stats.totalCachedTokens)}</span>
|
||||
</Card>
|
||||
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
|
||||
<span className="text-text-muted text-sm uppercase font-semibold">Output Tokens</span>
|
||||
<span className="truncate text-2xl font-bold text-success">{fmt(stats.totalCompletionTokens)}</span>
|
||||
|
||||
@@ -82,9 +82,20 @@ function CollapsibleSection({ title, children, defaultOpen = false, icon = null
|
||||
);
|
||||
}
|
||||
|
||||
function getCachedTokens(tokens) {
|
||||
return tokens?.cached_tokens || tokens?.cache_read_input_tokens || 0;
|
||||
}
|
||||
|
||||
function getCacheCreationTokens(tokens) {
|
||||
return tokens?.cache_creation_input_tokens || 0;
|
||||
}
|
||||
|
||||
function getInputTokens(tokens) {
|
||||
const prompt = tokens?.prompt_tokens || tokens?.input_tokens || 0;
|
||||
const cache = tokens?.cached_tokens || tokens?.cache_read_input_tokens || 0;
|
||||
// Canonical storage keeps prompt cache-inclusive. Legacy Claude rows may have
|
||||
// stored prompt cache-exclusive; fall back to cache when it's larger so old
|
||||
// rows don't under-report input.
|
||||
const cache = getCachedTokens(tokens);
|
||||
return prompt < cache ? cache : prompt;
|
||||
}
|
||||
|
||||
@@ -245,6 +256,8 @@ export default function RequestDetailsTab() {
|
||||
<th className="text-left p-4 text-sm font-semibold text-text-main">Model</th>
|
||||
<th className="text-left p-4 text-sm font-semibold text-text-main">Provider</th>
|
||||
<th className="text-right p-4 text-sm font-semibold text-text-main">Input Tokens</th>
|
||||
<th className="text-right p-4 text-sm font-semibold text-text-main">Cached</th>
|
||||
<th className="text-right p-4 text-sm font-semibold text-text-main">Cache Creation</th>
|
||||
<th className="text-right p-4 text-sm font-semibold text-text-main">Output Tokens</th>
|
||||
<th className="text-left p-4 text-sm font-semibold text-text-main">Latency</th>
|
||||
<th className="text-center p-4 text-sm font-semibold text-text-main">Action</th>
|
||||
@@ -286,6 +299,12 @@ export default function RequestDetailsTab() {
|
||||
<td className="p-4 text-sm text-text-main text-right font-mono">
|
||||
{getInputTokens(detail.tokens).toLocaleString()}
|
||||
</td>
|
||||
<td className="p-4 text-sm text-text-main text-right font-mono">
|
||||
{getCachedTokens(detail.tokens) > 0 ? getCachedTokens(detail.tokens).toLocaleString() : "—"}
|
||||
</td>
|
||||
<td className="p-4 text-sm text-text-main text-right font-mono">
|
||||
{getCacheCreationTokens(detail.tokens) > 0 ? getCacheCreationTokens(detail.tokens).toLocaleString() : "—"}
|
||||
</td>
|
||||
<td className="p-4 text-sm text-text-main text-right font-mono">
|
||||
{detail.tokens?.completion_tokens?.toLocaleString() || 0}
|
||||
</td>
|
||||
@@ -370,6 +389,22 @@ export default function RequestDetailsTab() {
|
||||
{getInputTokens(selectedDetail.tokens).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{getCachedTokens(selectedDetail.tokens) > 0 && (
|
||||
<div>
|
||||
<span className="text-text-muted">Cached Tokens:</span>{" "}
|
||||
<span className="text-text-main font-mono">
|
||||
{getCachedTokens(selectedDetail.tokens).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{getCacheCreationTokens(selectedDetail.tokens) > 0 && (
|
||||
<div>
|
||||
<span className="text-text-muted">Cache Creation:</span>{" "}
|
||||
<span className="text-text-main font-mono">
|
||||
{getCacheCreationTokens(selectedDetail.tokens).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-text-muted">Output Tokens:</span>{" "}
|
||||
<span className="text-text-main font-mono">
|
||||
|
||||
@@ -38,6 +38,9 @@ function ValueCells({ item, viewMode, isSummary = false }) {
|
||||
<td className="px-6 py-3 text-right text-text-muted">
|
||||
{isSummary && item.promptTokens === undefined ? "—" : fmt(item.promptTokens)}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right text-text-muted">
|
||||
{item.cachedTokens ? fmt(item.cachedTokens) : "—"}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right text-text-muted">
|
||||
{isSummary && item.completionTokens === undefined ? "—" : fmt(item.completionTokens)}
|
||||
</td>
|
||||
@@ -52,6 +55,9 @@ function ValueCells({ item, viewMode, isSummary = false }) {
|
||||
<td className="px-6 py-3 text-right text-text-muted">
|
||||
{isSummary && item.inputCost === undefined ? "—" : fmtCost(item.inputCost)}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right text-text-muted">
|
||||
{item.cachedCost ? fmtCost(item.cachedCost) : "—"}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right text-text-muted">
|
||||
{isSummary && item.outputCost === undefined ? "—" : fmtCost(item.outputCost)}
|
||||
</td>
|
||||
@@ -133,12 +139,14 @@ export default function UsageTable({
|
||||
if (viewMode === "tokens") {
|
||||
return [
|
||||
{ field: "promptTokens", label: "Input Tokens" },
|
||||
{ field: "cachedTokens", label: "Cached" },
|
||||
{ field: "completionTokens", label: "Output Tokens" },
|
||||
{ field: "totalTokens", label: "Total Tokens" },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ field: "promptTokens", label: "Input Cost" },
|
||||
{ field: "cachedCost", label: "Cached Cost" },
|
||||
{ field: "completionTokens", label: "Output Cost" },
|
||||
{ field: "cost", label: "Total Cost" },
|
||||
];
|
||||
|
||||
@@ -126,6 +126,8 @@ export async function POST(request) {
|
||||
|
||||
let providerSpecificData = normalizeProviderSpecificData(provider, body, body.providerSpecificData);
|
||||
|
||||
// Compatible LLM nodes support multiple API-key connections (key pool); runtime
|
||||
// rotates/fails over via getProviderCredentials. Embedding nodes stay single-connection.
|
||||
if (isOpenAICompatibleProvider(provider)) {
|
||||
const node = await getProviderNodeById(provider);
|
||||
if (!node) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,9 +89,16 @@ function sortData(dataMap, pendingMap = {}, sortBy, sortOrder) {
|
||||
.map(([key, data]) => {
|
||||
const totalTokens = (data.promptTokens || 0) + (data.completionTokens || 0);
|
||||
const totalCost = data.cost || 0;
|
||||
const inputCost = totalTokens > 0 ? (data.promptTokens || 0) * (totalCost / totalTokens) : 0;
|
||||
// ponytail: cost split is a token-share allocation of the (rate-accurate)
|
||||
// server total, not a per-rate recompute. cached is a subset of prompt, so
|
||||
// peel it out of the input share. Upgrade to a stored per-component cost
|
||||
// breakdown if exact cached-rate cost display is needed.
|
||||
const cachedTokens = data.cachedTokens || 0;
|
||||
const nonCachedInput = Math.max(0, (data.promptTokens || 0) - cachedTokens);
|
||||
const inputCost = totalTokens > 0 ? nonCachedInput * (totalCost / totalTokens) : 0;
|
||||
const cachedCost = totalTokens > 0 ? cachedTokens * (totalCost / totalTokens) : 0;
|
||||
const outputCost = totalTokens > 0 ? (data.completionTokens || 0) * (totalCost / totalTokens) : 0;
|
||||
return { ...data, key, totalTokens, totalCost, inputCost, outputCost, pending: pendingMap[key] || 0 };
|
||||
return { ...data, key, totalTokens, totalCost, inputCost, cachedCost, outputCost, pending: pendingMap[key] || 0 };
|
||||
})
|
||||
.sort((a, b) => {
|
||||
let valA = a[sortBy];
|
||||
@@ -122,7 +129,7 @@ function groupDataByKey(data, keyField) {
|
||||
if (!groups[gk]) {
|
||||
groups[gk] = {
|
||||
groupKey: gk,
|
||||
summary: { requests: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, cost: 0, inputCost: 0, outputCost: 0, lastUsed: null, pending: 0 },
|
||||
summary: { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, totalTokens: 0, cost: 0, inputCost: 0, cachedCost: 0, outputCost: 0, lastUsed: null, pending: 0 },
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
@@ -130,9 +137,11 @@ function groupDataByKey(data, keyField) {
|
||||
s.requests += item.requests || 0;
|
||||
s.promptTokens += item.promptTokens || 0;
|
||||
s.completionTokens += item.completionTokens || 0;
|
||||
s.cachedTokens += item.cachedTokens || 0;
|
||||
s.totalTokens += item.totalTokens || 0;
|
||||
s.cost += item.cost || 0;
|
||||
s.inputCost += item.inputCost || 0;
|
||||
s.cachedCost += item.cachedCost || 0;
|
||||
s.outputCost += item.outputCost || 0;
|
||||
s.pending += item.pending || 0;
|
||||
if (item.lastUsed && (!s.lastUsed || new Date(item.lastUsed) > new Date(s.lastUsed))) {
|
||||
|
||||
Reference in New Issue
Block a user