Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions open-sse/handlers/chatCore/streamingHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { pipeWithDisconnect } from "../../utils/streamHandler.js";
import { PROVIDERS } from "../../config/providers.js";
import { STREAM_STALL_TIMEOUT_MS } from "../../config/runtimeConfig.js";
import { buildAbortedResponsesTerminalBytes } from "../../utils/responsesStreamHelpers.js";
import { buildRequestDetail, extractRequestConfig, saveUsageStats } from "./requestDetail.js";
import { buildRequestDetail, extractRequestConfig } from "./requestDetail.js";
import { saveRequestDetail } from "@/lib/usageDb.js";
import { SSE_HEADERS_CORS as SSE_HEADERS } from "../../utils/sseConstants.js";

Expand Down Expand Up @@ -101,7 +101,8 @@ export function buildOnStreamComplete({ provider, model, connectionId, apiKey, r
console.error("[RequestDetail] Failed to update streaming content:", err.message);
});

saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, label: "STREAM USAGE" });
// Usage is already persisted by logUsage() in the SSE transform flush. Saving
// again here loses cache/reasoning detail and doubles dashboard costs.
};

return { onStreamComplete, streamDetailId };
Expand Down
235 changes: 180 additions & 55 deletions open-sse/providers/pricing.js

Large diffs are not rendered by default.

27 changes: 18 additions & 9 deletions open-sse/utils/usageTracking.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,24 +174,32 @@ export function extractUsage(chunk) {

// Claude format (message_delta event)
if (chunk.type === "message_delta" && chunk.usage && typeof chunk.usage === "object") {
const inputTokens = chunk.usage.input_tokens || 0;
const cacheReadTokens = chunk.usage.cache_read_input_tokens || 0;
const cacheCreationTokens = chunk.usage.cache_creation_input_tokens || 0;
return normalizeUsage({
prompt_tokens: chunk.usage.input_tokens || 0,
prompt_tokens: inputTokens + cacheReadTokens + cacheCreationTokens,
completion_tokens: chunk.usage.output_tokens || 0,
cache_read_input_tokens: chunk.usage.cache_read_input_tokens,
cache_creation_input_tokens: chunk.usage.cache_creation_input_tokens
cache_read_input_tokens: cacheReadTokens,
cache_creation_input_tokens: cacheCreationTokens
});
}

// OpenAI Responses API format (response.completed or response.done)
if ((chunk.type === "response.completed" || chunk.type === "response.done") && chunk.response?.usage && typeof chunk.response.usage === "object") {
const usage = chunk.response.usage;
const cachedTokens = usage.input_tokens_details?.cached_tokens;
const cacheCreationTokens = usage.input_tokens_details?.cache_creation_tokens;
return normalizeUsage({
prompt_tokens: usage.input_tokens || usage.prompt_tokens || 0,
completion_tokens: usage.output_tokens || usage.completion_tokens || 0,
cached_tokens: cachedTokens,
cache_creation_input_tokens: cacheCreationTokens,
reasoning_tokens: usage.output_tokens_details?.reasoning_tokens,
prompt_tokens_details: cachedTokens ? { cached_tokens: cachedTokens } : undefined
prompt_tokens_details: (cachedTokens || cacheCreationTokens) ? {
...(cachedTokens ? { cached_tokens: cachedTokens } : {}),
...(cacheCreationTokens ? { cache_creation_tokens: cacheCreationTokens } : {}),
} : undefined
});
}

Expand Down Expand Up @@ -308,10 +316,15 @@ export function logUsage(provider, usage, model = null, connectionId = null, api

const p = provider?.toUpperCase() || "UNKNOWN";

// Add cache info if present (unified from different formats)
const cacheRead = usage.cache_read_input_tokens || usage.cached_tokens || usage.prompt_tokens_details?.cached_tokens || usage.input_tokens_details?.cached_tokens;
const cacheCreation = usage.cache_creation_input_tokens || usage.prompt_tokens_details?.cache_creation_tokens || usage.input_tokens_details?.cache_creation_tokens;
const reasoning = usage.reasoning_tokens || usage.completion_tokens_details?.reasoning_tokens || usage.output_tokens_details?.reasoning_tokens;

// Support both formats:
// - OpenAI: prompt_tokens, completion_tokens
// - Claude: input_tokens, output_tokens
const inTokens = usage?.prompt_tokens || usage?.input_tokens || 0;
const inTokens = usage?.prompt_tokens || ((usage?.input_tokens || 0) + (cacheRead || 0) + (cacheCreation || 0));
const outTokens = usage?.completion_tokens || usage?.output_tokens || 0;
const accountPrefix = connectionId ? connectionId.slice(0, 8) + "..." : "unknown";

Expand All @@ -322,14 +335,10 @@ export function logUsage(provider, usage, model = null, connectionId = null, api
msg += ` ${COLORS.yellow}(estimated)${COLORS.reset}`;
}

// Add cache info if present (unified from different formats)
const cacheRead = usage.cache_read_input_tokens || usage.cached_tokens || usage.prompt_tokens_details?.cached_tokens;
if (cacheRead) msg += ` | cache_read=${cacheRead}`;

const cacheCreation = usage.cache_creation_input_tokens;
if (cacheCreation) msg += ` | cache_create=${cacheCreation}`;

const reasoning = usage.reasoning_tokens;
if (reasoning) msg += ` | reasoning=${reasoning}`;

console.log(msg);
Expand Down
32 changes: 30 additions & 2 deletions src/app/(dashboard)/dashboard/usage/components/OverviewCards.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,32 @@ import PropTypes from "prop-types";
import Card from "@/shared/components/Card";

const fmt = (n) => new Intl.NumberFormat().format(n || 0);
const fmtCost = (n) => `$${(n || 0).toFixed(2)}`;
const fmtCost = (n) => {
const value = Number(n || 0);
if (!Number.isFinite(value) || value === 0) return "$0.00";
const abs = Math.abs(value);
if (abs < 0.0001) return `$${value.toFixed(6)}`;
if (abs < 0.01) return `$${value.toFixed(4)}`;
return `$${value.toFixed(2)}`;
};

export default function OverviewCards({ stats }) {
const cachedInput = stats.totalCacheReadTokens || 0;
const cachePercent = stats.totalPromptTokens > 0 ? Math.round((cachedInput / stats.totalPromptTokens) * 100) : 0;
const hasTokenBreakdown = (stats.totalUncachedPromptTokens || cachedInput || stats.totalCacheCreationTokens) > 0;
const uncachedInput = hasTokenBreakdown ? (stats.totalUncachedPromptTokens || 0) : (stats.totalPromptTokens || 0);
const hasCostBreakdown = [
stats.totalInputCost,
stats.totalOutputCost,
stats.totalCachedInputCost,
stats.totalCacheCreationCost,
].some((value) => Number(value || 0) > 0);
const totalTokens = (stats.totalPromptTokens || 0) + (stats.totalCompletionTokens || 0);
const fallbackInputCost = totalTokens > 0 ? (stats.totalPromptTokens || 0) * ((stats.totalCost || 0) / totalTokens) : 0;
const fallbackOutputCost = totalTokens > 0 ? (stats.totalCompletionTokens || 0) * ((stats.totalCost || 0) / totalTokens) : 0;
const inputCost = hasCostBreakdown ? (stats.totalInputCost || 0) : fallbackInputCost;
const outputCost = hasCostBreakdown ? (stats.totalOutputCost || 0) : fallbackOutputCost;

return (
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-4 sm:gap-4">
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
Expand All @@ -16,15 +39,20 @@ export default function OverviewCards({ stats }) {
<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 Input Tokens</span>
<span className="truncate text-2xl font-bold text-primary">{fmt(stats.totalPromptTokens)}</span>
<span className="text-[10px] text-text-muted">
{fmt(uncachedInput)} uncached | {fmt(cachedInput)} cached ({cachePercent}%)
</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>
<span className="text-[10px] text-text-muted">{fmt(stats.totalReasoningTokens)} reasoning</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">Est. Cost</span>
<span className="truncate text-2xl font-bold text-warning">~{fmtCost(stats.totalCost)}</span>
<span className="text-[10px] text-text-muted">Estimated, not actual billing</span>
<span className="text-[10px] text-text-muted">Input {fmtCost(inputCost)} | Output {fmtCost(outputCost)}</span>
<span className="text-[10px] text-text-muted">Cache saved ~{fmtCost(stats.totalCacheSavings)}</span>
</Card>
</div>
);
Expand Down
47 changes: 41 additions & 6 deletions src/app/(dashboard)/dashboard/usage/components/UsageTable.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@ import Card from "@/shared/components/Card";
import Badge from "@/shared/components/Badge";

const fmt = (n) => new Intl.NumberFormat().format(n || 0);
const fmtCost = (n) => `$${(n || 0).toFixed(2)}`;
const fmtCost = (n) => {
const value = Number(n || 0);
if (!Number.isFinite(value) || value === 0) return "$0.00";
const abs = Math.abs(value);
if (abs < 0.0001) return `$${value.toFixed(6)}`;
if (abs < 0.01) return `$${value.toFixed(4)}`;
return `$${value.toFixed(2)}`;
};

function fmtTime(iso) {
if (!iso) return "Never";
Expand All @@ -32,14 +39,33 @@ SortIcon.propTypes = {
* Render 3 token or cost cells based on viewMode
*/
function ValueCells({ item, viewMode, isSummary = false }) {
const hasInputBreakdown = (item.cacheReadTokens || item.cacheCreationTokens || item.uncachedPromptTokens) > 0;
const hasInputCostBreakdown = (item.cachedInputCost || item.cacheCreationCost || item.uncachedInputCost) > 0;
const inputTokenParts = [
item.uncachedPromptTokens ? `${fmt(item.uncachedPromptTokens)} uncached` : null,
item.cacheReadTokens ? `${fmt(item.cacheReadTokens)} cached` : null,
item.cacheCreationTokens ? `${fmt(item.cacheCreationTokens)} write` : null,
].filter(Boolean);
const inputCostParts = [
item.uncachedInputCost ? `${fmtCost(item.uncachedInputCost)} uncached` : null,
item.cachedInputCost ? `${fmtCost(item.cachedInputCost)} cached` : null,
item.cacheCreationCost ? `${fmtCost(item.cacheCreationCost)} write` : null,
].filter(Boolean);

if (viewMode === "tokens") {
return (
<>
<td className="px-6 py-3 text-right text-text-muted">
{isSummary && item.promptTokens === undefined ? "—" : fmt(item.promptTokens)}
<div>{isSummary && item.promptTokens === undefined ? "—" : fmt(item.promptTokens)}</div>
{hasInputBreakdown && inputTokenParts.length > 0 && (
<div className="text-[10px] leading-4 text-text-muted/80">{inputTokenParts.join(" | ")}</div>
)}
</td>
<td className="px-6 py-3 text-right text-text-muted">
{isSummary && item.completionTokens === undefined ? "—" : fmt(item.completionTokens)}
<div>{isSummary && item.completionTokens === undefined ? "—" : fmt(item.completionTokens)}</div>
{item.reasoningTokens > 0 && (
<div className="text-[10px] leading-4 text-text-muted/80">{fmt(item.reasoningTokens)} reasoning</div>
)}
</td>
<td className="px-6 py-3 text-right font-medium">
{fmt(item.totalTokens)}
Expand All @@ -50,13 +76,22 @@ function ValueCells({ item, viewMode, isSummary = false }) {
return (
<>
<td className="px-6 py-3 text-right text-text-muted">
{isSummary && item.inputCost === undefined ? "—" : fmtCost(item.inputCost)}
<div>{isSummary && item.inputCost === undefined ? "—" : fmtCost(item.inputCost)}</div>
{hasInputCostBreakdown && inputCostParts.length > 0 && (
<div className="text-[10px] leading-4 text-text-muted/80">{inputCostParts.join(" | ")}</div>
)}
</td>
<td className="px-6 py-3 text-right text-text-muted">
{isSummary && item.outputCost === undefined ? "—" : fmtCost(item.outputCost)}
<div>{isSummary && item.outputCost === undefined ? "—" : fmtCost(item.outputCost)}</div>
{item.reasoningCost > 0 && (
<div className="text-[10px] leading-4 text-text-muted/80">{fmtCost(item.reasoningCost)} reasoning incl.</div>
)}
</td>
<td className="px-6 py-3 text-right font-medium text-warning">
{fmtCost(item.totalCost || item.cost)}
<div>{fmtCost(item.totalCost || item.cost)}</div>
{item.cacheSavings > 0 && (
<div className="text-[10px] leading-4 text-text-muted/80">{fmtCost(item.cacheSavings)} cache saved</div>
)}
</td>
</>
);
Expand Down
Loading