Skip to content
Merged
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
2 changes: 2 additions & 0 deletions src/main/lib/trpc/routers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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(),
})
Expand Down
66 changes: 66 additions & 0 deletions src/main/lib/trpc/routers/usage.ts
Original file line number Diff line number Diff line change
@@ -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<UsageEntry[]> {
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<UsageEntry[]>[] = []
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 }
}),
})
229 changes: 229 additions & 0 deletions src/main/lib/usage/aggregator.ts
Original file line number Diff line number Diff line change
@@ -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<string>()
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<string>()
const dailyMap = new Map<string, DailyBucket>()
const modelMap = new Map<string, ModelBreakdown>()
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,
}
}
Loading