diff --git a/src/main/lib/trpc/routers/index.ts b/src/main/lib/trpc/routers/index.ts index b98b18264..cfc0441e7 100644 --- a/src/main/lib/trpc/routers/index.ts +++ b/src/main/lib/trpc/routers/index.ts @@ -18,6 +18,7 @@ import { sandboxImportRouter } from "./sandbox-import" import { commandsRouter } from "./commands" import { voiceRouter } from "./voice" import { pluginsRouter } from "./plugins" +import { usageRouter } from "./usage" import { createGitRouter } from "../../git" import { BrowserWindow } from "electron" @@ -46,6 +47,7 @@ export function createAppRouter(getWindow: () => BrowserWindow | null) { commands: commandsRouter, voice: voiceRouter, plugins: pluginsRouter, + usage: usageRouter, // Git operations - named "changes" to match Superset API changes: createGitRouter(), }) diff --git a/src/main/lib/trpc/routers/usage.ts b/src/main/lib/trpc/routers/usage.ts new file mode 100644 index 000000000..3dae49eaf --- /dev/null +++ b/src/main/lib/trpc/routers/usage.ts @@ -0,0 +1,66 @@ +import { z } from "zod" +import { publicProcedure, router } from "../index" +import { readClaudeUsage } from "../../usage/claude-reader" +import { readCodexUsage } from "../../usage/codex-reader" +import { aggregate } from "../../usage/aggregator" +import type { UsageEntry } from "../../usage/types" + +const periodSchema = z.enum(["7d", "30d", "90d", "all"]) +const sourceSchema = z.enum(["claude", "codex", "all"]) + +/** + * In-memory cache of raw entries keyed by source. Re-reading JSONLs every + * query is fast (<200ms) but still wasteful — a 15s cache keeps the Usage + * page responsive when the user toggles period / source, while staying + * fresh enough that a just-finished session shows up on the next focus. + */ +type Cached = { entries: UsageEntry[]; fetchedAt: number } +const CACHE_TTL_MS = 15_000 +const cache: { + claude: Cached | null + codex: Cached | null +} = { claude: null, codex: null } + +async function getEntries(source: "claude" | "codex"): Promise { + const now = Date.now() + const cached = cache[source] + if (cached && now - cached.fetchedAt < CACHE_TTL_MS) { + return cached.entries + } + const entries = source === "claude" ? await readClaudeUsage() : await readCodexUsage() + cache[source] = { entries, fetchedAt: now } + return entries +} + +function invalidate(): void { + cache.claude = null + cache.codex = null +} + +export const usageRouter = router({ + /** + * Aggregated stats for the period + source. The heavy lifting (glob, + * parse, dedup, price) happens here on each call; the client just + * re-queries when the user toggles inputs. + */ + getOverview: publicProcedure + .input(z.object({ period: periodSchema, source: sourceSchema })) + .query(async ({ input }) => { + const tasks: Promise[] = [] + if (input.source === "claude" || input.source === "all") { + tasks.push(getEntries("claude")) + } + if (input.source === "codex" || input.source === "all") { + tasks.push(getEntries("codex")) + } + const pools = await Promise.all(tasks) + const merged = pools.flat() + return aggregate(merged, input.period, input.source) + }), + + /** Force the next query to re-read JSONLs from disk. */ + refresh: publicProcedure.mutation(() => { + invalidate() + return { ok: true } + }), +}) diff --git a/src/main/lib/usage/aggregator.ts b/src/main/lib/usage/aggregator.ts new file mode 100644 index 000000000..90757dcac --- /dev/null +++ b/src/main/lib/usage/aggregator.ts @@ -0,0 +1,229 @@ +import { costForTokens, displayNameFor, priceFor } from "./pricing" +import type { UsageEntry, UsagePeriod, UsageSourceFilter } from "./types" + +export type UsageTotals = { + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number + totalTokens: number + costUSD: number + /** Number of entries that couldn't be priced (unknown model). */ + unpricedEntries: number + /** Model ids seen but missing from the pricing table. */ + unpricedModels: string[] +} + +export type DailyBucket = { + /** Local-date ISO string, YYYY-MM-DD. */ + date: string + costUSD: number + totalTokens: number +} + +export type ModelBreakdown = { + /** Raw model id. */ + model: string + /** Friendly name from pricing table, or the raw id when unknown. */ + displayName: string + provider: "claude" | "codex" | "unknown" + totalTokens: number + costUSD: number + priced: boolean +} + +export type HeatmapCell = { + /** Local-date ISO string, YYYY-MM-DD. */ + date: string + /** 0 = Monday ... 6 = Sunday. Matches the layout in the screenshots. */ + dayOfWeek: number + /** Zero-based column index (0 = oldest week in range). */ + weekIndex: number + totalTokens: number +} + +export type UsageOverview = { + totals: UsageTotals + daily: DailyBucket[] + heatmap: HeatmapCell[] + models: ModelBreakdown[] + /** Range actually covered by the data (for labeling). */ + rangeStart: string + rangeEnd: string + /** Number of entries considered after dedup + filter. */ + entryCount: number +} + +function periodStart(period: UsagePeriod, now: number): number | null { + if (period === "all") return null + const days = period === "7d" ? 7 : period === "30d" ? 30 : 90 + return now - days * 24 * 60 * 60 * 1000 +} + +function filterBySource(entries: UsageEntry[], source: UsageSourceFilter): UsageEntry[] { + if (source === "all") return entries + return entries.filter((e) => e.source === source) +} + +function dedup(entries: UsageEntry[]): UsageEntry[] { + const seen = new Set() + const out: UsageEntry[] = [] + for (const e of entries) { + if (!e.dedupKey) { + out.push(e) + continue + } + if (seen.has(e.dedupKey)) continue + seen.add(e.dedupKey) + out.push(e) + } + return out +} + +/** YYYY-MM-DD in the local timezone. */ +function localDateKey(ts: number): string { + const d = new Date(ts) + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, "0") + const day = String(d.getDate()).padStart(2, "0") + return `${y}-${m}-${day}` +} + +/** Monday-indexed day of week (0=Mon ... 6=Sun) to match the screenshots. */ +function mondayDayOfWeek(ts: number): number { + const d = new Date(ts).getDay() // 0=Sun..6=Sat + return (d + 6) % 7 +} + +function costForEntry(entry: UsageEntry): { cost: number | null } { + if (typeof entry.costUSD === "number" && entry.costUSD > 0) { + return { cost: entry.costUSD } + } + return { + cost: costForTokens(entry.model, { + input: entry.inputTokens, + output: entry.outputTokens, + cacheWrite: entry.cacheCreationTokens, + cacheRead: entry.cacheReadTokens, + }), + } +} + +/** + * Reduce a list of entries into the overview payload the UI needs. + * Entries are expected post-source-filter. Dedup is applied here so callers + * can freely concatenate Claude + Codex readers without double-counting. + */ +export function aggregate( + rawEntries: UsageEntry[], + period: UsagePeriod, + source: UsageSourceFilter, + nowMs: number = Date.now(), +): UsageOverview { + const start = periodStart(period, nowMs) + const windowed = start === null ? rawEntries : rawEntries.filter((e) => e.ts >= start) + const scoped = filterBySource(windowed, source) + const deduped = dedup(scoped) + + const totals: UsageTotals = { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + costUSD: 0, + unpricedEntries: 0, + unpricedModels: [], + } + const unpriced = new Set() + const dailyMap = new Map() + const modelMap = new Map() + let earliest = Infinity + let latest = -Infinity + + for (const e of deduped) { + earliest = Math.min(earliest, e.ts) + latest = Math.max(latest, e.ts) + + totals.inputTokens += e.inputTokens + totals.outputTokens += e.outputTokens + totals.cacheReadTokens += e.cacheReadTokens + totals.cacheWriteTokens += e.cacheCreationTokens + + const entryTokens = e.inputTokens + e.outputTokens + e.cacheReadTokens + e.cacheCreationTokens + totals.totalTokens += entryTokens + + const { cost } = costForEntry(e) + if (cost === null) { + totals.unpricedEntries += 1 + unpriced.add(e.model) + } else { + totals.costUSD += cost + } + + const dayKey = localDateKey(e.ts) + const bucket = dailyMap.get(dayKey) ?? { date: dayKey, costUSD: 0, totalTokens: 0 } + bucket.costUSD += cost ?? 0 + bucket.totalTokens += entryTokens + dailyMap.set(dayKey, bucket) + + const pricing = priceFor(e.model) + const modelKey = e.model + const existing = modelMap.get(modelKey) ?? { + model: modelKey, + displayName: displayNameFor(modelKey), + provider: pricing?.provider ?? "unknown", + totalTokens: 0, + costUSD: 0, + priced: pricing !== null, + } + existing.totalTokens += entryTokens + existing.costUSD += cost ?? 0 + modelMap.set(modelKey, existing) + } + + totals.unpricedModels = Array.from(unpriced).sort() + + // Build full daily series (fill gaps with zero) over the visible range. + const rangeEndDate = new Date(nowMs) + const rangeStartDate = start !== null ? new Date(start) : new Date(earliest === Infinity ? nowMs : earliest) + const daily: DailyBucket[] = [] + const cursor = new Date(rangeStartDate) + cursor.setHours(0, 0, 0, 0) + const endCursor = new Date(rangeEndDate) + endCursor.setHours(0, 0, 0, 0) + while (cursor.getTime() <= endCursor.getTime()) { + const key = localDateKey(cursor.getTime()) + daily.push(dailyMap.get(key) ?? { date: key, costUSD: 0, totalTokens: 0 }) + cursor.setDate(cursor.getDate() + 1) + } + + // Build heatmap grid aligned to weeks. Column 0 = week containing rangeStartDate. + const heatmap: HeatmapCell[] = [] + const weekAnchor = new Date(rangeStartDate) + weekAnchor.setHours(0, 0, 0, 0) + // Align anchor to the Monday of that week. + weekAnchor.setDate(weekAnchor.getDate() - mondayDayOfWeek(weekAnchor.getTime())) + for (const bucket of daily) { + const dayTs = Date.parse(`${bucket.date}T00:00:00`) + const weekIndex = Math.floor((dayTs - weekAnchor.getTime()) / (7 * 24 * 60 * 60 * 1000)) + heatmap.push({ + date: bucket.date, + dayOfWeek: mondayDayOfWeek(dayTs), + weekIndex: Math.max(0, weekIndex), + totalTokens: bucket.totalTokens, + }) + } + + const models = Array.from(modelMap.values()).sort((a, b) => b.totalTokens - a.totalTokens) + + return { + totals, + daily, + heatmap, + models, + rangeStart: localDateKey(rangeStartDate.getTime()), + rangeEnd: localDateKey(rangeEndDate.getTime()), + entryCount: deduped.length, + } +} diff --git a/src/main/lib/usage/claude-reader.ts b/src/main/lib/usage/claude-reader.ts new file mode 100644 index 000000000..8b1f06caa --- /dev/null +++ b/src/main/lib/usage/claude-reader.ts @@ -0,0 +1,132 @@ +import type { Dirent } from "node:fs" +import { readdir, readFile, stat } from "node:fs/promises" +import { homedir } from "node:os" +import { join } from "node:path" +import type { UsageEntry } from "./types" + +/** + * Root directory Claude Code writes session JSONLs to. + * Honors CLAUDE_CONFIG_DIR (may be colon-separated for multi-root installs), + * matching ccusage's resolution order. + */ +function claudeProjectRoots(): string[] { + const envDir = process.env.CLAUDE_CONFIG_DIR + if (envDir && envDir.trim().length > 0) { + return envDir + .split(":") + .map((d) => d.trim()) + .filter(Boolean) + .map((d) => join(d, "projects")) + } + return [join(homedir(), ".claude", "projects")] +} + +async function walkJsonlFiles(dir: string, out: string[]): Promise { + let entries: Dirent[] + try { + entries = (await readdir(dir, { withFileTypes: true, encoding: "utf8" })) as Dirent[] + } catch { + return + } + for (const entry of entries) { + const name = entry.name as string + const full = join(dir, name) + if (entry.isDirectory()) { + await walkJsonlFiles(full, out) + } else if (entry.isFile() && name.endsWith(".jsonl")) { + out.push(full) + } + } +} + +type ClaudeRecord = { + type?: string + timestamp?: string + requestId?: string + message?: { + id?: string + model?: string + usage?: { + input_tokens?: number + output_tokens?: number + cache_creation_input_tokens?: number + cache_read_input_tokens?: number + } + } + costUSD?: number +} + +function parseLine(line: string): ClaudeRecord | null { + if (!line || line[0] !== "{") return null + try { + return JSON.parse(line) as ClaudeRecord + } catch { + return null + } +} + +function toEntry(rec: ClaudeRecord): UsageEntry | null { + if (rec.type !== "assistant") return null + const u = rec.message?.usage + if (!u) return null + const model = rec.message?.model + if (!model) return null + const ts = rec.timestamp ? Date.parse(rec.timestamp) : NaN + if (!Number.isFinite(ts)) return null + const messageId = rec.message?.id ?? "" + const requestId = rec.requestId ?? "" + const dedupKey = messageId && requestId ? `${messageId}:${requestId}` : null + return { + ts, + model, + source: "claude", + inputTokens: u.input_tokens ?? 0, + outputTokens: u.output_tokens ?? 0, + cacheCreationTokens: u.cache_creation_input_tokens ?? 0, + cacheReadTokens: u.cache_read_input_tokens ?? 0, + dedupKey, + costUSD: typeof rec.costUSD === "number" ? rec.costUSD : null, + } +} + +/** + * Read all Claude Code session JSONLs and return normalized entries. + * Files newer than `sinceMs` are fully scanned; older ones are skipped by + * mtime to keep the scan cheap even across many months of transcripts. + */ +export async function readClaudeUsage(sinceMs: number | null = null): Promise { + const roots = claudeProjectRoots() + const files: string[] = [] + for (const root of roots) { + await walkJsonlFiles(root, files) + } + + const entries: UsageEntry[] = [] + await Promise.all( + files.map(async (file) => { + if (sinceMs !== null) { + try { + const st = await stat(file) + if (st.mtimeMs < sinceMs) return + } catch { + return + } + } + let raw: string + try { + raw = await readFile(file, "utf8") + } catch { + return + } + for (const line of raw.split("\n")) { + const rec = parseLine(line) + if (!rec) continue + const entry = toEntry(rec) + if (!entry) continue + if (sinceMs !== null && entry.ts < sinceMs) continue + entries.push(entry) + } + }), + ) + return entries +} diff --git a/src/main/lib/usage/codex-reader.ts b/src/main/lib/usage/codex-reader.ts new file mode 100644 index 000000000..45c88bed7 --- /dev/null +++ b/src/main/lib/usage/codex-reader.ts @@ -0,0 +1,146 @@ +import type { Dirent } from "node:fs" +import { readdir, readFile, stat } from "node:fs/promises" +import { homedir } from "node:os" +import { join } from "node:path" +import type { UsageEntry } from "./types" + +function codexSessionsRoot(): string { + // Codex CLI does not advertise a CODEX_CONFIG_DIR override today; hardcode + // the default but keep it centralized so a future override is a one-liner. + return join(homedir(), ".codex", "sessions") +} + +async function walkJsonlFiles(dir: string, out: string[]): Promise { + let entries: Dirent[] + try { + entries = (await readdir(dir, { withFileTypes: true, encoding: "utf8" })) as Dirent[] + } catch { + return + } + for (const entry of entries) { + const name = entry.name as string + const full = join(dir, name) + if (entry.isDirectory()) { + await walkJsonlFiles(full, out) + } else if ( + entry.isFile() && + name.startsWith("rollout-") && + name.endsWith(".jsonl") + ) { + out.push(full) + } + } +} + +type CodexRecord = { + type?: string + timestamp?: string + payload?: { + type?: string + model?: string + info?: { + last_token_usage?: { + input_tokens?: number + cached_input_tokens?: number + output_tokens?: number + total_tokens?: number + } + } + } +} + +function parseLine(line: string): CodexRecord | null { + if (!line || line[0] !== "{") return null + try { + return JSON.parse(line) as CodexRecord + } catch { + return null + } +} + +/** + * Scan one Codex session file. + * + * Codex CLI writes a `session_meta` line at the top, then `turn_context` + * (which carries the model), then an `event_msg` of payload-type `token_count` + * after each model response. The token_count payload carries + * `info.last_token_usage` — interpreted here as the usage for the response + * that just finished, so summing across events gives the session total. + * + * `input_tokens` in Codex INCLUDES cached tokens (unlike Anthropic), so we + * subtract `cached_input_tokens` to land on a comparable "true new input" + * bucket. The cached portion goes into `cacheReadTokens`. + */ +async function readSession(file: string, sinceMs: number | null): Promise { + let raw: string + try { + raw = await readFile(file, "utf8") + } catch { + return [] + } + let currentModel: string | null = null + const out: UsageEntry[] = [] + let tokenEventIndex = 0 + + for (const line of raw.split("\n")) { + const rec = parseLine(line) + if (!rec) continue + + if (rec.type === "turn_context" && rec.payload?.model) { + currentModel = rec.payload.model + continue + } + if (rec.type === "session_meta" && rec.payload?.model && !currentModel) { + currentModel = rec.payload.model + continue + } + + if (rec.type !== "event_msg" || rec.payload?.type !== "token_count") continue + const usage = rec.payload?.info?.last_token_usage + if (!usage) continue + + const ts = rec.timestamp ? Date.parse(rec.timestamp) : NaN + if (!Number.isFinite(ts)) continue + if (sinceMs !== null && ts < sinceMs) continue + + const inputWithCached = usage.input_tokens ?? 0 + const cached = usage.cached_input_tokens ?? 0 + const inputUncached = Math.max(0, inputWithCached - cached) + const output = usage.output_tokens ?? 0 + if (inputUncached === 0 && output === 0 && cached === 0) continue + + out.push({ + ts, + model: currentModel ?? "gpt-unknown", + source: "codex", + inputTokens: inputUncached, + outputTokens: output, + cacheCreationTokens: 0, + cacheReadTokens: cached, + dedupKey: `${file}:${tokenEventIndex}`, + costUSD: null, + }) + tokenEventIndex += 1 + } + return out +} + +export async function readCodexUsage(sinceMs: number | null = null): Promise { + const files: string[] = [] + await walkJsonlFiles(codexSessionsRoot(), files) + + const results = await Promise.all( + files.map(async (file) => { + if (sinceMs !== null) { + try { + const st = await stat(file) + if (st.mtimeMs < sinceMs) return [] + } catch { + return [] + } + } + return readSession(file, sinceMs) + }), + ) + return results.flat() +} diff --git a/src/main/lib/usage/pricing.ts b/src/main/lib/usage/pricing.ts new file mode 100644 index 000000000..3e8ed91fe --- /dev/null +++ b/src/main/lib/usage/pricing.ts @@ -0,0 +1,112 @@ +/** + * Bundled model pricing snapshot (USD per 1M tokens). + * Snapshotted from https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json + * on 2026-04-17. Update this file when new models ship. + * + * Matching is prefix-based so that dated variants like "claude-opus-4-6-20250929" + * resolve to the base model entry. + */ + +export type ModelRates = { + /** USD per 1M input tokens. */ + input: number + /** USD per 1M output tokens. */ + output: number + /** USD per 1M tokens written to the 5-minute ephemeral cache (Anthropic). */ + cacheWrite?: number + /** USD per 1M tokens read from cache (both providers). */ + cacheRead?: number +} + +type PricingEntry = { + /** Display name shown in the UI. */ + displayName: string + /** Provider bucket for grouping + the source toggle. */ + provider: "claude" | "codex" + rates: ModelRates +} + +/** + * Ordered list of (prefix, entry) pairs. Longest-prefix-wins during lookup — + * the ordering here is the tie-breaker when two prefixes overlap (e.g., + * "claude-opus-4-6" should win over "claude-opus-4"). + */ +const PRICING_TABLE: ReadonlyArray = [ + // Claude — most specific first + ["claude-opus-4-7", { displayName: "Opus 4.7", provider: "claude", rates: { input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 } }], + ["claude-opus-4-6", { displayName: "Opus 4.6", provider: "claude", rates: { input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 } }], + ["claude-opus-4-5", { displayName: "Opus 4.5", provider: "claude", rates: { input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 } }], + ["claude-opus-4-1", { displayName: "Opus 4.1", provider: "claude", rates: { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 } }], + ["claude-opus-4", { displayName: "Opus 4", provider: "claude", rates: { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 } }], + ["claude-sonnet-4-6", { displayName: "Sonnet 4.6", provider: "claude", rates: { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 } }], + ["claude-sonnet-4-5", { displayName: "Sonnet 4.5", provider: "claude", rates: { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 } }], + ["claude-sonnet-4", { displayName: "Sonnet 4", provider: "claude", rates: { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 } }], + ["claude-haiku-4-5", { displayName: "Haiku 4.5", provider: "claude", rates: { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 } }], + ["claude-haiku-4", { displayName: "Haiku 4", provider: "claude", rates: { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 } }], + ["claude-3-7-sonnet", { displayName: "Sonnet 3.7", provider: "claude", rates: { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 } }], + ["claude-3-5-sonnet", { displayName: "Sonnet 3.5", provider: "claude", rates: { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 } }], + ["claude-3-5-haiku", { displayName: "Haiku 3.5", provider: "claude", rates: { input: 0.8, output: 4, cacheWrite: 1, cacheRead: 0.08 } }], + ["claude-3-opus", { displayName: "Opus 3", provider: "claude", rates: { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 } }], + ["claude-3-haiku", { displayName: "Haiku 3", provider: "claude", rates: { input: 0.25, output: 1.25, cacheWrite: 0.3, cacheRead: 0.03 } }], + + // Codex / OpenAI — Codex CLI reports `cached_input_tokens` (no cache-write distinction) + ["gpt-5.4-mini", { displayName: "GPT-5.4 mini", provider: "codex", rates: { input: 0.75, output: 4.5, cacheRead: 0.075 } }], + ["gpt-5.4", { displayName: "GPT-5.4", provider: "codex", rates: { input: 2.5, output: 15, cacheRead: 0.25 } }], + ["gpt-5.3-codex", { displayName: "GPT-5.3 Codex",provider: "codex", rates: { input: 1.75, output: 14, cacheRead: 0.175 } }], + ["gpt-5.2-codex", { displayName: "GPT-5.2 Codex",provider: "codex", rates: { input: 1.75, output: 14, cacheRead: 0.175 } }], + ["gpt-5-codex", { displayName: "GPT-5 Codex", provider: "codex", rates: { input: 1.25, output: 10 } }], + ["gpt-5-mini", { displayName: "GPT-5 mini", provider: "codex", rates: { input: 0.25, output: 2, cacheRead: 0.025 } }], + ["gpt-5", { displayName: "GPT-5", provider: "codex", rates: { input: 1.25, output: 10, cacheRead: 0.125 } }], + ["gpt-4.1-mini", { displayName: "GPT-4.1 mini", provider: "codex", rates: { input: 0.4, output: 1.6, cacheRead: 0.1 } }], + ["gpt-4.1", { displayName: "GPT-4.1", provider: "codex", rates: { input: 2, output: 8, cacheRead: 0.5 } }], + ["o4-mini", { displayName: "o4-mini", provider: "codex", rates: { input: 1.1, output: 4.4, cacheRead: 0.275 } }], + ["o3-mini", { displayName: "o3-mini", provider: "codex", rates: { input: 1.1, output: 4.4, cacheRead: 0.55 } }], + ["o3", { displayName: "o3", provider: "codex", rates: { input: 2, output: 8, cacheRead: 0.5 } }], + ["o1-mini", { displayName: "o1-mini", provider: "codex", rates: { input: 1.1, output: 4.4, cacheRead: 0.55 } }], + ["o1", { displayName: "o1", provider: "codex", rates: { input: 15, output: 60, cacheRead: 7.5 } }], +] + +/** + * Look up rates + display info for a model name. + * Matches the longest prefix in PRICING_TABLE. Returns null for unknown models + * so callers can surface "unpriced" instead of silently charging $0. + */ +export function priceFor(model: string | undefined | null): PricingEntry | null { + if (!model) return null + const normalized = model.toLowerCase() + let best: PricingEntry | null = null + let bestLen = 0 + for (const [prefix, entry] of PRICING_TABLE) { + if (normalized.startsWith(prefix) && prefix.length > bestLen) { + best = entry + bestLen = prefix.length + } + } + return best +} + +/** + * Compute cost in USD given a token bucket and a model name. + * Returns null when the model is unpriced — caller decides how to surface. + */ +export function costForTokens( + model: string | undefined | null, + tokens: { input: number; output: number; cacheWrite: number; cacheRead: number }, +): number | null { + const entry = priceFor(model) + if (!entry) return null + const r = entry.rates + const perMillion = 1_000_000 + return ( + (tokens.input * r.input) / perMillion + + (tokens.output * r.output) / perMillion + + (tokens.cacheWrite * (r.cacheWrite ?? 0)) / perMillion + + (tokens.cacheRead * (r.cacheRead ?? 0)) / perMillion + ) +} + +/** Resolve a display name, falling back to the raw id when unknown. */ +export function displayNameFor(model: string | undefined | null): string { + const entry = priceFor(model) + return entry?.displayName ?? (model ?? "unknown") +} diff --git a/src/main/lib/usage/types.ts b/src/main/lib/usage/types.ts new file mode 100644 index 000000000..bda879df1 --- /dev/null +++ b/src/main/lib/usage/types.ts @@ -0,0 +1,31 @@ +/** + * Normalized usage entry produced by each reader. + * All token fields are absolute counts (not deltas). `source` tells the + * aggregator which provider the entry came from so the UI can filter by it. + */ +export type UsageSource = "claude" | "codex" + +export type UsageEntry = { + /** Wall-clock timestamp of the record (ms since epoch). */ + ts: number + /** Raw model id from the provider (e.g., "claude-opus-4-6", "gpt-5-codex"). */ + model: string + source: UsageSource + inputTokens: number + outputTokens: number + /** Cache-creation tokens (Anthropic only; 0 for Codex). */ + cacheCreationTokens: number + /** Cache-read tokens (both providers). */ + cacheReadTokens: number + /** Stable id used for dedup: `${messageId}:${requestId}`. Claude-only in practice. */ + dedupKey: string | null + /** + * Cost pre-computed by the provider, when available. + * Anthropic Claude Code writes this on some assistant messages. Prefer it + * when present so totals line up with Anthropic's own billing numbers. + */ + costUSD: number | null +} + +export type UsagePeriod = "7d" | "30d" | "90d" | "all" +export type UsageSourceFilter = UsageSource | "all" diff --git a/src/renderer/features/agents/atoms/index.ts b/src/renderer/features/agents/atoms/index.ts index e64bb43e9..00a03cbee 100644 --- a/src/renderer/features/agents/atoms/index.ts +++ b/src/renderer/features/agents/atoms/index.ts @@ -1146,9 +1146,15 @@ export const showMessageJsonAtom = atomWithStorage( // Desktop view mode - takes priority over chat-based rendering // null = default behavior (chat/new-chat/kanban) -export type DesktopView = "automations" | "automations-detail" | "inbox" | "settings" | null +export type DesktopView = "automations" | "automations-detail" | "inbox" | "settings" | "usage" | null export const desktopViewAtom = atom(null) +// Usage page — persisted user preferences +export type UsagePeriod = "7d" | "30d" | "90d" | "all" +export type UsageSourceFilter = "claude" | "codex" | "all" +export const usagePeriodAtom = atomWithStorage("usage-period", "30d") +export const usageSourceAtom = atomWithStorage("usage-source", "all") + // Which automation is being viewed/edited (ID or "new" for creation) export const automationDetailIdAtom = atom(null) diff --git a/src/renderer/features/agents/ui/agents-content.tsx b/src/renderer/features/agents/ui/agents-content.tsx index f417a2e4c..d2b132f13 100644 --- a/src/renderer/features/agents/ui/agents-content.tsx +++ b/src/renderer/features/agents/ui/agents-content.tsx @@ -69,6 +69,7 @@ import { SubChatsQuickSwitchDialog } from "../components/subchats-quick-switch-d import { isDesktopApp } from "../../../lib/utils/platform" import { remoteTrpc } from "../../../lib/remote-trpc" import { SettingsContent } from "../../settings/settings-content" +import { UsageContent } from "../../usage/usage-content" // Desktop mock const useIsAdmin = () => false @@ -863,6 +864,8 @@ export function AgentsContent() { {/* Mobile: Settings/Automations/Inbox fullscreen views */} {desktopView === "settings" ? ( + ) : desktopView === "usage" ? ( + ) : betaAutomationsEnabled && desktopView === "automations" ? ( ) : betaAutomationsEnabled && desktopView === "automations-detail" ? ( @@ -1002,6 +1005,8 @@ export function AgentsContent() { > {desktopView === "settings" ? ( + ) : desktopView === "usage" ? ( + ) : betaAutomationsEnabled && desktopView === "automations" ? ( ) : betaAutomationsEnabled && desktopView === "automations-detail" ? ( diff --git a/src/renderer/features/sidebar/agents-sidebar.tsx b/src/renderer/features/sidebar/agents-sidebar.tsx index feb803843..0f0f9e3d4 100644 --- a/src/renderer/features/sidebar/agents-sidebar.tsx +++ b/src/renderer/features/sidebar/agents-sidebar.tsx @@ -40,7 +40,7 @@ import { } from "../../lib/hooks/use-remote-chats" import { usePrefetchLocalChat } from "../../lib/hooks/use-prefetch-local-chat" import { ArchivePopover } from "../agents/ui/archive-popover" -import { ChevronDown, MoreHorizontal, Columns3, ArrowUpRight } from "lucide-react" +import { ChevronDown, MoreHorizontal, Columns3, ArrowUpRight, BarChart3 } from "lucide-react" import { useQuery } from "@tanstack/react-query" import { remoteTrpc } from "../../lib/remote-trpc" // import { useRouter } from "next/navigation" // Desktop doesn't use next/navigation @@ -1116,6 +1116,36 @@ const KanbanButton = memo(function KanbanButton() { ) }) +// Isolated Usage Button - navigates to the Usage statistics page +const UsageButton = memo(function UsageButton() { + const setSelectedChatId = useSetAtom(selectedAgentChatIdAtom) + const setSelectedDraftId = useSetAtom(selectedDraftIdAtom) + const setShowNewChatForm = useSetAtom(showNewChatFormAtom) + const setDesktopView = useSetAtom(desktopViewAtom) + + const handleClick = useCallback(() => { + setSelectedChatId(null) + setSelectedDraftId(null) + setShowNewChatForm(false) + setDesktopView("usage") + }, [setSelectedChatId, setSelectedDraftId, setShowNewChatForm, setDesktopView]) + + return ( + + + + + Usage + + ) +}) + // Custom SVG icons matching web's icons.tsx function SidebarInboxIcon(props: React.SVGProps) { return ( @@ -3461,6 +3491,9 @@ export function AgentsSidebar({ {/* Archive Button - isolated component to prevent sidebar re-renders */} + + {/* Usage Button - opens the Usage statistics page */} +
diff --git a/src/renderer/features/usage/components/activity-heatmap.tsx b/src/renderer/features/usage/components/activity-heatmap.tsx new file mode 100644 index 000000000..2d9c48f79 --- /dev/null +++ b/src/renderer/features/usage/components/activity-heatmap.tsx @@ -0,0 +1,100 @@ +import { useMemo } from "react" +import { cn } from "../../../lib/utils" +import { formatCompact, formatShortDate } from "../lib/format" + +export type HeatmapCell = { + date: string + dayOfWeek: number + weekIndex: number + totalTokens: number +} + +type Props = { + cells: HeatmapCell[] + className?: string +} + +const DAY_LABELS = ["Mon", "", "Wed", "", "Fri", "", ""] +const CELL = 12 +const GAP = 3 + +function bucketFor(value: number, thresholds: number[]): number { + if (value <= 0) return 0 + for (let i = 0; i < thresholds.length; i += 1) { + if (value <= thresholds[i]!) return i + 1 + } + return thresholds.length +} + +export function ActivityHeatmap({ cells, className }: Props) { + const { weekCount, thresholds } = useMemo(() => { + const maxWeek = cells.reduce((m, c) => Math.max(m, c.weekIndex), 0) + const values = cells.map((c) => c.totalTokens).filter((v) => v > 0).sort((a, b) => a - b) + // 4 thresholds ≈ quartiles, so we end up with 5 intensity levels including 0. + const t: number[] = [] + if (values.length > 0) { + for (const q of [0.25, 0.5, 0.75, 0.95]) { + const idx = Math.min(values.length - 1, Math.floor(values.length * q)) + t.push(values[idx]!) + } + } + return { weekCount: maxWeek + 1, thresholds: t } + }, [cells]) + + const width = weekCount * (CELL + GAP) + 24 + const height = 7 * (CELL + GAP) + 20 + + return ( +
+
Activity
+
+ + {DAY_LABELS.map((label, i) => ( + + {label} + + ))} + {cells.map((c) => { + const level = bucketFor(c.totalTokens, thresholds) + const x = 24 + c.weekIndex * (CELL + GAP) + const y = c.dayOfWeek * (CELL + GAP) + const opacity = level === 0 ? 0.08 : 0.2 + level * 0.2 + return ( + + + {formatShortDate(c.date)} — {formatCompact(c.totalTokens)} tokens + + + ) + })} + +
+
+ Less + {[0.08, 0.3, 0.5, 0.7, 0.9].map((o, i) => ( + + ))} + More +
+
+ ) +} diff --git a/src/renderer/features/usage/components/daily-cost-chart.tsx b/src/renderer/features/usage/components/daily-cost-chart.tsx new file mode 100644 index 000000000..173397352 --- /dev/null +++ b/src/renderer/features/usage/components/daily-cost-chart.tsx @@ -0,0 +1,91 @@ +import { useMemo } from "react" +import { cn } from "../../../lib/utils" +import { formatShortDate, formatUSD } from "../lib/format" + +export type DailyBucket = { + date: string + costUSD: number + totalTokens: number +} + +type Props = { + daily: DailyBucket[] + className?: string +} + +const HEIGHT = 160 +const BAR_GAP = 2 +const BAR_MIN = 4 +const LEFT_PAD = 4 +const RIGHT_PAD = 4 + +export function DailyCostChart({ daily, className }: Props) { + const { max, tickIndices } = useMemo(() => { + let m = 0 + for (const d of daily) m = Math.max(m, d.costUSD) + // Tick positions: first, last, and every ~7th in between. + const ticks: number[] = [] + if (daily.length > 0) { + ticks.push(0) + for (let i = 7; i < daily.length - 1; i += 7) ticks.push(i) + if (daily.length > 1) ticks.push(daily.length - 1) + } + return { max: m, tickIndices: ticks } + }, [daily]) + + const barWidth = 10 + const innerWidth = LEFT_PAD + RIGHT_PAD + daily.length * (barWidth + BAR_GAP) + + return ( +
+
Daily Cost
+
+ + {daily.map((d, i) => { + const h = max > 0 ? Math.max(BAR_MIN, (d.costUSD / max) * HEIGHT) : 0 + const x = LEFT_PAD + i * (barWidth + BAR_GAP) + const y = HEIGHT - h + return ( + 0 ? 0.9 : 0.1} + > + + {formatShortDate(d.date)} — {formatUSD(d.costUSD)} + + + ) + })} + {tickIndices.map((i) => { + const d = daily[i] + if (!d) return null + const x = LEFT_PAD + i * (barWidth + BAR_GAP) + barWidth / 2 + return ( + + {formatShortDate(d.date)} + + ) + })} + +
+
+ ) +} diff --git a/src/renderer/features/usage/components/model-breakdown.tsx b/src/renderer/features/usage/components/model-breakdown.tsx new file mode 100644 index 000000000..5ed981892 --- /dev/null +++ b/src/renderer/features/usage/components/model-breakdown.tsx @@ -0,0 +1,79 @@ +import { useMemo } from "react" +import { cn } from "../../../lib/utils" +import { formatCompact, formatUSD } from "../lib/format" + +export type ModelRow = { + model: string + displayName: string + provider: "claude" | "codex" | "unknown" + totalTokens: number + costUSD: number + priced: boolean +} + +type Props = { + rows: ModelRow[] + className?: string +} + +const PROVIDER_DOT: Record = { + claude: "bg-[#d97757]", + codex: "bg-emerald-500", + unknown: "bg-muted-foreground", +} + +export function ModelBreakdown({ rows, className }: Props) { + const maxTokens = useMemo( + () => rows.reduce((m, r) => Math.max(m, r.totalTokens), 0), + [rows], + ) + + if (rows.length === 0) { + return ( +
+ No model usage recorded in this range. +
+ ) + } + + return ( +
+
+
Model
+
Tokens
+
Cost
+
Usage
+
+ {rows.map((row) => { + const pct = maxTokens > 0 ? (row.totalTokens / maxTokens) * 100 : 0 + return ( +
+
+ + + {row.displayName} + +
+
+ {formatCompact(row.totalTokens)} +
+
+ {row.priced ? formatUSD(row.costUSD) : } +
+
+
+
+
+ ) + })} +
+ ) +} diff --git a/src/renderer/features/usage/components/segmented-toggle.tsx b/src/renderer/features/usage/components/segmented-toggle.tsx new file mode 100644 index 000000000..2bb1b7641 --- /dev/null +++ b/src/renderer/features/usage/components/segmented-toggle.tsx @@ -0,0 +1,51 @@ +import { cn } from "../../../lib/utils" + +export type SegmentedOption = { + value: T + label: string +} + +type Props = { + value: T + onChange: (next: T) => void + options: SegmentedOption[] + size?: "sm" | "md" + className?: string +} + +export function SegmentedToggle({ + value, + onChange, + options, + size = "md", + className, +}: Props) { + return ( +
+ {options.map((opt) => { + const active = opt.value === value + return ( + + ) + })} +
+ ) +} diff --git a/src/renderer/features/usage/components/stat-card.tsx b/src/renderer/features/usage/components/stat-card.tsx new file mode 100644 index 000000000..056b4d6f0 --- /dev/null +++ b/src/renderer/features/usage/components/stat-card.tsx @@ -0,0 +1,32 @@ +import { cn } from "../../../lib/utils" +import { formatCompact, formatFull } from "../lib/format" + +type StatCardProps = { + label: string + value: number + /** When true, renders a $ prefix and uses two decimals. */ + currency?: boolean + /** Override the auto-formatted value with a pre-formatted string. */ + valueOverride?: string + className?: string +} + +export function StatCard({ label, value, currency, valueOverride, className }: StatCardProps) { + const display = + valueOverride ?? + (currency ? `$${value.toFixed(2)}` : formatCompact(value)) + const title = currency ? `$${value.toFixed(2)}` : formatFull(value) + return ( +
+
{label}
+
+ {display} +
+
+ ) +} diff --git a/src/renderer/features/usage/lib/format.ts b/src/renderer/features/usage/lib/format.ts new file mode 100644 index 000000000..8697a6140 --- /dev/null +++ b/src/renderer/features/usage/lib/format.ts @@ -0,0 +1,30 @@ +const compactFormatter = new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 1, +}) + +const fullFormatter = new Intl.NumberFormat("en-US") + +export function formatCompact(n: number): string { + if (!Number.isFinite(n)) return "0" + if (n === 0) return "0" + return compactFormatter.format(n) +} + +export function formatFull(n: number): string { + return fullFormatter.format(Math.round(n)) +} + +export function formatUSD(n: number, opts: { compact?: boolean } = {}): string { + if (!Number.isFinite(n)) return "$0.00" + if (opts.compact && Math.abs(n) >= 1000) { + return `$${compactFormatter.format(n)}` + } + return `$${n.toFixed(2)}` +} + +/** "Apr 17" style short label for axis ticks. */ +export function formatShortDate(iso: string): string { + const d = new Date(`${iso}T00:00:00`) + return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }) +} diff --git a/src/renderer/features/usage/usage-content.tsx b/src/renderer/features/usage/usage-content.tsx new file mode 100644 index 000000000..dd2b98bd7 --- /dev/null +++ b/src/renderer/features/usage/usage-content.tsx @@ -0,0 +1,187 @@ +import { useAtom, useSetAtom } from "jotai" +import { useEffect } from "react" +import { + desktopViewAtom, + usagePeriodAtom, + usageSourceAtom, + type UsagePeriod, + type UsageSourceFilter, +} from "../agents/atoms" +import { trpc } from "../../lib/trpc" +import { StatCard } from "./components/stat-card" +import { SegmentedToggle } from "./components/segmented-toggle" +import { ActivityHeatmap } from "./components/activity-heatmap" +import { DailyCostChart } from "./components/daily-cost-chart" +import { ModelBreakdown } from "./components/model-breakdown" +import { formatCompact, formatUSD } from "./lib/format" +import { RefreshCw } from "lucide-react" + +const PERIOD_OPTIONS: { value: UsagePeriod; label: string }[] = [ + { value: "7d", label: "7d" }, + { value: "30d", label: "30d" }, + { value: "90d", label: "90d" }, + { value: "all", label: "All" }, +] + +const SOURCE_OPTIONS: { value: UsageSourceFilter; label: string }[] = [ + { value: "all", label: "All" }, + { value: "claude", label: "Claude" }, + { value: "codex", label: "Codex" }, +] + +export function UsageContent() { + const [period, setPeriod] = useAtom(usagePeriodAtom) + const [source, setSource] = useAtom(usageSourceAtom) + const setDesktopView = useSetAtom(desktopViewAtom) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault() + setDesktopView(null) + } + } + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [setDesktopView]) + + const { + data, + isLoading, + isError, + error, + refetch, + isFetching, + } = trpc.usage.getOverview.useQuery( + { period, source }, + { staleTime: 15_000, refetchOnWindowFocus: false }, + ) + + const refreshMutation = trpc.usage.refresh.useMutation({ + onSuccess: () => { + refetch() + }, + }) + + return ( +
+
+
+
+

Usage

+

+ Aggregated from local Claude Code and Codex CLI session logs. +

+
+
+ + + +
+
+ + {isError ? ( +
+ Failed to load usage: {error?.message ?? "unknown error"} +
+ ) : null} + + {isLoading || !data ? ( + + ) : ( + <> +
+ + + + +
+ +
+ + + + +
+ +
+
+ +
+
+ +
+
+ +
+
Models
+ +
+ + {data.totals.unpricedModels.length > 0 ? ( +
+ Unpriced models (tokens shown, cost omitted):{" "} + {data.totals.unpricedModels.join(", ")} +
+ ) : null} + + )} +
+
+ ) +} + +function LoadingSkeleton() { + return ( +
+
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+
+
+
+
+
+
+ ) +}