fix: refine token usage reporting (#807)

This commit is contained in:
Haile
2026-05-30 11:09:27 +03:00
committed by GitHub
parent 86948097aa
commit 38bf21ddf5
15 changed files with 549 additions and 272 deletions

View File

@@ -97,17 +97,10 @@ export type CostCommandData = {
tokenUsage?: {
used?: number;
total?: number;
percentage?: number;
};
cost?: {
input?: string;
output?: string;
total?: string;
};
tokenBreakdown?: {
input?: number;
output?: number;
cache?: number;
};
provider?: string;
model?: string;

View File

@@ -624,19 +624,23 @@ export function useChatSessionState({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatMessages.length, isLoadingSessionMessages, searchTarget]);
// Token usage fetch for Claude
// Initial token usage fetch for providers with file-backed usage data.
useEffect(() => {
if (!selectedProject || !selectedSession?.id) {
setTokenBudget(null);
return;
}
const sessionProvider = selectedSession.__provider || 'claude';
if (sessionProvider !== 'claude') return;
if (sessionProvider !== 'claude' && sessionProvider !== 'codex' && sessionProvider !== 'gemini' && sessionProvider !== 'opencode') {
setTokenBudget(null);
return;
}
const fetchInitialTokenUsage = async () => {
try {
// Token usage endpoint is now keyed by the DB projectId.
const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage`;
const params = new URLSearchParams({ provider: sessionProvider });
const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage?${params.toString()}`;
const response = await authenticatedFetch(url);
if (response.ok) {
setTokenBudget(await response.json());

View File

@@ -18,7 +18,7 @@ import ClaudeStatus from './ClaudeStatus';
import ImageAttachment from './ImageAttachment';
import PermissionRequestsBanner from './PermissionRequestsBanner';
import ThinkingModeSelector from './ThinkingModeSelector';
import TokenUsagePie from './TokenUsagePie';
import TokenUsageSummary from './TokenUsageSummary';
import {
PromptInput,
PromptInputHeader,
@@ -60,7 +60,7 @@ interface ChatComposerProps {
onModeSwitch: () => void;
thinkingMode: string;
setThinkingMode: Dispatch<SetStateAction<string>>;
tokenBudget: { used?: number; total?: number } | null;
tokenBudget: Record<string, unknown> | null;
slashCommandsCount: number;
onToggleCommandMenu: () => void;
hasInput: boolean;
@@ -361,7 +361,7 @@ export default function ChatComposer({
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
)}
<TokenUsagePie used={tokenBudget?.used || 0} total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000} />
<TokenUsageSummary usage={tokenBudget} />
<PromptInputButton
tooltip={{ content: t('input.showAllCommands') }}

View File

@@ -6,7 +6,6 @@ import {
CircleHelp,
Clipboard,
Coins,
Command as CommandIcon,
Cpu,
Gauge,
Package,
@@ -17,7 +16,6 @@ import {
Timer,
RefreshCw,
X,
Zap,
} from 'lucide-react';
import { Badge, Button, Dialog, DialogContent, DialogTitle, Input } from '../../../../shared/view/ui';
@@ -84,7 +82,7 @@ const PROVIDER_LABELS: Record<string, string> = {
const FALLBACK_COMMANDS: CommandEntry[] = [
{ name: '/models', description: 'Browse available models for the active provider.' },
{ name: '/cost', description: 'Review context usage and estimated token spend.' },
{ name: '/cost', description: 'Review token usage for the active session.' },
{ name: '/status', description: 'Inspect runtime, version, provider, and environment status.' },
{ name: '/memory', description: 'Open the project CLAUDE.md memory file.' },
{ name: '/config', description: 'Open settings and configuration.' },
@@ -99,13 +97,6 @@ const getProviderLabel = (provider: string | undefined, fallback = 'Unknown') =>
return PROVIDER_LABELS[provider] || provider;
};
const clampPercentage = (value: number) => {
if (!Number.isFinite(value)) {
return 0;
}
return Math.max(0, Math.min(100, value));
};
const formatNumber = (value: number) => {
if (!Number.isFinite(value)) {
return '0';
@@ -113,11 +104,6 @@ const formatNumber = (value: number) => {
return value.toLocaleString();
};
const formatCurrency = (value: number | string | undefined) => {
const numeric = Number(value ?? 0);
return `$${Number.isFinite(numeric) ? numeric.toFixed(4) : '0.0000'}`;
};
function MetricCard({
label,
value,
@@ -507,62 +493,71 @@ function ModelsContent({
function CostContent({ data }: { data: CostCommandData }) {
const used = Number(data.tokenUsage?.used ?? 0);
const total = Number(data.tokenUsage?.total ?? 0);
const percentage = clampPercentage(Number(data.tokenUsage?.percentage ?? 0));
const model = data.model || 'Unknown';
const provider = getProviderLabel(data.provider, data.provider || 'Unknown');
const inputTokens = Number(data.tokenBreakdown?.input ?? 0);
const outputTokens = Number(data.tokenBreakdown?.output ?? 0);
const cacheTokens = Number(data.tokenBreakdown?.cache ?? 0);
const totalCost = Number(data.cost?.total ?? 0);
const hasBreakdown =
typeof data.tokenBreakdown?.input === 'number' ||
typeof data.tokenBreakdown?.output === 'number';
const usageRows = [
{ label: 'Total tokens used', value: formatNumber(used), icon: Activity },
...(hasBreakdown
? [
{
label: 'Input tokens',
value: formatNumber(Number(data.tokenBreakdown?.input ?? 0)),
icon: TerminalSquare,
},
{
label: 'Output tokens',
value: formatNumber(Number(data.tokenBreakdown?.output ?? 0)),
icon: Coins,
},
]
: [
{
label: 'Breakdown',
value: 'Unavailable',
icon: TerminalSquare,
},
]),
...(total > 0
? [{ label: 'Context window', value: formatNumber(total), icon: Gauge }]
: []),
];
return (
<div className="grid gap-4 lg:grid-cols-[18rem_1fr]">
<div className="rounded-3xl border border-primary/25 bg-primary/10 p-5 text-center">
<div
className="mx-auto grid h-40 w-40 place-items-center rounded-full p-2 shadow-inner"
style={{
background: `conic-gradient(hsl(var(--primary)) ${percentage * 3.6}deg, hsl(var(--muted)) 0deg)`,
}}
>
<div className="grid h-full w-full place-items-center rounded-full border border-border/70 bg-popover">
<div>
<p className="font-mono text-3xl font-semibold text-foreground">{percentage.toFixed(1)}%</p>
<p className="mt-1 text-xs uppercase tracking-[0.18em] text-muted-foreground">context</p>
<div className="space-y-4">
<div className="overflow-hidden rounded-2xl border border-border/70 bg-background/75">
{usageRows.map((row) => {
const Icon = row.icon;
return (
<div
key={row.label}
className="flex items-center justify-between gap-4 border-b border-border/60 px-4 py-3 last:border-b-0"
>
<div className="flex min-w-0 items-center gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl border border-primary/20 bg-primary/10 text-primary">
<Icon className="h-4 w-4" />
</span>
<span className="truncate text-sm font-medium text-foreground">{row.label}</span>
</div>
<span className="shrink-0 font-mono text-sm font-semibold text-foreground">{row.value}</span>
</div>
</div>
</div>
<p className="mt-4 text-sm text-muted-foreground">
{formatNumber(used)} of {formatNumber(total)} tokens used
</p>
);
})}
</div>
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-3">
<MetricCard label="Input" value={formatCurrency(data.cost?.input)} icon={Zap} />
<MetricCard label="Output" value={formatCurrency(data.cost?.output)} icon={Activity} />
<MetricCard label="Total" value={formatCurrency(totalCost)} icon={Coins} tone="primary" />
</div>
<div className="grid gap-3 sm:grid-cols-3">
<MetricCard label="Input tokens" value={formatNumber(inputTokens)} icon={CommandIcon} />
<MetricCard label="Output tokens" value={formatNumber(outputTokens)} icon={TerminalSquare} />
<MetricCard label="Cache tokens" value={formatNumber(cacheTokens)} icon={Package} />
</div>
<div className="rounded-2xl border border-border/70 bg-muted/20 p-4">
<div className="grid gap-3 sm:grid-cols-2">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Provider</p>
<p className="mt-1 text-sm font-semibold text-foreground">{provider}</p>
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Model</p>
<p className="mt-1 break-all font-mono text-sm text-foreground">{model}</p>
</div>
<div className="rounded-2xl border border-border/70 bg-muted/20 p-4">
<div className="grid gap-3 sm:grid-cols-2">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Provider</p>
<p className="mt-1 text-sm font-semibold text-foreground">{provider}</p>
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Model</p>
<p className="mt-1 break-all font-mono text-sm text-foreground">{model}</p>
</div>
<p className="mt-3 text-xs leading-5 text-muted-foreground">
Cost is an estimate based on the available token counters and default provider rates.
</p>
</div>
</div>
</div>
@@ -636,8 +631,8 @@ export default function CommandResultModal({
},
cost: {
eyebrow: 'Session telemetry',
title: 'Usage & Cost',
subtitle: 'Token budget, context pressure, and estimated spend for this session.',
title: 'Token Usage',
subtitle: 'Input, output, and total token counts for this session.',
icon: Coins,
},
status: {

View File

@@ -1,54 +0,0 @@
type TokenUsagePieProps = {
used: number;
total: number;
};
export default function TokenUsagePie({ used, total }: TokenUsagePieProps) {
// Token usage visualization component
// Only bail out on missing values or nonpositive totals; allow used===0 to render 0%
if (used == null || total == null || total <= 0) return null;
const percentage = Math.min(100, (used / total) * 100);
const radius = 10;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (percentage / 100) * circumference;
// Color based on usage level
const getColor = () => {
if (percentage < 50) return '#3b82f6'; // blue
if (percentage < 75) return '#f59e0b'; // orange
return '#ef4444'; // red
};
return (
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<svg width="24" height="24" viewBox="0 0 24 24" className="-rotate-90 transform">
{/* Background circle */}
<circle
cx="12"
cy="12"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="2"
className="text-gray-300 dark:text-gray-600"
/>
{/* Progress circle */}
<circle
cx="12"
cy="12"
r={radius}
fill="none"
stroke={getColor()}
strokeWidth="2"
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
/>
</svg>
<span title={`${used.toLocaleString()} / ${total.toLocaleString()} tokens`}>
{percentage.toFixed(1)}%
</span>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { ActivityIcon } from 'lucide-react';
type TokenUsageSummaryProps = {
usage: Record<string, unknown> | null;
};
const formatTokenCount = (value: number) => {
if (!Number.isFinite(value) || value <= 0) {
return '0';
}
if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`;
}
if (value >= 10_000) {
return `${Math.round(value / 1_000)}K`;
}
if (value >= 1_000) {
return `${(value / 1_000).toFixed(1)}K`;
}
return value.toLocaleString();
};
const readUsageNumber = (value: unknown) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
};
export default function TokenUsageSummary({ usage }: TokenUsageSummaryProps) {
const breakdown =
usage?.breakdown && typeof usage.breakdown === 'object'
? usage.breakdown as Record<string, unknown>
: null;
const inputTokens = readUsageNumber(usage?.inputTokens ?? breakdown?.input);
const outputTokens = readUsageNumber(usage?.outputTokens ?? breakdown?.output);
const usedTokens = readUsageNumber(usage?.used) || inputTokens + outputTokens;
return (
<div
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border/70 bg-background/70 px-2 text-xs text-muted-foreground shadow-sm transition-colors hover:border-primary/25 hover:text-foreground sm:gap-2 sm:px-2.5"
title={`${usedTokens.toLocaleString()} tokens used`}
>
<span className="grid h-5 w-5 place-items-center rounded-md bg-primary/10 text-primary">
<ActivityIcon className="h-3.5 w-3.5" />
</span>
<span className="font-medium text-foreground">{formatTokenCount(usedTokens)}</span>
<span className="hidden text-muted-foreground/70 sm:inline">tokens</span>
</div>
);
}