Skip to content

Commit c3903df

Browse files
committed
Add a cost/usage dashboard
Aggregate token usage and estimated cost across all sessions, broken down by provider, by model, and over the last 14 days, so spend isn't only visible one conversation at a time.
1 parent 217a320 commit c3903df

7 files changed

Lines changed: 330 additions & 1 deletion

File tree

‎frontend/src/App.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Layout from "./components/layout";
33
import Chat from "./pages/Chat";
44
import Settings from "./pages/Settings";
55
import Compare from "./pages/Compare";
6+
import UsageDashboard from "./pages/UsageDashboard";
67
import { ThemeProvider } from "@/components/theme-provider";
78
import { SessionsProvider } from "@/lib/sessions-context";
89
import { I18nProvider } from "@/lib/i18n";
@@ -16,6 +17,7 @@ const router = createHashRouter([
1617
{ path: "chat/:sessionId", element: <Chat /> },
1718
{ path: "settings", element: <Settings /> },
1819
{ path: "compare", element: <Compare /> },
20+
{ path: "usage", element: <UsageDashboard /> },
1921
],
2022
},
2123
]);

‎frontend/src/components/command-palette.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { FolderOpen, MessageSquare, Plus, Settings as SettingsIcon, Scale } from "lucide-react";
1+
import { FolderOpen, MessageSquare, Plus, Settings as SettingsIcon, Scale, BarChart3 } from "lucide-react";
22
import {
33
CommandDialog,
44
CommandEmpty,
@@ -18,6 +18,7 @@ export function CommandPalette({
1818
onOpenSession,
1919
onNavigateSettings,
2020
onNavigateCompare,
21+
onNavigateUsage,
2122
}: {
2223
open: boolean;
2324
onOpenChange: (open: boolean) => void;
@@ -27,6 +28,7 @@ export function CommandPalette({
2728
onOpenSession: (id: string) => void;
2829
onNavigateSettings: () => void;
2930
onNavigateCompare: () => void;
31+
onNavigateUsage: () => void;
3032
}) {
3133
function run(action: () => void) {
3234
action();
@@ -48,6 +50,9 @@ export function CommandPalette({
4850
<CommandItem onSelect={() => run(onNavigateCompare)}>
4951
<Scale /> Compare models
5052
</CommandItem>
53+
<CommandItem onSelect={() => run(onNavigateUsage)}>
54+
<BarChart3 /> Usage dashboard
55+
</CommandItem>
5156
</CommandGroup>
5257

5358
{projects.length > 0 && (

‎frontend/src/components/layout.tsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
X,
1818
Keyboard,
1919
Scale,
20+
BarChart3,
2021
} from "lucide-react";
2122
import { Button } from "@/components/ui/button";
2223
import { Input } from "@/components/ui/input";
@@ -667,6 +668,16 @@ export default function Layout() {
667668
<Scale className="size-4" />
668669
{t.compareModels}
669670
</Button>
671+
<Button
672+
onClick={() => navigate("/usage")}
673+
size="sm"
674+
variant="ghost"
675+
className="w-full justify-start gap-2 text-muted-foreground"
676+
disabled={!hasApi}
677+
>
678+
<BarChart3 className="size-4" />
679+
{t.usageDashboard}
680+
</Button>
670681
</div>
671682

672683
<div className="relative px-3 pb-2">
@@ -768,6 +779,7 @@ export default function Layout() {
768779
onOpenSession={(id) => navigate(`/chat/${id}`)}
769780
onNavigateSettings={() => navigate("/settings")}
770781
onNavigateCompare={() => navigate("/compare")}
782+
onNavigateUsage={() => navigate("/usage")}
771783
/>
772784
<KeyboardShortcutsDialog open={shortcutsOpen} onOpenChange={setShortcutsOpen} keybindings={keybindings} />
773785
<OnboardingWizard open={showOnboarding} onDone={() => setShowOnboarding(false)} />

‎frontend/src/lib/translations.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,15 @@ export interface Dictionary {
178178
shortcutNewline: string;
179179
shortcutStopGenerating: string;
180180
compareModels: string;
181+
usageDashboard: string;
182+
usageTotalCost: string;
183+
usageTotalTokens: string;
184+
usageTotalSessions: string;
185+
usageByProvider: string;
186+
usageByModel: string;
187+
usageByDay: string;
188+
usageSessions: string;
189+
usageNoData: string;
181190
compareSamePrompt: string;
182191
compareRun: string;
183192
compareRunToSee: string;
@@ -449,6 +458,15 @@ export const en: Dictionary = {
449458
shortcutNewline: "New line",
450459
shortcutStopGenerating: "Stop generating",
451460
compareModels: "Compare models",
461+
usageDashboard: "Usage dashboard",
462+
usageTotalCost: "Estimated total cost",
463+
usageTotalTokens: "Total tokens",
464+
usageTotalSessions: "Chats with usage",
465+
usageByProvider: "By provider",
466+
usageByModel: "By model",
467+
usageByDay: "Last 14 days",
468+
usageSessions: "chats",
469+
usageNoData: "No usage data yet — send some messages to cloud models to see cost estimates here.",
452470
compareSamePrompt: "Type a prompt to send to every selected model at once...",
453471
compareRun: "Run",
454472
compareRunToSee: "Run the comparison to see this model's response.",
@@ -728,6 +746,15 @@ export const tr: Dictionary = {
728746
shortcutNewline: "Yeni satır",
729747
shortcutStopGenerating: "Üretimi durdur",
730748
compareModels: "Modelleri karşılaştır",
749+
usageDashboard: "Kullanım paneli",
750+
usageTotalCost: "Tahmini toplam maliyet",
751+
usageTotalTokens: "Toplam token",
752+
usageTotalSessions: "Kullanımı olan sohbetler",
753+
usageByProvider: "Sağlayıcıya göre",
754+
usageByModel: "Modele göre",
755+
usageByDay: "Son 14 gün",
756+
usageSessions: "sohbet",
757+
usageNoData: "Henüz kullanım verisi yok — maliyet tahminlerini burada görmek için bulut modellere birkaç mesaj gönderin.",
731758
compareSamePrompt: "Seçili tüm modellere aynı anda gönderilecek bir prompt yazın...",
732759
compareRun: "Çalıştır",
733760
compareRunToSee: "Bu modelin yanıtını görmek için karşılaştırmayı çalıştırın.",

‎frontend/src/lib/usage.test.ts‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { describe, it, expect } from "vitest";
2+
import { summarizeSession, aggregateBy } from "./usage";
3+
import type { ChatSession } from "@/types/electron";
4+
5+
function makeSession(overrides: Partial<ChatSession> = {}): ChatSession {
6+
return {
7+
id: "1",
8+
title: "Test chat",
9+
model: "openai:gpt-4o-mini",
10+
messages: [],
11+
createdAt: "2026-01-01T00:00:00.000Z",
12+
updatedAt: "2026-01-01T00:00:00.000Z",
13+
...overrides,
14+
};
15+
}
16+
17+
describe("summarizeSession", () => {
18+
it("sums usage across messages and attributes it to the session's current model", () => {
19+
const usage = summarizeSession(
20+
makeSession({
21+
messages: [
22+
{ role: "user", content: "hi" },
23+
{ role: "assistant", content: "hello", usage: { promptTokens: 10, completionTokens: 5 } },
24+
{ role: "user", content: "more" },
25+
{ role: "assistant", content: "reply", usage: { promptTokens: 20, completionTokens: 8 } },
26+
],
27+
})
28+
);
29+
30+
expect(usage.provider).toBe("openai");
31+
expect(usage.modelId).toBe("gpt-4o-mini");
32+
expect(usage.promptTokens).toBe(30);
33+
expect(usage.completionTokens).toBe(13);
34+
expect(usage.cost).not.toBeNull();
35+
});
36+
37+
it("returns null cost for a model with no known pricing", () => {
38+
const usage = summarizeSession(
39+
makeSession({
40+
model: "ollama:llama3.2",
41+
messages: [{ role: "assistant", content: "hi", usage: { promptTokens: 10, completionTokens: 5 } }],
42+
})
43+
);
44+
expect(usage.cost).toBeNull();
45+
expect(usage.promptTokens).toBe(10);
46+
});
47+
48+
it("returns zero usage for a session with no usage data", () => {
49+
const usage = summarizeSession(makeSession({ messages: [{ role: "user", content: "hi" }] }));
50+
expect(usage.promptTokens).toBe(0);
51+
expect(usage.completionTokens).toBe(0);
52+
});
53+
});
54+
55+
describe("aggregateBy", () => {
56+
it("groups and sums tokens/cost/session count by the given key", () => {
57+
const usages = [
58+
summarizeSession(
59+
makeSession({
60+
id: "a",
61+
model: "openai:gpt-4o-mini",
62+
messages: [{ role: "assistant", content: "x", usage: { promptTokens: 10, completionTokens: 5 } }],
63+
})
64+
),
65+
summarizeSession(
66+
makeSession({
67+
id: "b",
68+
model: "openai:gpt-4o-mini",
69+
messages: [{ role: "assistant", content: "y", usage: { promptTokens: 20, completionTokens: 10 } }],
70+
})
71+
),
72+
summarizeSession(
73+
makeSession({
74+
id: "c",
75+
model: "anthropic:claude-sonnet-5",
76+
messages: [{ role: "assistant", content: "z", usage: { promptTokens: 5, completionTokens: 5 } }],
77+
})
78+
),
79+
];
80+
81+
const byModel = aggregateBy(usages, (u) => u.modelId ?? "unknown");
82+
const gpt4oMini = byModel.find(([key]) => key === "gpt-4o-mini");
83+
expect(gpt4oMini?.[1].sessions).toBe(2);
84+
expect(gpt4oMini?.[1].tokens).toBe(45);
85+
});
86+
});

‎frontend/src/lib/usage.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { parseModelRef } from "@/lib/providers";
2+
import { estimateCost } from "@/lib/pricing";
3+
import type { ChatSession, ProviderId } from "@/types/electron";
4+
5+
export interface SessionUsage {
6+
session: ChatSession;
7+
provider: ProviderId | null;
8+
modelId: string | null;
9+
promptTokens: number;
10+
completionTokens: number;
11+
cost: number | null;
12+
}
13+
14+
export interface UsageAggregate {
15+
tokens: number;
16+
cost: number;
17+
sessions: number;
18+
}
19+
20+
// Usage is aggregated per session, not per message — sessions only store a
21+
// single `model` field (the app doesn't track which model produced any given
22+
// message if the user switched mid-conversation), so a session's entire
23+
// token usage is attributed to whatever model it's currently set to. This
24+
// matches the same simplification the per-message cost display already uses
25+
// in Chat.tsx.
26+
export function summarizeSession(session: ChatSession): SessionUsage {
27+
let promptTokens = 0;
28+
let completionTokens = 0;
29+
for (const m of session.messages) {
30+
if (m.usage) {
31+
promptTokens += m.usage.promptTokens ?? 0;
32+
completionTokens += m.usage.completionTokens ?? 0;
33+
}
34+
}
35+
const parsed = session.model ? parseModelRef(session.model) : null;
36+
const cost = parsed ? estimateCost(parsed.modelId, promptTokens, completionTokens) : null;
37+
return {
38+
session,
39+
provider: parsed?.provider ?? null,
40+
modelId: parsed?.modelId ?? null,
41+
promptTokens,
42+
completionTokens,
43+
cost,
44+
};
45+
}
46+
47+
export function aggregateBy(usages: SessionUsage[], keyOf: (u: SessionUsage) => string): [string, UsageAggregate][] {
48+
const map = new Map<string, UsageAggregate>();
49+
for (const u of usages) {
50+
const key = keyOf(u);
51+
const entry = map.get(key) ?? { tokens: 0, cost: 0, sessions: 0 };
52+
entry.tokens += u.promptTokens + u.completionTokens;
53+
entry.cost += u.cost ?? 0;
54+
entry.sessions += 1;
55+
map.set(key, entry);
56+
}
57+
return [...map.entries()].sort((a, b) => b[1].cost - a[1].cost);
58+
}

0 commit comments

Comments
 (0)