authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-08 06:59:04-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-08 03:59:04-07:00
log8101cd044911c7dc2a2adf7c7a9ba7962abf57b6
treee98f5650379f428bf5dcc6e7cae287c68fb8b080
parent30164cb1ba8ea05fdd6be69215dba2cc2f0e2aa8
signature Signed by PGP key B5690EEEBB952194

feat(usage): usage page reading provider transcripts across environments (#5684)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

28 files changed, 3795 insertions(+), 1 deletions(-)

apps/server/src/auth/RpcAuthorization.ts+1
...@@ -45,6 +45,7 @@ export const RPC_REQUIRED_SCOPES = {...@@ -45,6 +45,7 @@ export const RPC_REQUIRED_SCOPES = {
45 [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope,45 [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope,
46 [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope,46 [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope,
47 [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope,47 [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope,
48 [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope,
48 [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope,49 [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope,
49 [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope,50 [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope,
50 [WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope,51 [WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope,
apps/server/src/server.test.ts+2
...@@ -149,6 +149,7 @@ import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryR...@@ -149,6 +149,7 @@ import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryR
149import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClient.ts";149import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClient.ts";
150import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts";150import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts";
151import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";151import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
152import * as UsageService from "./usage/UsageService.ts";
152import * as Data from "effect/Data";153import * as Data from "effect/Data";
153154
154import { makeOrchestrationIntegrationHarness } from "../integration/OrchestrationEngineHarness.integration.ts";155import { makeOrchestrationIntegrationHarness } from "../integration/OrchestrationEngineHarness.integration.ts";
...@@ -825,6 +826,7 @@ const buildAppUnderTest = (options?: {...@@ -825,6 +826,7 @@ const buildAppUnderTest = (options?: {
825826
826 const appLayer = servedRoutesLayer.pipe(827 const appLayer = servedRoutesLayer.pipe(
827 Layer.provide(resourceTelemetryLayer),828 Layer.provide(resourceTelemetryLayer),
829 Layer.provide(UsageService.layerTest),
828 Layer.provide(830 Layer.provide(
829 Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({831 Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({
830 record: () => Effect.void,832 record: () => Effect.void,
apps/server/src/server.ts+4
...@@ -101,6 +101,7 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien...@@ -101,6 +101,7 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien
101import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts";101import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts";
102import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts";102import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts";
103import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";103import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
104import * as UsageService from "./usage/UsageService.ts";
104import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";105import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
105import {106import {
106 clearPersistedServerRuntimeState,107 clearPersistedServerRuntimeState,
...@@ -158,6 +159,8 @@ const BackgroundLayerLive = BackgroundPolicy.layer.pipe(...@@ -158,6 +159,8 @@ const BackgroundLayerLive = BackgroundPolicy.layer.pipe(
158 Layer.provideMerge(ServerSettingsLayerLive),159 Layer.provideMerge(ServerSettingsLayerLive),
159);160);
160161
162const UsageLayerLive = UsageService.layer.pipe(Layer.provide(ServerSettingsLayerLive));
163
161const ResourceDiagnosticsLayerLive = Layer.mergeAll(164const ResourceDiagnosticsLayerLive = Layer.mergeAll(
162 ResourceTelemetryLayerLive,165 ResourceTelemetryLayerLive,
163 ProcessDiagnostics.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)),166 ProcessDiagnostics.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)),
...@@ -410,6 +413,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(...@@ -410,6 +413,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(
410 // Misc.413 // Misc.
411 Layer.provideMerge(BackgroundLayerLive),414 Layer.provideMerge(BackgroundLayerLive),
412 Layer.provideMerge(ResourceDiagnosticsLayerLive),415 Layer.provideMerge(ResourceDiagnosticsLayerLive),
416 Layer.provideMerge(UsageLayerLive),
413 Layer.provideMerge(TraceDiagnostics.layer),417 Layer.provideMerge(TraceDiagnostics.layer),
414 Layer.provideMerge(AnalyticsService.layer),418 Layer.provideMerge(AnalyticsService.layer),
415 Layer.provideMerge(ExternalLauncher.layer),419 Layer.provideMerge(ExternalLauncher.layer),
apps/server/src/usage/UsageService.ts created+420
...@@ -0,0 +1,420 @@
1/**
2 * UsageService - scans provider transcripts and returns priced daily usage.
3 *
4 * The scan reads the provider CLIs' own session files rather than T3 Code's
5 * orchestration projections, so usage covers turns driven outside T3 Code too.
6 * This is the approach `ccusage` takes.
7 *
8 * Transcripts are append-only, so parsed records are memoised per file by
9 * `(size, mtime)`. A cold 30-day scan of ~1.4 GB lands around 2-3 seconds; warm
10 * scans only reparse files that changed.
11 *
12 * @module UsageService
13 */
14import * as NodeOS from "node:os";
15
16import {
17 USAGE_CONTRACT_VERSION,
18 type UsageProviderKind,
19 type UsageSource,
20 type UsageSummary,
21 type UsageSummaryInput,
22 UsageReadError,
23} from "@t3tools/contracts";
24import * as Cause from "effect/Cause";
25import * as Clock from "effect/Clock";
26import * as Context from "effect/Context";
27import * as DateTime from "effect/DateTime";
28import * as Effect from "effect/Effect";
29import * as FileSystem from "effect/FileSystem";
30import * as Layer from "effect/Layer";
31import * as Option from "effect/Option";
32import * as Path from "effect/Path";
33import * as Schema from "effect/Schema";
34import { HttpClient, HttpClientResponse } from "effect/unstable/http";
35
36import { ServerConfig } from "../config.ts";
37import * as ServerSettings from "../serverSettings.ts";
38import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts";
39import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";
40import { UsageAggregator } from "./usageAggregation.ts";
41import { parseRateTable, type RateTable } from "./usagePricing.ts";
42import {
43 listTranscriptFiles,
44 readDirectoryVolumeId,
45 readTranscriptRecords,
46} from "./usageTranscriptReader.ts";
47import {
48 decodeScanCache,
49 dedupeWithinFile,
50 encodeScanCache,
51 pruneScanCache,
52 type ScanCache,
53} from "./usageScanCache.ts";
54import type { UsageRecord } from "./usageTranscripts.ts";
55
56const LITELLM_RATES_URL =
57 "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
58
59/** Rates move rarely; a day-old table keeps the page working offline. */
60const RATES_TTL_MS = 24 * 60 * 60 * 1000;
61
62/**
63 * Files are filtered by mtime before opening. The slack covers a session whose
64 * last write lands just before local midnight on the window's first day.
65 */
66const MTIME_SLACK_MS = 36 * 60 * 60 * 1000;
67
68/** Longest window the UI offers, plus slack. Older entries are pruned. */
69const CACHE_RETENTION_DAYS = 90;
70
71/** On-disk shape of the rate snapshot. */
72const RatesCacheFile = Schema.Struct({
73 fetchedAtMs: Schema.Number,
74 document: Schema.Unknown,
75});
76const decodeRatesCache = Schema.decodeUnknownEffect(
77 Schema.fromJsonString(RatesCacheFile as unknown as Schema.Codec<typeof RatesCacheFile.Type>),
78);
79const encodeRatesCache = Schema.encodeEffect(
80 Schema.fromJsonString(RatesCacheFile as unknown as Schema.Codec<typeof RatesCacheFile.Type>),
81);
82
83/** The scan cache is narrowed by hand in `usageScanCache`, so JSON is enough here. */
84const ScanCacheJson = Schema.fromJsonString(Schema.Unknown as unknown as Schema.Codec<unknown>);
85const decodeScanCacheFile = Schema.decodeUnknownEffect(ScanCacheJson);
86const encodeScanCacheFile = Schema.encodeEffect(ScanCacheJson);
87
88export class UsageService extends Context.Service<
89 UsageService,
90 {
91 readonly readSummary: (input: UsageSummaryInput) => Effect.Effect<UsageSummary, UsageReadError>;
92 }
93>()("t3/usage/UsageService") {}
94
95/** Empty summary, for suites that only need the RPC surface to resolve. */
96export const layerTest = Layer.succeed(
97 UsageService,
98 UsageService.of({
99 readSummary: (input) =>
100 Effect.succeed({
101 contractVersion: USAGE_CONTRACT_VERSION,
102 readAt: "1970-01-01T00:00:00.000Z",
103 timeZone: input.timeZone,
104 sinceDay: input.sinceDay,
105 untilDay: input.untilDay,
106 buckets: [],
107 sources: [],
108 pricing: {
109 status: "unavailable",
110 source: LITELLM_RATES_URL,
111 fetchedAt: null,
112 knownModels: 0,
113 },
114 scanDurationMs: 0,
115 }),
116 }),
117);
118
119export const make = Effect.gen(function* () {
120 const fileSystem = yield* FileSystem.FileSystem;
121 const path = yield* Path.Path;
122 const config = yield* ServerConfig;
123 const settingsService = yield* ServerSettings.ServerSettingsService;
124 const httpClient = yield* HttpClient.HttpClient;
125
126 const fileCache: ScanCache = new Map();
127 let cacheDirty = false;
128
129 const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json");
130 const scanCachePath = path.join(config.stateDir, "usage-scan-cache.json");
131 let rates: RateTable = new Map();
132 let ratesFetchedAtMs: number | null = null;
133 let ratesStatus: UsageSummary["pricing"]["status"] = "unavailable";
134
135 /**
136 * Loads the LiteLLM rate table, preferring a fresh copy and falling back to
137 * the on-disk snapshot. With neither, every model reports as unpriced rather
138 * than the page failing.
139 */
140 const ensureRates = Effect.fn("UsageService.ensureRates")(function* () {
141 const now = yield* Clock.currentTimeMillis;
142 if (ratesFetchedAtMs !== null && now - ratesFetchedAtMs < RATES_TTL_MS) return;
143
144 if (ratesFetchedAtMs === null) {
145 const fromDisk = yield* fileSystem.readFileString(ratesCachePath).pipe(
146 Effect.flatMap((raw) => decodeRatesCache(raw)),
147 Effect.catchCause(() => Effect.succeed(null)),
148 );
149 if (fromDisk !== null) {
150 const parsed = parseRateTable(fromDisk.document);
151 if (parsed.size > 0) {
152 rates = parsed;
153 ratesFetchedAtMs = fromDisk.fetchedAtMs;
154 ratesStatus = "cached";
155 if (now - fromDisk.fetchedAtMs < RATES_TTL_MS) return;
156 }
157 }
158 }
159
160 const fetched = yield* httpClient.get(LITELLM_RATES_URL).pipe(
161 Effect.flatMap(HttpClientResponse.filterStatusOk),
162 Effect.flatMap((response) => response.json),
163 Effect.timeout(10_000),
164 Effect.catchCause(() => Effect.succeed(null)),
165 );
166 if (fetched === null) {
167 // The refresh failed; whatever we are serving is now past its TTL and
168 // must not keep claiming to be fresh.
169 if (rates.size > 0) ratesStatus = "cached";
170 return;
171 }
172
173 const parsed = parseRateTable(fetched);
174 if (parsed.size === 0) return;
175
176 rates = parsed;
177 ratesFetchedAtMs = now;
178 ratesStatus = "fresh";
179
180 yield* encodeRatesCache({ fetchedAtMs: now, document: fetched }).pipe(
181 Effect.flatMap((serialized) => fileSystem.writeFileString(ratesCachePath, serialized)),
182 Effect.catchCause(() => Effect.void),
183 );
184 });
185
186 /**
187 * Claude's config dir is the home itself when overridden, but a default
188 * install nests transcripts under `~/.claude/projects`. Probe both.
189 */
190 const resolveClaudeTranscriptDir = (homePath: string) =>
191 Effect.gen(function* () {
192 const nested = path.join(homePath, ".claude", "projects");
193 const nestedExists = yield* fileSystem
194 .exists(nested)
195 .pipe(Effect.catchCause(() => Effect.succeed(false)));
196 return nestedExists ? nested : path.join(homePath, "projects");
197 });
198
199 /** Resolves the transcript directory for each provider. */
200 const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* () {
201 // A settings failure must surface as an error: swallowing it here would
202 // present "zero usage from every provider" as a valid answer.
203 const settings = yield* settingsService.getSettings.pipe(
204 Effect.catchCause(
205 (cause) =>
206 new UsageReadError({
207 reason: "scanFailed",
208 // Bounded description; the squashed failure travels as the cause.
209 // Squashed, not the Cause tree: a full tree in a Defect field is
210 // the unbounded wire payload the bounded detail exists to avoid.
211 detail: "Server settings could not be read.",
212 cause: Cause.squash(cause),
213 }),
214 ),
215 );
216
217 const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent);
218 const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome);
219 const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex);
220
221 return [
222 { provider: "claude" as const, dir: claudeDir },
223 { provider: "codex" as const, dir: path.join(codexLayout.sharedHomePath, "sessions") },
224 ];
225 });
226
227 /**
228 * Loads the persisted scan cache exactly once per process.
229 *
230 * `Effect.cached` makes concurrent first readers await the same load rather
231 * than each seeing a "loaded" flag set before the read finished and cold
232 * scanning against an empty cache.
233 */
234 const ensureScanCacheLoaded = yield* Effect.cached(
235 Effect.gen(function* () {
236 const document = yield* fileSystem.readFileString(scanCachePath).pipe(
237 Effect.flatMap((raw) => decodeScanCacheFile(raw)),
238 Effect.catchCause(() => Effect.succeed(null)),
239 );
240 if (document === null) return;
241 for (const [path, entry] of decodeScanCache(document)) fileCache.set(path, entry);
242 }),
243 );
244
245 const persistScanCache = Effect.fn("UsageService.persistScanCache")(function* () {
246 if (!cacheDirty) return;
247 // Cleared only after the write lands, so a failed persist is retried on
248 // the next scan instead of leaving disk permanently stale.
249 yield* encodeScanCacheFile(encodeScanCache(fileCache)).pipe(
250 Effect.flatMap((serialized) => fileSystem.writeFileString(scanCachePath, serialized)),
251 Effect.map(() => {
252 cacheDirty = false;
253 }),
254 // A cache we cannot write is a slower next start, not a failed read.
255 Effect.catchCause(() => Effect.void),
256 );
257 });
258
259 /** Parses one transcript, reusing the cached result when it is unchanged. */
260 const readFileRecords = (
261 filePath: string,
262 size: number,
263 mtimeMs: number,
264 provider: UsageProviderKind,
265 ): Effect.Effect<readonly UsageRecord[]> =>
266 Effect.gen(function* () {
267 const cached = fileCache.get(filePath);
268 // Provider is part of the identity: if both providers were ever pointed
269 // at one directory, a hit parsed by the other parser must not be reused.
270 if (
271 cached &&
272 cached.size === size &&
273 cached.mtimeMs === mtimeMs &&
274 cached.provider === provider
275 ) {
276 return cached.records;
277 }
278
279 const parsed = yield* Effect.promise(() => readTranscriptRecords(filePath, provider));
280 // A read failure is not an empty transcript: caching it under this
281 // (size, mtime) would silently drop the file's usage until it changes.
282 if (parsed === null) return [];
283 // Stored already de-duplicated within the file, which is 99% of all
284 // duplicates. The aggregator still runs the cross-file dedupe pass.
285 const records = dedupeWithinFile(parsed);
286
287 fileCache.set(filePath, { size, mtimeMs, provider, records });
288 cacheDirty = true;
289 return records;
290 });
291
292 const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) {
293 if (input.sinceDay > input.untilDay) {
294 return yield* new UsageReadError({
295 reason: "invalidWindow",
296 detail: `sinceDay '${input.sinceDay}' is after untilDay '${input.untilDay}'`,
297 });
298 }
299
300 const startedAtMs = yield* Clock.currentTimeMillis;
301 yield* ensureRates();
302 yield* ensureScanCacheLoaded;
303
304 const hostId = NodeOS.hostname();
305 // The home resolvers ask for `Path` themselves; satisfy them from the
306 // instance we already hold so `readSummary` stays context-free.
307 const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path));
308 const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`);
309 if (Option.isNone(windowStart)) {
310 return yield* new UsageReadError({
311 reason: "invalidWindow",
312 detail: `sinceDay '${input.sinceDay}' is not a valid date`,
313 });
314 }
315 const windowStartMs = DateTime.toEpochMillis(windowStart.value) - MTIME_SLACK_MS;
316
317 const aggregator = new UsageAggregator({
318 timeZone: input.timeZone,
319 sinceDay: input.sinceDay,
320 untilDay: input.untilDay,
321 rates,
322 });
323
324 const sources: UsageSource[] = [];
325 const livePaths = new Set<string>();
326 const walkedRoots: string[] = [];
327
328 for (const { provider, dir } of dirs) {
329 const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir));
330 const exists = yield* fileSystem
331 .exists(dir)
332 .pipe(Effect.catchCause(() => Effect.succeed(false)));
333
334 if (!exists) {
335 sources.push({
336 fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId },
337 status: "missing",
338 scannedFiles: 0,
339 skippedFiles: 0,
340 malformedRecords: 0,
341 distinctSessions: 0,
342 message: "No transcript directory on this environment.",
343 });
344 continue;
345 }
346
347 walkedRoots.push(dir);
348 const files = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs));
349 let scannedFiles = 0;
350 let skippedFiles = 0;
351 // Distinct per directory. Buckets carry per-cell session counts, but a
352 // session spans days and models, so clients total this figure instead.
353 const sessionIds = new Set<string>();
354
355 for (const file of files) {
356 livePaths.add(file.path);
357 const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider);
358 if (records.length === 0) {
359 skippedFiles += 1;
360 continue;
361 }
362 scannedFiles += 1;
363 for (const record of records) {
364 // Only sessions that contributed in-window count: the mtime slack
365 // admits boundary files whose records fall outside the range.
366 if (aggregator.add(record) && record.sessionId.length > 0) {
367 sessionIds.add(record.sessionId);
368 }
369 }
370 }
371
372 sources.push({
373 fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId },
374 status: "ok",
375 scannedFiles,
376 skippedFiles,
377 malformedRecords: 0,
378 distinctSessions: sessionIds.size,
379 message: null,
380 });
381 }
382
383 const pruned = pruneScanCache(fileCache, {
384 livePaths,
385 walkedRoots,
386 windowStartMs,
387 retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000,
388 });
389 if (pruned > 0) cacheDirty = true;
390 yield* persistScanCache();
391
392 const aggregated = aggregator.finish();
393 const readAt = yield* DateTime.now;
394 const finishedAtMs = yield* Clock.currentTimeMillis;
395
396 return {
397 contractVersion: USAGE_CONTRACT_VERSION,
398 readAt: DateTime.formatIso(readAt),
399 timeZone: input.timeZone,
400 sinceDay: input.sinceDay,
401 untilDay: input.untilDay,
402 buckets: aggregated.buckets,
403 sources,
404 pricing: {
405 status: ratesStatus,
406 source: LITELLM_RATES_URL,
407 fetchedAt:
408 ratesFetchedAtMs === null
409 ? null
410 : DateTime.formatIso(DateTime.makeUnsafe(ratesFetchedAtMs)),
411 knownModels: rates.size,
412 },
413 scanDurationMs: Math.max(0, finishedAtMs - startedAtMs),
414 } satisfies UsageSummary;
415 });
416
417 return { readSummary } as const;
418});
419
420export const layer = Layer.effect(UsageService, make);
apps/server/src/usage/usageAggregation.test.ts created+132
...@@ -0,0 +1,132 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { UsageAggregator } from "./usageAggregation.ts";
4import type { RateTable } from "./usagePricing.ts";
5import type { UsageRecord } from "./usageTranscripts.ts";
6
7const rates: RateTable = new Map([
8 [
9 "claude-fable-5",
10 {
11 inputCostPerToken: 1e-5,
12 outputCostPerToken: 5e-5,
13 cacheReadCostPerToken: 1e-6,
14 cacheCreationCostPerToken: 1.25e-5,
15 },
16 ],
17]);
18
19function record(overrides: Partial<UsageRecord> = {}): UsageRecord {
20 return {
21 provider: "claude",
22 // 2026-08-07T04:05Z is still Aug 6 in Los Angeles.
23 timestampMs: Date.parse("2026-08-07T04:05:13.944Z"),
24 model: "claude-fable-5",
25 sessionId: "session-a",
26 totals: {
27 uncachedInputTokens: 100,
28 cachedInputTokens: 1000,
29 cacheCreationTokens: 10,
30 outputTokens: 50,
31 reasoningTokens: 0,
32 },
33 reportedCostUsd: null,
34 dedupeKey: null,
35 ...overrides,
36 };
37}
38
39function aggregate(records: readonly UsageRecord[], timeZone = "UTC") {
40 const aggregator = new UsageAggregator({
41 timeZone,
42 sinceDay: "2026-08-01",
43 untilDay: "2026-08-31",
44 rates,
45 });
46 for (const item of records) aggregator.add(item);
47 return aggregator.finish();
48}
49
50describe("UsageAggregator", () => {
51 it("keeps only the first record for a repeated dedupe key", () => {
52 const result = aggregate([
53 record({ dedupeKey: "msg_1:" }),
54 record({ dedupeKey: "msg_1:" }),
55 record({ dedupeKey: "msg_1:" }),
56 ]);
57
58 expect(result.duplicatesDropped).toBe(2);
59 expect(result.buckets).toHaveLength(1);
60 expect(result.buckets[0]?.records).toBe(1);
61 expect(result.buckets[0]?.totals.outputTokens).toBe(50);
62 });
63
64 it("still sums records that carry no dedupe key", () => {
65 const result = aggregate([record(), record()]);
66
67 expect(result.duplicatesDropped).toBe(0);
68 expect(result.buckets[0]?.totals.outputTokens).toBe(100);
69 });
70
71 it("buckets by the day in the requested time zone", () => {
72 const utc = aggregate([record()], "UTC");
73 const losAngeles = aggregate([record()], "America/Los_Angeles");
74
75 expect(utc.buckets[0]?.day).toBe("2026-08-07");
76 expect(losAngeles.buckets[0]?.day).toBe("2026-08-06");
77 });
78
79 it("prices against the rate table", () => {
80 const result = aggregate([record()]);
81
82 // 100*1e-5 + 1000*1e-6 + 10*1.25e-5 + 50*5e-5
83 expect(result.buckets[0]?.costUsd).toBeCloseTo(0.004625, 9);
84 expect(result.buckets[0]?.costSource).toBe("modelPriced");
85 });
86
87 it("counts tokens but not cost for a model with no rate", () => {
88 const result = aggregate([record({ model: "kimi-k3" })]);
89
90 expect(result.buckets[0]?.costUsd).toBe(0);
91 expect(result.buckets[0]?.costSource).toBe("unpriced");
92 expect(result.buckets[0]?.unpricedRecords).toBe(1);
93 expect(result.buckets[0]?.totals.outputTokens).toBe(50);
94 });
95
96 it("prefers a reported cost over the rate table", () => {
97 const result = aggregate([record({ reportedCostUsd: 1.25 })]);
98
99 expect(result.buckets[0]?.costUsd).toBe(1.25);
100 expect(result.buckets[0]?.costSource).toBe("providerReported");
101 });
102
103 it("drops records outside the window", () => {
104 const result = aggregate([record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") })]);
105
106 expect(result.outOfWindow).toBe(1);
107 expect(result.buckets).toHaveLength(0);
108 });
109
110 it("reports whether a record contributed", () => {
111 const aggregator = new UsageAggregator({
112 timeZone: "UTC",
113 sinceDay: "2026-08-01",
114 untilDay: "2026-08-31",
115 rates,
116 });
117
118 expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(true);
119 expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(false);
120 expect(aggregator.add(record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") }))).toBe(false);
121 });
122
123 it("separates providers and models into their own buckets", () => {
124 const result = aggregate([
125 record(),
126 record({ provider: "codex", model: "gpt-5.6-sol" }),
127 record({ model: "claude-opus-5" }),
128 ]);
129
130 expect(result.buckets).toHaveLength(3);
131 });
132});
apps/server/src/usage/usageAggregation.ts created
Binary files /dev/null and b/apps/server/src/usage/usageAggregation.ts differ
apps/server/src/usage/usagePricing.ts created+148
...@@ -0,0 +1,148 @@
1/**
2 * Model rate lookup and cost arithmetic.
3 *
4 * Rates come from LiteLLM's `model_prices_and_context_window.json`, the same
5 * table `ccusage` prices against. Everything here is pure: fetching and caching
6 * the table lives in `UsageService`.
7 *
8 * @module usagePricing
9 */
10import type { UsageCostSource, UsageTokenTotals } from "@t3tools/contracts";
11
12/**
13 * The subset of a LiteLLM entry we price against. All values are USD per token.
14 *
15 * LiteLLM also publishes tiered variants (`*_above_272k_tokens`, `*_flex`,
16 * `*_priority`, `*_batches`). We deliberately price at the base tier: the
17 * transcripts don't record which tier served a request, so anything else would
18 * be a guess dressed up as precision.
19 */
20export interface ModelRate {
21 readonly inputCostPerToken: number;
22 readonly outputCostPerToken: number;
23 readonly cacheReadCostPerToken: number;
24 readonly cacheCreationCostPerToken: number;
25}
26
27export type RateTable = ReadonlyMap<string, ModelRate>;
28
29/** Raw shape of one LiteLLM entry, narrowed to the fields we read. */
30interface LiteLlmEntry {
31 readonly input_cost_per_token?: unknown;
32 readonly output_cost_per_token?: unknown;
33 readonly cache_read_input_token_cost?: unknown;
34 readonly cache_creation_input_token_cost?: unknown;
35}
36
37function finiteNumber(value: unknown): number | null {
38 return typeof value === "number" && Number.isFinite(value) ? value : null;
39}
40
41/**
42 * Projects the LiteLLM document into a rate table.
43 *
44 * Entries without both an input and an output rate are dropped: a half-priced
45 * model would silently under-report cost, which is worse than reporting the
46 * model as unpriced.
47 */
48export function parseRateTable(document: unknown): RateTable {
49 const table = new Map<string, ModelRate>();
50 if (typeof document !== "object" || document === null) return table;
51
52 for (const [name, raw] of Object.entries(document as Record<string, unknown>)) {
53 if (typeof raw !== "object" || raw === null) continue;
54 const entry = raw as LiteLlmEntry;
55 const input = finiteNumber(entry.input_cost_per_token);
56 const output = finiteNumber(entry.output_cost_per_token);
57 if (input === null || output === null) continue;
58
59 table.set(normalizeModelName(name), {
60 inputCostPerToken: input,
61 outputCostPerToken: output,
62 // Anthropic bills cache reads at a discount and cache writes at a
63 // premium. When a model omits them, cached input is priced as plain
64 // input rather than as free.
65 cacheReadCostPerToken: finiteNumber(entry.cache_read_input_token_cost) ?? input,
66 cacheCreationCostPerToken: finiteNumber(entry.cache_creation_input_token_cost) ?? input,
67 });
68 }
69 return table;
70}
71
72/**
73 * Canonicalises a model name for lookup.
74 *
75 * Strips a `provider/` prefix (LiteLLM publishes both `claude-opus-5` and
76 * `anthropic/claude-opus-5`) and lowercases, since transcripts are inconsistent
77 * about casing.
78 */
79export function normalizeModelName(model: string): string {
80 const trimmed = model.trim().toLowerCase();
81 const slash = trimmed.lastIndexOf("/");
82 return slash === -1 ? trimmed : trimmed.slice(slash + 1);
83}
84
85/**
86 * Models we never price, regardless of the table.
87 *
88 * `<synthetic>` marks locally generated messages that were never billed. Bare
89 * family names ("opus", "sonnet") are genuinely ambiguous across generations,
90 * so we report them as unpriced instead of guessing a generation.
91 */
92const UNPRICEABLE_MODELS = new Set([
93 "<synthetic>",
94 "synthetic",
95 "opus",
96 "sonnet",
97 "haiku",
98 "fable",
99]);
100
101export function lookupRate(table: RateTable, model: string): ModelRate | null {
102 const normalized = normalizeModelName(model);
103 if (normalized.length === 0 || UNPRICEABLE_MODELS.has(normalized)) return null;
104 return table.get(normalized) ?? null;
105}
106
107export interface PricedUsage {
108 readonly costUsd: number;
109 readonly costSource: UsageCostSource;
110}
111
112/**
113 * Prices a bucket's tokens.
114 *
115 * `reasoningTokens` is intentionally not charged separately: it is already
116 * counted inside `outputTokens`.
117 */
118export function priceUsage(
119 table: RateTable,
120 model: string,
121 totals: UsageTokenTotals,
122 reportedCostUsd: number | null,
123): PricedUsage {
124 if (reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) {
125 return { costUsd: reportedCostUsd, costSource: "providerReported" };
126 }
127
128 const rate = lookupRate(table, model);
129 if (rate === null) return { costUsd: 0, costSource: "unpriced" };
130
131 const costUsd =
132 totals.uncachedInputTokens * rate.inputCostPerToken +
133 totals.cachedInputTokens * rate.cacheReadCostPerToken +
134 totals.cacheCreationTokens * rate.cacheCreationCostPerToken +
135 totals.outputTokens * rate.outputCostPerToken;
136
137 return { costUsd, costSource: "modelPriced" };
138}
139
140/**
141 * What the cached input would have cost at full input rates, minus what it
142 * actually cost. Drives the "cache savings" figure.
143 */
144export function cacheSavingsUsd(table: RateTable, model: string, totals: UsageTokenTotals): number {
145 const rate = lookupRate(table, model);
146 if (rate === null) return 0;
147 return totals.cachedInputTokens * (rate.inputCostPerToken - rate.cacheReadCostPerToken);
148}
apps/server/src/usage/usageScanCache.test.ts created+206
...@@ -0,0 +1,206 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import {
4 decodeScanCache,
5 dedupeWithinFile,
6 encodeScanCache,
7 pruneScanCache,
8 type ScanCache,
9} from "./usageScanCache.ts";
10import type { UsageRecord } from "./usageTranscripts.ts";
11
12function record(overrides: Partial<UsageRecord> = {}): UsageRecord {
13 return {
14 provider: "claude",
15 timestampMs: 1_786_000_000_000,
16 model: "claude-fable-5",
17 sessionId: "session-a",
18 totals: {
19 uncachedInputTokens: 2,
20 cachedInputTokens: 1000,
21 cacheCreationTokens: 10,
22 outputTokens: 50,
23 reasoningTokens: 0,
24 },
25 reportedCostUsd: null,
26 dedupeKey: "msg_1:",
27 ...overrides,
28 };
29}
30
31function cacheWith(entries: readonly [string, number, readonly UsageRecord[]][]): ScanCache {
32 const cache: ScanCache = new Map();
33 for (const [path, mtimeMs, records] of entries) {
34 cache.set(path, { size: records.length * 10, mtimeMs, provider: "claude", records });
35 }
36 return cache;
37}
38
39describe("scan cache round trip", () => {
40 it("restores records unchanged", () => {
41 const original = cacheWith([
42 ["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:", model: "claude-opus-5" })]],
43 ["/b.jsonl", 200, [record({ sessionId: "session-b", reportedCostUsd: 1.5 })]],
44 ]);
45
46 const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original))));
47
48 expect(restored.size).toBe(2);
49 expect(restored.get("/a.jsonl")).toEqual(original.get("/a.jsonl"));
50 expect(restored.get("/b.jsonl")).toEqual(original.get("/b.jsonl"));
51 });
52
53 it("interns repeated model and session strings", () => {
54 const encoded = encodeScanCache(
55 cacheWith([["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:" }), record()]]]),
56 );
57
58 expect(encoded.models).toEqual(["claude-fable-5"]);
59 expect(encoded.sessions).toEqual(["session-a"]);
60 });
61
62 it("treats a corrupt or foreign document as an empty cache", () => {
63 // A bad cache should cost one cold scan, never a broken page.
64 expect(decodeScanCache(null).size).toBe(0);
65 expect(decodeScanCache("nonsense").size).toBe(0);
66 expect(decodeScanCache({ version: 999, models: [], sessions: [], files: {} }).size).toBe(0);
67 });
68
69 it("skips malformed file entries but keeps good ones", () => {
70 const encoded = encodeScanCache(cacheWith([["/good.jsonl", 100, [record()]]]));
71 const withJunk = {
72 ...encoded,
73 files: { ...encoded.files, "/bad.jsonl": { s: "nope", m: 1, p: "claude", r: [] } },
74 };
75
76 const restored = decodeScanCache(JSON.parse(JSON.stringify(withJunk)));
77 expect([...restored.keys()]).toEqual(["/good.jsonl"]);
78 });
79
80 it("rejects the whole cache when an intern table holds a non-string", () => {
81 // models: [1] would pass the undefined guard, put a number in a record's
82 // model, and crash normalizeModelName at aggregate time.
83 const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]]));
84 const poisoned = { ...encoded, models: [1] };
85
86 expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).size).toBe(0);
87 });
88
89 it("drops the whole entry when any row is corrupt, forcing a cold re-parse", () => {
90 // Keeping the surviving rows under the original (size, mtime) would read
91 // as a valid warm hit and the file would never be re-parsed.
92 const encoded = encodeScanCache(
93 cacheWith([["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:" })]]]),
94 );
95 const rows = encoded.files["/a.jsonl"]!.r;
96 const poisoned = {
97 ...encoded,
98 files: {
99 "/a.jsonl": {
100 ...encoded.files["/a.jsonl"]!,
101 r: [rows[0]!, [...rows[1]!.slice(0, 3), "not-a-number", ...rows[1]!.slice(4)]],
102 },
103 },
104 };
105
106 const restored = decodeScanCache(JSON.parse(JSON.stringify(poisoned)));
107 expect(restored.has("/a.jsonl")).toBe(false);
108 });
109});
110
111describe("pruneScanCache", () => {
112 const retentionCutoffMs = 1000;
113
114 it("drops entries older than retention", () => {
115 const cache = cacheWith([["/old.jsonl", 500, [record()]]]);
116
117 const removed = pruneScanCache(cache, {
118 livePaths: new Set(),
119 walkedRoots: ["/"],
120 windowStartMs: 400,
121 retentionCutoffMs,
122 });
123
124 expect(removed).toBe(1);
125 expect(cache.size).toBe(0);
126 });
127
128 it("drops in-window entries whose file has disappeared", () => {
129 const cache = cacheWith([["/gone.jsonl", 5000, [record()]]]);
130
131 pruneScanCache(cache, {
132 livePaths: new Set(),
133 walkedRoots: ["/"],
134 windowStartMs: 4000,
135 retentionCutoffMs,
136 });
137
138 expect(cache.size).toBe(0);
139 });
140
141 it("keeps entries outside the walked window that are still within retention", () => {
142 // Viewing 7 days must not evict the 30-day entries, which that walk never
143 // looked for and so cannot prove are gone.
144 const cache = cacheWith([["/older-but-valid.jsonl", 2000, [record()]]]);
145
146 const removed = pruneScanCache(cache, {
147 livePaths: new Set(),
148 walkedRoots: ["/"],
149 windowStartMs: 4000,
150 retentionCutoffMs,
151 });
152
153 expect(removed).toBe(0);
154 expect(cache.size).toBe(1);
155 });
156
157 it("keeps entries the walk saw", () => {
158 const cache = cacheWith([["/live.jsonl", 5000, [record()]]]);
159
160 pruneScanCache(cache, {
161 livePaths: new Set(["/live.jsonl"]),
162 walkedRoots: ["/"],
163 windowStartMs: 4000,
164 retentionCutoffMs,
165 });
166
167 expect(cache.size).toBe(1);
168 });
169});
170
171describe("pruneScanCache with an unwalked root", () => {
172 it("keeps in-window entries for a provider whose directory was not walked", () => {
173 // A missing provider root or failed settings read leaves livePaths without
174 // that provider's files. Its warm entries must survive the pass.
175 const cache = cacheWith([["/codex/sessions/a.jsonl", 5000, [record()]]]);
176
177 const removed = pruneScanCache(cache, {
178 livePaths: new Set(),
179 walkedRoots: ["/claude/projects"],
180 windowStartMs: 4000,
181 retentionCutoffMs: 1000,
182 });
183
184 expect(removed).toBe(0);
185 expect(cache.size).toBe(1);
186 });
187});
188
189describe("dedupeWithinFile", () => {
190 it("keeps the first record per dedupe key", () => {
191 const kept = dedupeWithinFile([
192 record({ totals: { ...record().totals, outputTokens: 1 } }),
193 record({ totals: { ...record().totals, outputTokens: 999 } }),
194 record({ dedupeKey: "msg_2:" }),
195 ]);
196
197 expect(kept).toHaveLength(2);
198 expect(kept[0]?.totals.outputTokens).toBe(1);
199 });
200
201 it("keeps every record that has no dedupe key", () => {
202 expect(
203 dedupeWithinFile([record({ dedupeKey: null }), record({ dedupeKey: null })]),
204 ).toHaveLength(2);
205 });
206});
apps/server/src/usage/usageScanCache.ts created+253
...@@ -0,0 +1,253 @@
1/**
2 * Durable per-file scan cache.
3 *
4 * Transcripts are append-only and a file that has not changed can never yield
5 * different usage, so parsed records are keyed by `(size, mtime)` and reused.
6 * Without this every server restart re-parses the whole window: roughly 3.5s
7 * for a 30-day scan here, against ~11ms to reload this cache.
8 *
9 * Caching *per file* rather than per day is deliberate. It is timezone
10 * independent, so changing the reporting zone does not invalidate anything, and
11 * it keeps cross-file de-duplication exact: cached entries are de-duplicated
12 * within their own file only, and the aggregator still applies the global
13 * dedupe pass over the small surviving key set.
14 *
15 * @module usageScanCache
16 */
17import type { UsageProviderKind } from "@t3tools/contracts";
18
19import type { UsageRecord } from "./usageTranscripts.ts";
20
21export const USAGE_SCAN_CACHE_VERSION = 1 as const;
22
23export interface CachedFile {
24 readonly size: number;
25 readonly mtimeMs: number;
26 readonly provider: UsageProviderKind;
27 readonly records: readonly UsageRecord[];
28}
29
30export type ScanCache = Map<string, CachedFile>;
31
32/**
33 * Row layout for the serialised form. Positional and interned rather than
34 * object-per-record: on a 30-day window that is the difference between a file
35 * measured in tens of megabytes and one under six.
36 */
37type SerializedRecord = readonly [
38 timestampMs: number,
39 modelIndex: number,
40 sessionIndex: number,
41 uncachedInputTokens: number,
42 cachedInputTokens: number,
43 cacheCreationTokens: number,
44 outputTokens: number,
45 reasoningTokens: number,
46 dedupeKey: string | null,
47 reportedCostUsd: number | null,
48];
49
50interface SerializedFile {
51 readonly s: number;
52 readonly m: number;
53 readonly p: UsageProviderKind;
54 readonly r: readonly SerializedRecord[];
55}
56
57interface SerializedCache {
58 readonly version: number;
59 readonly models: readonly string[];
60 readonly sessions: readonly string[];
61 readonly files: Readonly<Record<string, SerializedFile>>;
62}
63
64/** Serialises the cache, interning the repeated model and session strings. */
65export function encodeScanCache(cache: ScanCache): SerializedCache {
66 const models: string[] = [];
67 const sessions: string[] = [];
68 const modelIndex = new Map<string, number>();
69 const sessionIndex = new Map<string, number>();
70
71 const intern = (table: string[], index: Map<string, number>, value: string): number => {
72 const existing = index.get(value);
73 if (existing !== undefined) return existing;
74 const next = table.length;
75 table.push(value);
76 index.set(value, next);
77 return next;
78 };
79
80 const files: Record<string, SerializedFile> = {};
81 for (const [path, entry] of cache) {
82 files[path] = {
83 s: entry.size,
84 m: entry.mtimeMs,
85 p: entry.provider,
86 r: entry.records.map((record) => [
87 record.timestampMs,
88 intern(models, modelIndex, record.model),
89 intern(sessions, sessionIndex, record.sessionId),
90 record.totals.uncachedInputTokens,
91 record.totals.cachedInputTokens,
92 record.totals.cacheCreationTokens,
93 record.totals.outputTokens,
94 record.totals.reasoningTokens,
95 record.dedupeKey,
96 record.reportedCostUsd,
97 ]),
98 };
99 }
100
101 return { version: USAGE_SCAN_CACHE_VERSION, models, sessions, files };
102}
103
104function isRecordArray(value: unknown): value is readonly unknown[] {
105 return Array.isArray(value);
106}
107
108/**
109 * Rebuilds the cache from a parsed document.
110 *
111 * Anything malformed yields an empty cache rather than an error: a corrupt
112 * cache should cost one cold scan, never a broken page.
113 */
114export function decodeScanCache(document: unknown): ScanCache {
115 const cache: ScanCache = new Map();
116 if (typeof document !== "object" || document === null) return cache;
117
118 const root = document as Partial<SerializedCache>;
119 if (root.version !== USAGE_SCAN_CACHE_VERSION) return cache;
120 if (!isRecordArray(root.models) || !isRecordArray(root.sessions)) return cache;
121 if (typeof root.files !== "object" || root.files === null) return cache;
122
123 // The intern tables must be all strings: a numeric entry would pass the
124 // undefined guard below, land in a record's model, and crash the aggregate
125 // at normalizeModelName. A corrupt table rejects the whole cache.
126 if (!root.models.every((value) => typeof value === "string")) return cache;
127 if (!root.sessions.every((value) => typeof value === "string")) return cache;
128 const models = root.models as readonly string[];
129 const sessions = root.sessions as readonly string[];
130
131 for (const [path, raw] of Object.entries(root.files)) {
132 if (typeof raw !== "object" || raw === null) continue;
133 const entry = raw as Partial<SerializedFile>;
134 if (typeof entry.s !== "number" || typeof entry.m !== "number") continue;
135 if (entry.p !== "claude" && entry.p !== "codex") continue;
136 if (!isRecordArray(entry.r)) continue;
137
138 const provider: UsageProviderKind = entry.p;
139 const records: UsageRecord[] = [];
140 // Any corrupt row disqualifies the whole entry. Keeping the survivors
141 // under the original (size, mtime) would read as a valid warm hit and the
142 // file would never be re-parsed, silently losing the dropped rows' usage.
143 let corrupt = false;
144 for (const row of entry.r) {
145 if (!isRecordArray(row) || row.length < 10) {
146 corrupt = true;
147 break;
148 }
149 const [
150 timestampMs,
151 modelIndex,
152 sessionIndex,
153 uncached,
154 cached,
155 cacheCreation,
156 output,
157 reasoning,
158 dedupeKey,
159 reportedCostUsd,
160 ] = row as SerializedRecord;
161
162 const model = typeof modelIndex === "number" ? models[modelIndex] : undefined;
163 if (
164 typeof timestampMs !== "number" ||
165 !Number.isFinite(timestampMs) ||
166 model === undefined ||
167 !Number.isFinite(uncached) ||
168 !Number.isFinite(cached) ||
169 !Number.isFinite(cacheCreation) ||
170 !Number.isFinite(output) ||
171 !Number.isFinite(reasoning)
172 ) {
173 corrupt = true;
174 break;
175 }
176
177 records.push({
178 provider,
179 timestampMs,
180 model,
181 sessionId: (typeof sessionIndex === "number" ? sessions[sessionIndex] : undefined) ?? "",
182 totals: {
183 uncachedInputTokens: uncached,
184 cachedInputTokens: cached,
185 cacheCreationTokens: cacheCreation,
186 outputTokens: output,
187 reasoningTokens: reasoning,
188 },
189 reportedCostUsd: typeof reportedCostUsd === "number" ? reportedCostUsd : null,
190 dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null,
191 });
192 }
193
194 if (corrupt) continue;
195 cache.set(path, { size: entry.s, mtimeMs: entry.m, provider, records });
196 }
197
198 return cache;
199}
200
201export interface PruneOptions {
202 /** Files the walk just saw. Only meaningful inside the walked window. */
203 readonly livePaths: ReadonlySet<string>;
204 /**
205 * Roots the walk actually completed. Absence from `livePaths` only proves a
206 * file is gone when its root was walked: a provider whose directory failed to
207 * resolve this pass must not have its warm entries purged.
208 */
209 readonly walkedRoots: readonly string[];
210 /** Start of the walked window; entries older than this were not looked for. */
211 readonly windowStartMs: number;
212 /** Entries older than this are dropped regardless. */
213 readonly retentionCutoffMs: number;
214}
215
216/**
217 * Drops aged-out entries, and entries for files that have disappeared.
218 *
219 * The walk only covers the requested window, so absence from `livePaths` only
220 * proves deletion for entries *inside* that window. Pruning everything the walk
221 * missed would evict the 30-day entries every time someone looked at 7 days.
222 *
223 * Replaces an earlier record cap that cleared the whole cache once exceeded,
224 * which meant a large enough window never warmed up at all.
225 */
226export function pruneScanCache(cache: ScanCache, options: PruneOptions): number {
227 let removed = 0;
228 for (const [path, entry] of cache) {
229 const agedOut = entry.mtimeMs < options.retentionCutoffMs;
230 const underWalkedRoot = options.walkedRoots.some((root) => path.startsWith(root));
231 const deleted =
232 underWalkedRoot && entry.mtimeMs >= options.windowStartMs && !options.livePaths.has(path);
233 if (agedOut || deleted) {
234 cache.delete(path);
235 removed += 1;
236 }
237 }
238 return removed;
239}
240
241/** Within-file de-duplication, applied before an entry is cached. */
242export function dedupeWithinFile(records: readonly UsageRecord[]): readonly UsageRecord[] {
243 const seen = new Set<string>();
244 const kept: UsageRecord[] = [];
245 for (const record of records) {
246 if (record.dedupeKey !== null) {
247 if (seen.has(record.dedupeKey)) continue;
248 seen.add(record.dedupeKey);
249 }
250 kept.push(record);
251 }
252 return kept;
253}
apps/server/src/usage/usageTranscriptReader.ts created+141
...@@ -0,0 +1,141 @@
1// @effect-diagnostics nodeBuiltinImport:off
2/**
3 * Raw filesystem access for transcript scanning.
4 *
5 * Isolated here so the rest of the usage code stays on Effect's `FileSystem`.
6 * The direct `node:fs` streaming is deliberate: a cold 30-day window is ~1.4 GB
7 * across ~1,500 files, and `readline` over a read stream is roughly an order of
8 * magnitude cheaper than materialising each file. The equivalent Effect stream
9 * pipeline is idiomatic but not fast enough to sit behind a page load.
10 *
11 * @module usageTranscriptReader
12 */
13import * as NodeFS from "node:fs";
14import * as NodeFSP from "node:fs/promises";
15import * as NodePath from "node:path";
16import * as NodeReadline from "node:readline";
17
18import type { UsageProviderKind } from "@t3tools/contracts";
19
20import {
21 initialCodexScanState,
22 mightCarryUsage,
23 parseClaudeLine,
24 parseCodexLine,
25 type UsageRecord,
26} from "./usageTranscripts.ts";
27
28export interface TranscriptFile {
29 readonly path: string;
30 readonly size: number;
31 readonly mtimeMs: number;
32}
33
34/**
35 * Lists `.jsonl` transcripts under `root` last modified at or after `sinceMs`.
36 *
37 * Errors on individual entries are swallowed: session files rotate and get
38 * removed while the walk is in flight, and a partial listing is far better than
39 * failing the page.
40 */
41export async function listTranscriptFiles(
42 root: string,
43 sinceMs: number,
44): Promise<readonly TranscriptFile[]> {
45 const found: TranscriptFile[] = [];
46
47 const walk = async (dir: string): Promise<void> => {
48 let entries;
49 try {
50 entries = await NodeFSP.readdir(dir, { withFileTypes: true });
51 } catch {
52 return;
53 }
54 for (const entry of entries) {
55 const child = NodePath.join(dir, entry.name);
56 if (entry.isDirectory()) {
57 await walk(child);
58 continue;
59 }
60 if (!entry.name.endsWith(".jsonl")) continue;
61 try {
62 const stats = await NodeFSP.stat(child);
63 if (stats.mtimeMs >= sinceMs) {
64 found.push({ path: child, size: stats.size, mtimeMs: stats.mtimeMs });
65 }
66 } catch {
67 // Vanished between readdir and stat.
68 }
69 }
70 };
71
72 await walk(root);
73 return found;
74}
75
76/**
77 * Filesystem identity of a directory, as `device:inode`.
78 *
79 * Used to tell "two servers reading the same transcript directory" apart from
80 * "two machines whose hostname and home path happen to match". Returns an empty
81 * string when the directory cannot be stat'd.
82 */
83export async function readDirectoryVolumeId(path: string): Promise<string> {
84 try {
85 const stats = await NodeFSP.stat(path);
86 return `${stats.dev}:${stats.ino}`;
87 } catch {
88 return "";
89 }
90}
91
92/**
93 * Streams one transcript and returns the usage records it contains, or `null`
94 * when the file could not be read.
95 *
96 * The distinction matters to the caller's cache: a genuinely empty transcript
97 * is a stable fact worth memoising, while a transient read failure memoised
98 * under the same `(size, mtime)` key would silently drop that file's usage
99 * until the file next changes.
100 *
101 * Codex carries the active model on `turn_context` lines that hold no usage of
102 * their own, so those still have to pass through the reducer to keep model
103 * attribution correct.
104 */
105export async function readTranscriptRecords(
106 filePath: string,
107 provider: UsageProviderKind,
108): Promise<readonly UsageRecord[] | null> {
109 const records: UsageRecord[] = [];
110 const codexState = initialCodexScanState();
111
112 try {
113 const lines = NodeReadline.createInterface({
114 input: NodeFS.createReadStream(filePath, { encoding: "utf8" }),
115 crlfDelay: Infinity,
116 });
117
118 for await (const line of lines) {
119 if (provider === "codex") {
120 if (
121 !mightCarryUsage(line, provider) &&
122 !line.includes('"turn_context"') &&
123 !line.includes('"session_meta"')
124 ) {
125 continue;
126 }
127 const record = parseCodexLine(line, codexState);
128 if (record !== null) records.push(record);
129 continue;
130 }
131
132 if (!mightCarryUsage(line, provider)) continue;
133 const record = parseClaudeLine(line);
134 if (record !== null) records.push(record);
135 }
136 } catch {
137 return null;
138 }
139
140 return records;
141}
apps/server/src/usage/usageTranscripts.test.ts created+151
...@@ -0,0 +1,151 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import {
4 initialCodexScanState,
5 parseClaudeLine,
6 parseCodexLine,
7 totalTokens,
8} from "./usageTranscripts.ts";
9
10/** Shaped after a real Claude Code assistant record. */
11function claudeLine(overrides: {
12 messageId: string;
13 contentType: string;
14 model?: string;
15 outputTokens?: number;
16}): string {
17 return JSON.stringify({
18 type: "assistant",
19 timestamp: "2026-08-07T04:05:13.944Z",
20 sessionId: "5a128faa-8253-489e-b935-6c08e8e670c0",
21 cwd: "/home/theo/project",
22 message: {
23 id: overrides.messageId,
24 role: "assistant",
25 model: overrides.model ?? "claude-fable-5",
26 content: [{ type: overrides.contentType }],
27 usage: {
28 input_tokens: 2,
29 cache_creation_input_tokens: 66818,
30 cache_read_input_tokens: 1000,
31 output_tokens: overrides.outputTokens ?? 286,
32 },
33 },
34 });
35}
36
37describe("parseClaudeLine", () => {
38 it("extracts token totals and a dedupe key", () => {
39 const record = parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text" }));
40
41 expect(record).not.toBeNull();
42 expect(record?.provider).toBe("claude");
43 expect(record?.model).toBe("claude-fable-5");
44 expect(record?.totals).toEqual({
45 uncachedInputTokens: 2,
46 cachedInputTokens: 1000,
47 cacheCreationTokens: 66818,
48 outputTokens: 286,
49 reasoningTokens: 0,
50 });
51 expect(record?.dedupeKey).toBe("msg_1:");
52 });
53
54 it("gives every content block of one message the same dedupe key", () => {
55 // T3 Code writes one record per content block, each repeating the parent
56 // message's full usage. Summing them would overcount ~2.4x on real data.
57 const text = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "text" }));
58 const toolUse = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "tool_use" }));
59
60 expect(text?.dedupeKey).toBe(toolUse?.dedupeKey);
61 expect(text?.totals).toEqual(toolUse?.totals);
62 });
63
64 it("ignores records that are not assistant messages", () => {
65 expect(parseClaudeLine(JSON.stringify({ type: "user", message: {} }))).toBeNull();
66 expect(parseClaudeLine("not json")).toBeNull();
67 });
68});
69
70describe("parseCodexLine", () => {
71 const sessionMeta = JSON.stringify({
72 type: "session_meta",
73 timestamp: "2026-08-01T05:17:41.289Z",
74 payload: { type: "session_meta", id: "019fbbc1-b12c-7360-a685-28c181f0025f" },
75 });
76 const turnContext = JSON.stringify({
77 type: "turn_context",
78 timestamp: "2026-08-01T05:17:42.694Z",
79 payload: { type: "turn_context", model: "gpt-5.6-sol" },
80 });
81 const tokenCount = (inputTokens: number, cached: number, output: number, reasoning: number) =>
82 JSON.stringify({
83 type: "event_msg",
84 timestamp: "2026-08-01T05:17:49.919Z",
85 payload: {
86 type: "token_count",
87 info: {
88 last_token_usage: {
89 input_tokens: inputTokens,
90 cached_input_tokens: cached,
91 cache_write_input_tokens: 0,
92 output_tokens: output,
93 reasoning_output_tokens: reasoning,
94 },
95 },
96 },
97 });
98
99 it("attributes usage to the model from the preceding turn context", () => {
100 const state = initialCodexScanState();
101 parseCodexLine(sessionMeta, state);
102 parseCodexLine(turnContext, state);
103 const record = parseCodexLine(tokenCount(19239, 11008, 299, 116), state);
104
105 expect(record?.provider).toBe("codex");
106 expect(record?.model).toBe("gpt-5.6-sol");
107 expect(record?.sessionId).toBe("019fbbc1-b12c-7360-a685-28c181f0025f");
108 // Codex reports input_tokens inclusive of the cached portion.
109 expect(record?.totals.uncachedInputTokens).toBe(19239 - 11008);
110 expect(record?.totals.cachedInputTokens).toBe(11008);
111 expect(record?.totals.reasoningTokens).toBe(116);
112 });
113
114 it("skips a repeated token_count so deltas are not double counted", () => {
115 const state = initialCodexScanState();
116 parseCodexLine(turnContext, state);
117 const first = parseCodexLine(tokenCount(100, 0, 10, 0), state);
118 const repeat = parseCodexLine(tokenCount(100, 0, 10, 0), state);
119
120 expect(first).not.toBeNull();
121 expect(repeat).toBeNull();
122 });
123
124 it("drops usage that arrives before any model is known", () => {
125 const state = initialCodexScanState();
126 expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull();
127 });
128
129 it("does not let a pre-model event poison the duplicate signature", () => {
130 // A token_count before its turn_context is dropped; the identical event
131 // re-emitted once the model is known must still be counted.
132 const state = initialCodexScanState();
133 expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull();
134 parseCodexLine(turnContext, state);
135 expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).not.toBeNull();
136 });
137});
138
139describe("totalTokens", () => {
140 it("does not add reasoning on top of output", () => {
141 expect(
142 totalTokens({
143 uncachedInputTokens: 10,
144 cachedInputTokens: 20,
145 cacheCreationTokens: 30,
146 outputTokens: 40,
147 reasoningTokens: 25,
148 }),
149 ).toBe(100);
150 });
151});
apps/server/src/usage/usageTranscripts.ts created+246
...@@ -0,0 +1,246 @@
1/**
2 * Pure parsers for the provider CLIs' on-disk session transcripts.
3 *
4 * Both parsers are line-at-a-time reducers so callers can stream large files
5 * without materialising them. Neither touches the filesystem.
6 *
7 * @module usageTranscripts
8 */
9import type { UsageProviderKind, UsageTokenTotals } from "@t3tools/contracts";
10
11export interface UsageRecord {
12 readonly provider: UsageProviderKind;
13 readonly timestampMs: number;
14 readonly model: string;
15 readonly sessionId: string;
16 readonly totals: UsageTokenTotals;
17 readonly reportedCostUsd: number | null;
18 /**
19 * Key for cross-file de-duplication, or `null` when the record is inherently
20 * unique and needs no dedup.
21 */
22 readonly dedupeKey: string | null;
23}
24
25const EMPTY_TOTALS: UsageTokenTotals = {
26 uncachedInputTokens: 0,
27 cachedInputTokens: 0,
28 cacheCreationTokens: 0,
29 outputTokens: 0,
30 reasoningTokens: 0,
31};
32
33function int(value: unknown): number {
34 return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
35}
36
37function parseTimestampMs(value: unknown): number | null {
38 if (typeof value !== "string") return null;
39 const parsed = Date.parse(value);
40 return Number.isNaN(parsed) ? null : parsed;
41}
42
43export function addTotals(a: UsageTokenTotals, b: UsageTokenTotals): UsageTokenTotals {
44 return {
45 uncachedInputTokens: a.uncachedInputTokens + b.uncachedInputTokens,
46 cachedInputTokens: a.cachedInputTokens + b.cachedInputTokens,
47 cacheCreationTokens: a.cacheCreationTokens + b.cacheCreationTokens,
48 outputTokens: a.outputTokens + b.outputTokens,
49 reasoningTokens: a.reasoningTokens + b.reasoningTokens,
50 };
51}
52
53export function totalTokens(totals: UsageTokenTotals): number {
54 // reasoningTokens is a subset of outputTokens and must not be added again.
55 return (
56 totals.uncachedInputTokens +
57 totals.cachedInputTokens +
58 totals.cacheCreationTokens +
59 totals.outputTokens
60 );
61}
62
63/**
64 * Cheap substring gate applied before `JSON.parse`.
65 *
66 * Transcripts are mostly tool output; only a minority of lines carry usage. On
67 * a 30-day window this skips roughly half the lines outright and is worth about
68 * an order of magnitude.
69 */
70export function mightCarryUsage(line: string, provider: UsageProviderKind): boolean {
71 return provider === "claude" ? line.includes('"usage"') : line.includes('"token_count"');
72}
73
74/* -------------------------------------------------------------------------- */
75/* Claude Code */
76/* -------------------------------------------------------------------------- */
77
78/**
79 * Parses one line of a Claude Code transcript.
80 *
81 * T3 Code writes one record per assistant *content block*, and every one of
82 * those records repeats the same complete `usage` object for the parent
83 * message. Summing them overcounts by roughly 2.4x on a real workload, so the
84 * caller must drop repeats by `dedupeKey` and keep the first.
85 */
86export function parseClaudeLine(line: string): UsageRecord | null {
87 let parsed: unknown;
88 try {
89 parsed = JSON.parse(line);
90 } catch {
91 return null;
92 }
93 if (typeof parsed !== "object" || parsed === null) return null;
94
95 const record = parsed as Record<string, unknown>;
96 if (record["type"] !== "assistant") return null;
97
98 const message = record["message"];
99 if (typeof message !== "object" || message === null) return null;
100 const messageRecord = message as Record<string, unknown>;
101
102 const usage = messageRecord["usage"];
103 if (typeof usage !== "object" || usage === null) return null;
104 const usageRecord = usage as Record<string, unknown>;
105
106 const timestampMs = parseTimestampMs(record["timestamp"]);
107 if (timestampMs === null) return null;
108
109 const model = typeof messageRecord["model"] === "string" ? messageRecord["model"] : "";
110 if (model.length === 0) return null;
111
112 const messageId = typeof messageRecord["id"] === "string" ? messageRecord["id"] : null;
113 const requestId = typeof record["requestId"] === "string" ? record["requestId"] : null;
114 // Matches ccusage: prefer the message/request pair, fall back to whichever
115 // half exists. Records with neither cannot be de-duplicated.
116 const dedupeKey =
117 messageId === null && requestId === null ? null : `${messageId ?? ""}:${requestId ?? ""}`;
118
119 const cost = record["costUSD"];
120
121 return {
122 provider: "claude",
123 timestampMs,
124 model,
125 sessionId: typeof record["sessionId"] === "string" ? record["sessionId"] : "",
126 totals: {
127 uncachedInputTokens: int(usageRecord["input_tokens"]),
128 cachedInputTokens: int(usageRecord["cache_read_input_tokens"]),
129 cacheCreationTokens: int(usageRecord["cache_creation_input_tokens"]),
130 outputTokens: int(usageRecord["output_tokens"]),
131 // Anthropic folds thinking tokens into output and does not break them out.
132 reasoningTokens: 0,
133 },
134 reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null,
135 dedupeKey,
136 };
137}
138
139/* -------------------------------------------------------------------------- */
140/* Codex */
141/* -------------------------------------------------------------------------- */
142
143/**
144 * Rolling state for a single Codex rollout file.
145 *
146 * Codex `token_count` events carry no model, so the model is carried forward
147 * from the most recent `turn_context`. Sessions that switch models mid-run
148 * attribute correctly from the switch onward.
149 */
150export interface CodexScanState {
151 model: string;
152 sessionId: string;
153 lastUsageSignature: string | null;
154}
155
156export function initialCodexScanState(): CodexScanState {
157 return { model: "", sessionId: "", lastUsageSignature: null };
158}
159
160/**
161 * Feeds one line of a Codex rollout into `state`, returning a record when the
162 * line was a usage event.
163 *
164 * Deltas come from `last_token_usage`. Summing those across a session
165 * reconciles with the session's final `total_token_usage`, provided
166 * consecutive duplicate events are dropped, which this does.
167 */
168export function parseCodexLine(line: string, state: CodexScanState): UsageRecord | null {
169 let parsed: unknown;
170 try {
171 parsed = JSON.parse(line);
172 } catch {
173 return null;
174 }
175 if (typeof parsed !== "object" || parsed === null) return null;
176
177 const record = parsed as Record<string, unknown>;
178 const payload = record["payload"];
179 if (typeof payload !== "object" || payload === null) return null;
180 const payloadRecord = payload as Record<string, unknown>;
181 const payloadType = payloadRecord["type"];
182
183 if (record["type"] === "session_meta") {
184 const id = payloadRecord["id"] ?? payloadRecord["session_id"];
185 if (typeof id === "string") state.sessionId = id;
186 return null;
187 }
188
189 if (record["type"] === "turn_context") {
190 if (typeof payloadRecord["model"] === "string") state.model = payloadRecord["model"];
191 return null;
192 }
193
194 if (payloadType !== "token_count") return null;
195
196 const info = payloadRecord["info"];
197 if (typeof info !== "object" || info === null) return null;
198 const last = (info as Record<string, unknown>)["last_token_usage"];
199 if (typeof last !== "object" || last === null) return null;
200 const lastRecord = last as Record<string, unknown>;
201
202 // Only an event that is otherwise eligible may consume the duplicate
203 // signature. A token_count arriving before its turn_context (no model yet)
204 // must not poison it, or the re-emitted copy after the model is known would
205 // be skipped as a duplicate and those tokens never counted.
206 const timestampMs = parseTimestampMs(record["timestamp"]);
207 if (timestampMs === null) return null;
208 if (state.model.length === 0) return null;
209
210 // Codex re-emits an unchanged token_count on some stream boundaries. Summing
211 // those would double count, so identical consecutive payloads are skipped.
212 const signature = JSON.stringify(lastRecord);
213 if (signature === state.lastUsageSignature) return null;
214 state.lastUsageSignature = signature;
215
216 const inputTokens = int(lastRecord["input_tokens"]);
217 const cachedInputTokens = int(lastRecord["cached_input_tokens"]);
218 const cacheCreationTokens = int(lastRecord["cache_write_input_tokens"]);
219 const outputTokens = int(lastRecord["output_tokens"]);
220
221 const totals: UsageTokenTotals = {
222 // Codex reports `input_tokens` inclusive of the cached portion.
223 uncachedInputTokens: Math.max(0, inputTokens - cachedInputTokens - cacheCreationTokens),
224 cachedInputTokens,
225 cacheCreationTokens,
226 outputTokens,
227 // Reported inside output_tokens, surfaced separately for the token mix.
228 reasoningTokens: Math.min(outputTokens, int(lastRecord["reasoning_output_tokens"])),
229 };
230
231 if (totalTokens(totals) === 0) return null;
232
233 return {
234 provider: "codex",
235 timestampMs,
236 model: state.model,
237 sessionId: state.sessionId,
238 totals,
239 // Codex does not report cost in the rollout.
240 reportedCostUsd: null,
241 // Rollout files are unique per session, so events need no global dedup.
242 dedupeKey: null,
243 };
244}
245
246export { EMPTY_TOTALS };
apps/server/src/ws.ts+6
...@@ -105,6 +105,7 @@ import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts";...@@ -105,6 +105,7 @@ import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts";
105import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";105import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
106import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";106import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
107import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";107import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
108import * as UsageService from "./usage/UsageService.ts";
108import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";109import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
109import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts";110import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts";
110import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";111import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
...@@ -412,6 +413,7 @@ const makeWsRpcLayer = (...@@ -412,6 +413,7 @@ const makeWsRpcLayer = (
412 const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics;413 const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics;
413 const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor;414 const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor;
414 const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry;415 const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry;
416 const usage = yield* UsageService.UsageService;
415 const relayClient = yield* RelayClient.RelayClient;417 const relayClient = yield* RelayClient.RelayClient;
416 const authorizationError = (requiredScope: AuthEnvironmentScope) =>418 const authorizationError = (requiredScope: AuthEnvironmentScope) =>
417 new EnvironmentAuthorizationError({419 new EnvironmentAuthorizationError({
...@@ -1529,6 +1531,10 @@ const makeWsRpcLayer = (...@@ -1529,6 +1531,10 @@ const makeWsRpcLayer = (
1529 "rpc.aggregate": "server",1531 "rpc.aggregate": "server",
1530 },1532 },
1531 ),1533 ),
1534 [WS_METHODS.serverGetUsageSummary]: (input) =>
1535 observeRpcEffect(WS_METHODS.serverGetUsageSummary, usage.readSummary(input), {
1536 "rpc.aggregate": "server",
1537 }),
1532 [WS_METHODS.serverRetryResourceTelemetry]: (_input) =>1538 [WS_METHODS.serverRetryResourceTelemetry]: (_input) =>
1533 observeRpcEffect(WS_METHODS.serverRetryResourceTelemetry, resourceTelemetry.retry, {1539 observeRpcEffect(WS_METHODS.serverRetryResourceTelemetry, resourceTelemetry.retry, {
1534 "rpc.aggregate": "server",1540 "rpc.aggregate": "server",
apps/web/src/components/sidebar/SidebarChrome.tsx+14-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { SettingsIcon } from "lucide-react";1import { ChartNoAxesColumnIcon, SettingsIcon } from "lucide-react";
2import { memo, useCallback } from "react";2import { memo, useCallback } from "react";
3import { Link, useNavigate } from "@tanstack/react-router";3import { Link, useNavigate } from "@tanstack/react-router";
44
...@@ -118,11 +118,24 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() {...@@ -118,11 +118,24 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
118 void navigate({ to: "/settings" });118 void navigate({ to: "/settings" });
119 }, [isMobile, navigate, setOpenMobile]);119 }, [isMobile, navigate, setOpenMobile]);
120120
121 const handleUsageClick = useCallback(() => {
122 if (isMobile) {
123 setOpenMobile(false);
124 }
125 void navigate({ to: "/usage" });
126 }, [isMobile, navigate, setOpenMobile]);
127
121 return (128 return (
122 <SidebarFooter className="p-[var(--sidebar-content-inset)]">129 <SidebarFooter className="p-[var(--sidebar-content-inset)]">
123 <SidebarProviderUpdatePill />130 <SidebarProviderUpdatePill />
124 <SidebarUpdatePill />131 <SidebarUpdatePill />
125 <SidebarMenu>132 <SidebarMenu>
133 <SidebarMenuItem>
134 <SidebarMenuButton onClick={handleUsageClick}>
135 <ChartNoAxesColumnIcon />
136 <span>Usage</span>
137 </SidebarMenuButton>
138 </SidebarMenuItem>
126 <SidebarMenuItem>139 <SidebarMenuItem>
127 <SidebarMenuButton onClick={handleSettingsClick}>140 <SidebarMenuButton onClick={handleSettingsClick}>
128 <SettingsIcon />141 <SettingsIcon />
apps/web/src/components/usage/UsagePage.tsx created+454
...@@ -0,0 +1,454 @@
1import type { UsageProviderKind } from "@t3tools/contracts";
2import { RefreshCwIcon } from "lucide-react";
3import { useMemo, useState } from "react";
4
5import { cn } from "../../lib/utils";
6import { useUsage } from "../../state/usage";
7import {
8 enumerateDays,
9 formatCount,
10 formatDayShort,
11 formatPercent,
12 formatTokens,
13 formatUsd,
14 makeWindow,
15} from "../../usage/usageFormat";
16import { ScrollArea } from "../ui/scroll-area";
17import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";
18import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders";
19
20const WINDOW_OPTIONS = [
21 { days: 7, label: "7 days" },
22 { days: 30, label: "30 days" },
23 { days: 90, label: "90 days" },
24] as const;
25
26export function UsagePage() {
27 const [windowDays, setWindowDays] = useState<number>(30);
28 const [metric, setMetric] = useState<UsageChartMetric>("cost");
29 const [breakdown, setBreakdown] = useState<"model" | "day">("model");
30
31 // Recomputed only when the window length changes, so a re-render does not
32 // shift the range and refetch every environment.
33 const window = useMemo(() => makeWindow(windowDays), [windowDays]);
34 const { merged, environments, isPending, isPartial, refresh } = useUsage(window);
35
36 const days = useMemo(
37 () => enumerateDays(window.sinceDay, window.untilDay),
38 [window.sinceDay, window.untilDay],
39 );
40 const recentDays = useMemo(() => merged.daily.toReversed().slice(0, 8), [merged.daily]);
41
42 // Ranked by whatever the toggle is showing, so the bars always descend.
43 const orderedProviders = useMemo(
44 () =>
45 merged.providers.toSorted((a, b) =>
46 metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens,
47 ),
48 [merged.providers, metric],
49 );
50
51 const activeDays = merged.daily.filter((day) => day.totalTokens > 0).length;
52 const dailyAverage = activeDays === 0 ? 0 : merged.totalTokens / activeDays;
53 const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens;
54 const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput;
55
56 return (
57 <ScrollArea className="h-full">
58 <div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-6">
59 <header className="flex flex-wrap items-start justify-between gap-4">
60 <div className="flex flex-col gap-1">
61 <h1 className="text-2xl font-semibold text-foreground">Usage</h1>
62 <p className="text-sm text-muted-foreground">
63 {formatDayShort(window.sinceDay)} to {formatDayShort(window.untilDay)}
64 </p>
65 </div>
66 <div className="flex items-center gap-2">
67 <div className="flex overflow-hidden rounded-md border border-border">
68 {WINDOW_OPTIONS.map((option) => (
69 <button
70 key={option.days}
71 type="button"
72 onClick={() => setWindowDays(option.days)}
73 className={cn(
74 "px-3 py-1.5 text-xs",
75 option.days === windowDays
76 ? "bg-muted text-foreground"
77 : "text-muted-foreground hover:text-foreground",
78 )}
79 >
80 {option.label}
81 </button>
82 ))}
83 </div>
84 <button
85 type="button"
86 onClick={refresh}
87 aria-label="Refresh usage"
88 className="rounded-md border border-border p-2 text-muted-foreground hover:text-foreground"
89 >
90 <RefreshCwIcon className="size-3.5" />
91 </button>
92 </div>
93 </header>
94
95 <UsageCoverageNotice
96 environments={environments}
97 duplicateSources={merged.duplicateSources}
98 staleEnvironments={merged.staleEnvironments}
99 isPartial={isPartial}
100 />
101
102 {isPending ? (
103 <p className="py-16 text-center text-sm text-muted-foreground">
104 Scanning provider transcripts…
105 </p>
106 ) : (
107 <>
108 {/* Cost first: the financial answer, then the provider split. */}
109 <section className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">
110 {/* The summary follows the chart toggle, so the headline and the
111 series are always reading the same units. */}
112 <div className="flex flex-col gap-5">
113 <div className="flex flex-col gap-1">
114 <span className="text-xs tracking-wide text-muted-foreground uppercase">
115 {metric === "cost" ? "Raw token cost" : "Processed tokens"}
116 </span>
117 <span className="text-4xl font-semibold text-foreground tabular-nums">
118 {metric === "cost"
119 ? `${formatUsd(merged.costUsd)}*`
120 : formatTokens(merged.totalTokens)}
121 </span>
122 <span className="text-xs text-muted-foreground">
123 {metric === "cost"
124 ? "* if billed at full API rate"
125 : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`}
126 </span>
127 </div>
128
129 {orderedProviders.map((provider) => {
130 const share = metric === "cost" ? provider.costShare : provider.tokenShare;
131 return (
132 <div key={provider.provider} className="flex flex-col gap-1.5">
133 <div className="flex items-baseline justify-between">
134 <span className="flex items-center gap-2 text-sm text-foreground">
135 <ProviderMark provider={provider.provider} className="size-4" />
136 {PROVIDER_LABEL[provider.provider]}
137 </span>
138 <span className="text-sm text-foreground tabular-nums">
139 {metric === "cost"
140 ? formatUsd(provider.costUsd)
141 : formatTokens(provider.totalTokens)}
142 </span>
143 </div>
144 <div className="h-1 w-full overflow-hidden rounded-full bg-muted">
145 <div
146 className="h-full"
147 style={{
148 width: `${(share * 100).toFixed(1)}%`,
149 backgroundColor: PROVIDER_COLOR[provider.provider],
150 }}
151 />
152 </div>
153 <span className="text-xs text-muted-foreground">
154 {metric === "cost"
155 ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens`
156 : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`}
157 </span>
158 </div>
159 );
160 })}
161 </div>
162
163 <div className="flex flex-col gap-3">
164 <div className="flex flex-wrap items-center justify-between gap-3">
165 <h2 className="text-sm font-medium text-foreground">
166 Daily {metric === "tokens" ? "processed tokens" : "cost"}
167 </h2>
168 <div className="flex items-center gap-4">
169 <div className="flex overflow-hidden rounded-md border border-border">
170 {(["cost", "tokens"] as const).map((option) => (
171 <button
172 key={option}
173 type="button"
174 onClick={() => setMetric(option)}
175 className={cn(
176 "px-2.5 py-1 text-[10px] tracking-wide uppercase",
177 option === metric
178 ? "bg-muted text-foreground"
179 : "text-muted-foreground hover:text-foreground",
180 )}
181 >
182 {option}
183 </button>
184 ))}
185 </div>
186 <UsageChartLegend />
187 </div>
188 </div>
189 <UsageProviderChart days={days} daily={merged.daily} metric={metric} />
190 </div>
191 </section>
192
193 <section className="grid grid-cols-2 gap-px border-y border-border bg-border md:grid-cols-5">
194 <Metric
195 label="Processed tokens"
196 value={formatTokens(merged.totalTokens)}
197 detail={`${formatTokens(dailyAverage)} per active day`}
198 />
199 <Metric
200 label="Cached input"
201 value={formatTokens(merged.cachedInputTokens)}
202 detail={`${formatPercent(cachedShare)} of observed input`}
203 />
204 <Metric
205 label="Uncached input"
206 value={formatTokens(merged.uncachedInputTokens)}
207 detail={`${formatTokens(merged.cacheCreationTokens)} cache writes`}
208 />
209 <Metric
210 label="Output"
211 value={formatTokens(merged.outputTokens)}
212 detail={`includes ${formatTokens(merged.reasoningTokens)} reasoning`}
213 />
214 <Metric
215 label="Cache savings"
216 value={formatUsd(merged.costQuality.cacheSavingsUsd)}
217 detail={
218 merged.costUsd > 0
219 ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw token cost`
220 : "vs full input rates"
221 }
222 />
223 </section>
224
225 <section className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,18rem)]">
226 <div className="flex flex-col gap-3">
227 <div className="flex items-center justify-between gap-3">
228 <h2 className="text-sm font-medium text-foreground">Breakdown</h2>
229 <div className="flex overflow-hidden rounded-md border border-border">
230 {(["model", "day"] as const).map((option) => (
231 <button
232 key={option}
233 type="button"
234 onClick={() => setBreakdown(option)}
235 className={cn(
236 "px-2.5 py-1 text-[10px] tracking-wide uppercase",
237 option === breakdown
238 ? "bg-muted text-foreground"
239 : "text-muted-foreground hover:text-foreground",
240 )}
241 >
242 {option}
243 </button>
244 ))}
245 </div>
246 </div>
247
248 {breakdown === "model" ? (
249 <table className="w-full text-sm">
250 <thead>
251 <tr className="border-b border-border text-left text-xs text-muted-foreground">
252 <th className="py-2 font-normal">Model</th>
253 <th className="py-2 text-right font-normal">Cost</th>
254 <th className="py-2 text-right font-normal">Share</th>
255 <th className="py-2 text-right font-normal">Tokens</th>
256 </tr>
257 </thead>
258 <tbody>
259 {merged.models.length === 0 ? (
260 <tr>
261 <td colSpan={4} className="py-6 text-center text-muted-foreground">
262 No activity in this window.
263 </td>
264 </tr>
265 ) : (
266 merged.models.map((model) => (
267 <tr
268 key={`${model.provider}:${model.model}`}
269 className="border-b border-border/50"
270 >
271 <td className="py-2 text-foreground">
272 <span className="flex items-center gap-2">
273 <ProviderMark provider={model.provider} className="size-3.5" />
274 {model.model}
275 </span>
276 </td>
277 <td className="py-2 text-right text-foreground tabular-nums">
278 {formatUsd(model.costUsd)}
279 </td>
280 <td className="py-2 text-right text-muted-foreground tabular-nums">
281 {formatPercent(model.costShare)}
282 </td>
283 <td className="py-2 text-right text-muted-foreground tabular-nums">
284 {formatTokens(model.totalTokens)}
285 </td>
286 </tr>
287 ))
288 )}
289 </tbody>
290 </table>
291 ) : (
292 <table className="w-full text-sm">
293 <thead>
294 <tr className="border-b border-border text-left text-xs text-muted-foreground">
295 <th className="py-2 font-normal">Day</th>
296 {PROVIDER_ORDER.map((provider) => (
297 <th key={provider} className="py-2 text-right font-normal">
298 {PROVIDER_LABEL[provider]}
299 </th>
300 ))}
301 <th className="py-2 text-right font-normal">Total</th>
302 <th className="py-2 text-right font-normal">Tokens</th>
303 </tr>
304 </thead>
305 <tbody>
306 {recentDays.length === 0 ? (
307 <tr>
308 <td colSpan={5} className="py-6 text-center text-muted-foreground">
309 No activity in this window.
310 </td>
311 </tr>
312 ) : (
313 recentDays.map((day) => (
314 <tr key={day.day} className="border-b border-border/50">
315 <td className="py-2 text-foreground">{formatDayShort(day.day)}</td>
316 {PROVIDER_ORDER.map((provider) => (
317 <td
318 key={provider}
319 className="py-2 text-right text-muted-foreground tabular-nums"
320 >
321 {formatUsd(day.byProvider.get(provider)?.costUsd ?? 0)}
322 </td>
323 ))}
324 <td className="py-2 text-right text-foreground tabular-nums">
325 {formatUsd(day.costUsd)}
326 </td>
327 <td className="py-2 text-right text-muted-foreground tabular-nums">
328 {formatTokens(day.totalTokens)}
329 </td>
330 </tr>
331 ))
332 )}
333 </tbody>
334 </table>
335 )}
336 </div>
337
338 <div className="flex flex-col gap-3">
339 <h2 className="text-sm font-medium text-foreground">Cost quality</h2>
340 <dl className="flex flex-col">
341 <QualityRow
342 label="Provider reported"
343 value={formatPercent(merged.costQuality.providerReportedShare)}
344 />
345 <QualityRow
346 label="Model priced"
347 value={formatPercent(merged.costQuality.modelPricedShare)}
348 />
349 <QualityRow
350 label="Unpriced"
351 value={formatPercent(merged.costQuality.unpricedShare)}
352 />
353 <QualityRow
354 label="Cache savings"
355 value={formatUsd(merged.costQuality.cacheSavingsUsd)}
356 />
357 </dl>
358 </div>
359 </section>
360 </>
361 )}
362 </div>
363 </ScrollArea>
364 );
365}
366
367/** Brand mark for the harness a row belongs to. */
368function ProviderMark({
369 provider,
370 className,
371}: {
372 readonly provider: UsageProviderKind;
373 readonly className: string;
374}) {
375 const Mark = PROVIDER_MARK[provider];
376 return <Mark className={cn("shrink-0", className)} aria-hidden />;
377}
378
379function Metric({
380 label,
381 value,
382 detail,
383}: {
384 readonly label: string;
385 readonly value: string;
386 readonly detail: string;
387}) {
388 return (
389 <div className="flex flex-col gap-0.5 bg-background px-4 py-3">
390 <span className="text-xs text-muted-foreground">{label}</span>
391 <span className="text-lg text-foreground tabular-nums">{value}</span>
392 <span className="text-xs text-muted-foreground">{detail}</span>
393 </div>
394 );
395}
396
397function QualityRow({ label, value }: { readonly label: string; readonly value: string }) {
398 return (
399 <div className="flex items-center justify-between border-b border-border/50 py-2 text-sm">
400 <dt className="text-muted-foreground">{label}</dt>
401 <dd className="text-foreground tabular-nums">{value}</dd>
402 </div>
403 );
404}
405
406/**
407 * Says plainly when the totals are incomplete: an environment still answering,
408 * one that failed, or one whose transcripts another environment already
409 * reported.
410 */
411function UsageCoverageNotice({
412 environments,
413 duplicateSources,
414 staleEnvironments,
415 isPartial,
416}: {
417 readonly environments: readonly {
418 environmentId: string;
419 label: string;
420 error: string | null;
421 isPending: boolean;
422 }[];
423 readonly duplicateSources: readonly string[];
424 readonly staleEnvironments: readonly string[];
425 readonly isPartial: boolean;
426}) {
427 const failed = environments.filter((environment) => environment.error !== null);
428 const stale = environments.filter((environment) =>
429 staleEnvironments.includes(environment.environmentId),
430 );
431 if (failed.length === 0 && stale.length === 0 && duplicateSources.length === 0 && !isPartial) {
432 return null;
433 }
434
435 return (
436 <div className="flex flex-col gap-1 border border-border px-3 py-2 text-xs text-muted-foreground">
437 {isPartial ? <span>Some environments are still reporting. Totals are partial.</span> : null}
438 {failed.map((environment) => (
439 <span key={environment.label}>{environment.label} could not report usage.</span>
440 ))}
441 {stale.map((environment) => (
442 <span key={environment.label}>
443 {environment.label} runs an older server version and is excluded from totals.
444 </span>
445 ))}
446 {duplicateSources.length > 0 ? (
447 <span>
448 Counted once across environments sharing a transcript directory:{" "}
449 {duplicateSources.join(", ")}
450 </span>
451 ) : null}
452 </div>
453 );
454}
apps/web/src/components/usage/UsageProviderChart.test.ts created+91
...@@ -0,0 +1,91 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { buildDayColumns, niceScale } from "./UsageProviderChart";
4
5describe("niceScale", () => {
6 it("never puts the peak above the top of the scale", () => {
7 // Regression: an earlier version stopped at the last step below the peak,
8 // so the tallest day was drawn past the plot and clipped.
9 for (const peak of [1122.71, 999, 1, 0.04, 1_400_000_000, 37.5, 5000, 100.001]) {
10 const { max } = niceScale(peak, 4);
11 expect(max, `peak ${peak}`).toBeGreaterThanOrEqual(peak);
12 }
13 });
14
15 it("starts at zero and ends at the maximum", () => {
16 const { max, ticks } = niceScale(1122.71, 4);
17
18 expect(ticks[0]).toBe(0);
19 expect(ticks[ticks.length - 1]).toBeCloseTo(max, 6);
20 });
21
22 it("uses evenly spaced 1/2/5 steps", () => {
23 const { ticks } = niceScale(1122.71, 4);
24 const steps = ticks.slice(1).map((tick, index) => tick - (ticks[index] ?? 0));
25
26 for (const step of steps) expect(step).toBeCloseTo(steps[0] ?? 0, 6);
27 const [first = 0] = steps;
28 const normalized = first / 10 ** Math.floor(Math.log10(first));
29 expect([1, 2, 5, 10]).toContain(Math.round(normalized));
30 });
31
32 it("keeps the tick count near the requested resolution", () => {
33 const { ticks } = niceScale(1122.71, 4);
34 expect(ticks.length).toBeGreaterThanOrEqual(3);
35 expect(ticks.length).toBeLessThanOrEqual(7);
36 });
37
38 it("degrades to a single zero tick with no data", () => {
39 expect(niceScale(0, 4)).toEqual({ max: 0, ticks: [0] });
40 });
41});
42
43describe("buildDayColumns", () => {
44 const days = ["2026-08-01", "2026-08-02", "2026-08-03"];
45 const byDay = new Map([
46 [
47 "2026-08-01",
48 {
49 day: "2026-08-01",
50 costUsd: 30,
51 totalTokens: 300,
52 byProvider: new Map([
53 ["codex" as const, { costUsd: 10, totalTokens: 100 }],
54 ["claude" as const, { costUsd: 20, totalTokens: 200 }],
55 ]),
56 },
57 ],
58 // 2026-08-02 is deliberately absent: a day with no activity.
59 [
60 "2026-08-03",
61 {
62 day: "2026-08-03",
63 costUsd: 5,
64 totalTokens: 50,
65 byProvider: new Map([["claude" as const, { costUsd: 5, totalTokens: 50 }]]),
66 },
67 ],
68 ]);
69
70 it("plots each day on its own", () => {
71 expect(buildDayColumns(days, byDay, "cost").map((column) => column.total)).toEqual([30, 0, 5]);
72 });
73
74 it("reads the requested metric", () => {
75 expect(buildDayColumns(days, byDay, "tokens").map((column) => column.total)).toEqual([
76 300, 0, 50,
77 ]);
78 });
79
80 it("keeps the bands contiguous so the areas stay additive", () => {
81 for (const column of buildDayColumns(days, byDay, "cost")) {
82 let expectedBase = 0;
83 for (const band of column.bands) {
84 expect(band.base).toBeCloseTo(expectedBase, 9);
85 expect(band.top).toBeCloseTo(band.base + band.value, 9);
86 expectedBase = band.top;
87 }
88 expect(column.total).toBeCloseTo(expectedBase, 9);
89 }
90 });
91});
apps/web/src/components/usage/UsageProviderChart.tsx created+411
...@@ -0,0 +1,411 @@
1import type { UsageProviderKind } from "@t3tools/contracts";
2import { useCallback, useMemo, useRef, useState } from "react";
3
4import type { DailyTotals } from "../../usage/usageMerge";
5import { formatDayShort, formatTokens, formatUsd } from "../../usage/usageFormat";
6import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders";
7
8const VIEW_WIDTH = 960;
9const VIEW_HEIGHT = 260;
10const TICK_COUNT = 4;
11const PLOT_TOP = 8;
12
13export type UsageChartMetric = "tokens" | "cost";
14
15interface UsageProviderChartProps {
16 readonly days: readonly string[];
17 readonly daily: readonly DailyTotals[];
18 readonly metric: UsageChartMetric;
19}
20
21/** One day's stacked bands, shared by the paths and the hover readout. */
22export interface DayColumn {
23 readonly bands: readonly {
24 readonly provider: UsageProviderKind;
25 readonly value: number;
26 readonly base: number;
27 readonly top: number;
28 }[];
29 readonly total: number;
30}
31
32interface Point {
33 readonly x: number;
34 readonly y: number;
35}
36
37function valueFor(
38 daily: DailyTotals | undefined,
39 provider: UsageProviderKind,
40 metric: UsageChartMetric,
41): number {
42 const entry = daily?.byProvider.get(provider);
43 if (entry === undefined) return 0;
44 return metric === "tokens" ? entry.totalTokens : entry.costUsd;
45}
46
47/**
48 * Monotone cubic tangents (Fritsch-Carlson).
49 *
50 * Plain cubic smoothing overshoots on spiky daily data and would dip the area
51 * below zero between points, which reads as negative spend. This variant is
52 * shape-preserving, so a smoothed series never leaves the range of its samples.
53 */
54function monotoneTangents(points: readonly Point[]): readonly number[] {
55 const count = points.length;
56 if (count < 2) return [0];
57
58 const slopes: number[] = [];
59 for (let index = 0; index < count - 1; index += 1) {
60 const dx = (points[index + 1]?.x ?? 0) - (points[index]?.x ?? 0);
61 const dy = (points[index + 1]?.y ?? 0) - (points[index]?.y ?? 0);
62 slopes.push(dx === 0 ? 0 : dy / dx);
63 }
64
65 const tangents: number[] = Array.from({ length: count }, () => 0);
66 tangents[0] = slopes[0] ?? 0;
67 tangents[count - 1] = slopes[count - 2] ?? 0;
68 for (let index = 1; index < count - 1; index += 1) {
69 const previous = slopes[index - 1] ?? 0;
70 const next = slopes[index] ?? 0;
71 tangents[index] = previous * next <= 0 ? 0 : (previous + next) / 2;
72 }
73
74 for (let index = 0; index < count - 1; index += 1) {
75 const slope = slopes[index] ?? 0;
76 if (slope === 0) {
77 tangents[index] = 0;
78 tangents[index + 1] = 0;
79 continue;
80 }
81 const a = (tangents[index] ?? 0) / slope;
82 const b = (tangents[index + 1] ?? 0) / slope;
83 const magnitude = a * a + b * b;
84 if (magnitude > 9) {
85 const scale = 3 / Math.sqrt(magnitude);
86 tangents[index] = scale * a * slope;
87 tangents[index + 1] = scale * b * slope;
88 }
89 }
90
91 return tangents;
92}
93
94/** One cubic segment of a smoothed boundary. */
95interface CurveSegment {
96 readonly from: Point;
97 readonly c1: Point;
98 readonly c2: Point;
99 readonly to: Point;
100}
101
102/** Smoothed polyline through `points`, as explicit cubic control points. */
103function smoothCurve(points: readonly Point[]): readonly CurveSegment[] {
104 if (points.length < 2) return [];
105 const tangents = monotoneTangents(points);
106 const segments: CurveSegment[] = [];
107
108 for (let index = 0; index < points.length - 1; index += 1) {
109 const from = points[index];
110 const to = points[index + 1];
111 if (from === undefined || to === undefined) continue;
112 const dx = to.x - from.x;
113 segments.push({
114 from,
115 c1: { x: from.x + dx / 3, y: from.y + ((tangents[index] ?? 0) * dx) / 3 },
116 c2: { x: to.x - dx / 3, y: to.y - ((tangents[index + 1] ?? 0) * dx) / 3 },
117 to,
118 });
119 }
120 return segments;
121}
122
123function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string {
124 const first = segments[0];
125 if (first === undefined) return "";
126 let path = `${startCommand}${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`;
127 for (const segment of segments) {
128 path += ` C${segment.c1.x.toFixed(2)},${segment.c1.y.toFixed(2)} ${segment.c2.x.toFixed(2)},${segment.c2.y.toFixed(2)} ${segment.to.x.toFixed(2)},${segment.to.y.toFixed(2)}`;
129 }
130 return path;
131}
132
133/**
134 * The same curve walked end to start. A cubic reverses exactly by swapping its
135 * control points, so this traces the identical geometry.
136 *
137 * Bands must use this rather than re-smoothing their base points in reverse:
138 * the tangent clamp in `monotoneTangents` runs left to right, so smoothing is
139 * not perfectly symmetric under reversal, and independently smoothed edges of
140 * adjacent bands could hairline-gap or overlap. Sharing one curve per stack
141 * boundary makes that geometrically impossible.
142 */
143function reversedCurvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string {
144 const last = segments[segments.length - 1];
145 if (last === undefined) return "";
146 let path = `${startCommand}${last.to.x.toFixed(2)},${last.to.y.toFixed(2)}`;
147 for (let index = segments.length - 1; index >= 0; index -= 1) {
148 const segment = segments[index];
149 if (segment === undefined) continue;
150 path += ` C${segment.c2.x.toFixed(2)},${segment.c2.y.toFixed(2)} ${segment.c1.x.toFixed(2)},${segment.c1.y.toFixed(2)} ${segment.from.x.toFixed(2)},${segment.from.y.toFixed(2)}`;
151 }
152 return path;
153}
154
155/**
156 * Builds a scale whose maximum is a readable 1/2/5 x 10^n step at or above the
157 * peak.
158 *
159 * Rounding the maximum *up* is the point: stopping at the last step below the
160 * peak leaves the tallest day drawn past the top of the plot, where it is
161 * clipped.
162 */
163export function niceScale(peak: number, count: number): { max: number; ticks: readonly number[] } {
164 if (peak <= 0) return { max: 0, ticks: [0] };
165
166 const rawStep = peak / count;
167 const magnitude = 10 ** Math.floor(Math.log10(rawStep));
168 const normalized = rawStep / magnitude;
169 const step = (normalized > 5 ? 10 : normalized > 2 ? 5 : normalized > 1 ? 2 : 1) * magnitude;
170
171 const max = Math.ceil(peak / step) * step;
172 const ticks: number[] = [];
173 for (let value = 0; value <= max + step * 1e-6; value += step) ticks.push(value);
174 return { max, ticks };
175}
176
177/**
178 * Turns the merged daily totals into stacked bands, one column per day.
179 *
180 * The chart paths and the hover readout both consume this, so the number under
181 * the cursor is by construction the number that was plotted rather than a
182 * second derivation that can drift from it.
183 */
184export function buildDayColumns(
185 days: readonly string[],
186 byDay: ReadonlyMap<string, DailyTotals>,
187 metric: UsageChartMetric,
188): readonly DayColumn[] {
189 return days.map((day) => {
190 const entry = byDay.get(day);
191 let stackTop = 0;
192 const bands = PROVIDER_ORDER.map((provider) => {
193 const value = valueFor(entry, provider, metric);
194 const base = stackTop;
195 stackTop += value;
196 return { provider, value, base, top: stackTop };
197 });
198 return { bands, total: stackTop };
199 });
200}
201
202export function UsageProviderChart({ days, daily, metric }: UsageProviderChartProps) {
203 const byDay = useMemo(() => new Map(daily.map((entry) => [entry.day, entry])), [daily]);
204 const [hoverIndex, setHoverIndex] = useState<number | null>(null);
205 const plotRef = useRef<HTMLDivElement | null>(null);
206
207 const { paths, ticks, stepX, toY, series } = useMemo(() => {
208 if (days.length === 0) {
209 return {
210 paths: [],
211 ticks: [0] as readonly number[],
212 stepX: 0,
213 toY: () => VIEW_HEIGHT,
214 series: [] as readonly DayColumn[],
215 };
216 }
217
218 const stacked = buildDayColumns(days, byDay, metric);
219
220 const peak = stacked.reduce((max, column) => Math.max(max, column.total), 0);
221 const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT);
222 const step = days.length === 1 ? 0 : VIEW_WIDTH / (days.length - 1);
223 // Reserve a sliver above the top gridline so the series stroke, which is
224 // drawn at constant screen width, is not shaved off at a peak.
225 const toY = (value: number) =>
226 max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP);
227
228 // One smoothed curve per stack boundary (baseline, then each provider's
229 // cumulative top). Band k is the region between boundary k and k+1, both
230 // drawn from these shared control points.
231 const boundaries = [
232 stacked.map((_, dayIndex) => ({ x: dayIndex * step, y: toY(0) })),
233 ...PROVIDER_ORDER.map((_, providerIndex) =>
234 stacked.map((column, dayIndex) => ({
235 x: dayIndex * step,
236 y: toY(column.bands[providerIndex]?.top ?? 0),
237 })),
238 ),
239 ].map(smoothCurve);
240
241 const built = PROVIDER_ORDER.map((provider, providerIndex) => {
242 const top = boundaries[providerIndex + 1] ?? [];
243 const base = boundaries[providerIndex] ?? [];
244 return {
245 provider,
246 area: `${curvePath(top, "M")} ${reversedCurvePath(base, "L")} Z`,
247 line: curvePath(top, "M"),
248 };
249 });
250
251 return { paths: built, ticks: tickValues, stepX: step, toY, series: stacked };
252 }, [byDay, days, metric]);
253
254 const format = metric === "tokens" ? formatTokens : formatUsd;
255
256 const handleMove = useCallback(
257 (event: React.MouseEvent<HTMLDivElement>) => {
258 const bounds = plotRef.current?.getBoundingClientRect();
259 if (bounds === undefined || bounds.width === 0 || days.length === 0) return;
260 const fraction = (event.clientX - bounds.left) / bounds.width;
261 const index = Math.round(fraction * (days.length - 1));
262 setHoverIndex(Math.min(days.length - 1, Math.max(0, index)));
263 },
264 [days.length],
265 );
266
267 const hoveredDay = hoverIndex === null ? undefined : days[hoverIndex];
268 const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex];
269 const hoverLeft = days.length <= 1 ? 0 : ((hoverIndex ?? 0) / (days.length - 1)) * 100;
270
271 return (
272 <div className="flex flex-col gap-1">
273 <div className="flex gap-2">
274 {/* Axis labels sit outside the plot so they stay aligned to gridlines. */}
275 <div className="relative h-56 w-14 shrink-0">
276 {ticks.map((tick) => (
277 <span
278 key={tick}
279 className="absolute right-0 -translate-y-1/2 text-[10px] text-muted-foreground tabular-nums"
280 style={{ top: `${(toY(tick) / VIEW_HEIGHT) * 100}%` }}
281 >
282 {tick === 0 ? "0" : format(tick)}
283 </span>
284 ))}
285 </div>
286
287 <div
288 ref={plotRef}
289 className="relative h-56 flex-1"
290 onMouseMove={handleMove}
291 onMouseLeave={() => setHoverIndex(null)}
292 >
293 <svg
294 className="h-full w-full"
295 viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`}
296 preserveAspectRatio="none"
297 role="img"
298 aria-label={`Daily ${metric === "tokens" ? "processed tokens" : "cost"} by provider`}
299 >
300 {ticks.map((tick) => {
301 const y = toY(tick);
302 return (
303 <line
304 key={tick}
305 x1={0}
306 x2={VIEW_WIDTH}
307 y1={y}
308 y2={y}
309 stroke="currentColor"
310 strokeWidth={1}
311 className="text-border"
312 vectorEffect="non-scaling-stroke"
313 />
314 );
315 })}
316
317 {paths.map(({ provider, area, line }) => (
318 <g key={provider}>
319 <path d={area} fill={PROVIDER_COLOR[provider]} fillOpacity={0.5} />
320 <path
321 d={line}
322 fill="none"
323 stroke={PROVIDER_COLOR[provider]}
324 strokeWidth={1.5}
325 vectorEffect="non-scaling-stroke"
326 />
327 </g>
328 ))}
329
330 {hoverIndex === null ? null : (
331 <line
332 x1={hoverIndex * stepX}
333 x2={hoverIndex * stepX}
334 y1={PLOT_TOP}
335 y2={VIEW_HEIGHT}
336 stroke="currentColor"
337 strokeWidth={1}
338 className="text-muted-foreground"
339 vectorEffect="non-scaling-stroke"
340 />
341 )}
342 </svg>
343
344 {hoveredDay === undefined ? null : (
345 <div
346 className="pointer-events-none absolute top-0 z-10 min-w-36 border border-border bg-background/95 px-2 py-1.5 text-xs"
347 style={{
348 left: `${hoverLeft}%`,
349 transform: hoverLeft > 60 ? "translateX(-100%)" : "translateX(0)",
350 }}
351 >
352 <div className="mb-1 text-muted-foreground">{formatDayShort(hoveredDay)}</div>
353 {PROVIDER_ORDER.map((provider) => {
354 const Mark = PROVIDER_MARK[provider];
355 return (
356 <div key={provider} className="flex items-center justify-between gap-3">
357 <span className="flex items-center gap-1.5 text-muted-foreground">
358 <Mark className="size-3 shrink-0" aria-hidden />
359 {PROVIDER_LABEL[provider]}
360 </span>
361 <span className="text-foreground tabular-nums">
362 {format(
363 hoveredColumn?.bands.find((band) => band.provider === provider)?.value ?? 0,
364 )}
365 </span>
366 </div>
367 );
368 })}
369 <div className="mt-1 flex items-center justify-between gap-3 border-t border-border pt-1">
370 <span className="text-muted-foreground">Total</span>
371 <span className="text-foreground tabular-nums">
372 {format(hoveredColumn?.total ?? 0)}
373 </span>
374 </div>
375 </div>
376 )}
377 </div>
378 </div>
379
380 <div className="flex justify-between pl-16 text-[10px] text-muted-foreground uppercase">
381 <span>{days[0] === undefined ? "" : formatDayShort(days[0])}</span>
382 <span>
383 {days[Math.floor(days.length / 2)] === undefined
384 ? ""
385 : formatDayShort(days[Math.floor(days.length / 2)] ?? "")}
386 </span>
387 <span>
388 {days[days.length - 1] === undefined ? "" : formatDayShort(days[days.length - 1] ?? "")}
389 </span>
390 </div>
391 </div>
392 );
393}
394
395export function UsageChartLegend() {
396 return (
397 <div className="flex items-center gap-4">
398 {PROVIDER_ORDER.map((provider) => {
399 // The marks carry the same fills as the bands, so they key the chart
400 // just as a colour swatch would.
401 const Mark = PROVIDER_MARK[provider];
402 return (
403 <span key={provider} className="flex items-center gap-1.5 text-xs text-muted-foreground">
404 <Mark className="size-3.5 shrink-0" aria-hidden />
405 {PROVIDER_LABEL[provider]}
406 </span>
407 );
408 })}
409 </div>
410 );
411}
apps/web/src/components/usage/usageProviders.ts created+32
...@@ -0,0 +1,32 @@
1import type { UsageProviderKind } from "@t3tools/contracts";
2
3import { ClaudeAI, type Icon, OpenAI } from "../Icons";
4
5/**
6 * Stacking and table order. Codex sits under Claude Code so the larger band
7 * reads as the top surface, matching the reference layout.
8 */
9export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"];
10
11export const PROVIDER_LABEL: Record<UsageProviderKind, string> = {
12 claude: "Claude Code",
13 codex: "Codex",
14};
15
16/** Claude's brand orange against a neutral white for Codex. */
17export const PROVIDER_COLOR: Record<UsageProviderKind, string> = {
18 claude: "#d97757",
19 codex: "#e6e6e6",
20};
21
22/**
23 * Brand marks, reused from the provider picker.
24 *
25 * These ship their own fills (`#d97757` for Claude, white on dark for OpenAI),
26 * which are the same colours as the chart bands, so swapping a colour dot for a
27 * mark keeps the series association intact rather than trading it away.
28 */
29export const PROVIDER_MARK: Record<UsageProviderKind, Icon> = {
30 claude: ClaudeAI,
31 codex: OpenAI,
32};
apps/web/src/routeTree.gen.ts+21
...@@ -9,6 +9,7 @@...@@ -9,6 +9,7 @@
9// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.9// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
1010
11import { Route as rootRouteImport } from './routes/__root'11import { Route as rootRouteImport } from './routes/__root'
12import { Route as UsageRouteImport } from './routes/usage'
12import { Route as SettingsRouteImport } from './routes/settings'13import { Route as SettingsRouteImport } from './routes/settings'
13import { Route as PairRouteImport } from './routes/pair'14import { Route as PairRouteImport } from './routes/pair'
14import { Route as ConnectRouteImport } from './routes/connect'15import { Route as ConnectRouteImport } from './routes/connect'
...@@ -26,6 +27,11 @@ import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback'...@@ -26,6 +27,11 @@ import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback'
26import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'27import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'
27import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'28import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'
2829
30const UsageRoute = UsageRouteImport.update({
31 id: '/usage',
32 path: '/usage',
33 getParentRoute: () => rootRouteImport,
34} as any)
29const SettingsRoute = SettingsRouteImport.update({35const SettingsRoute = SettingsRouteImport.update({
30 id: '/settings',36 id: '/settings',
31 path: '/settings',37 path: '/settings',
...@@ -112,6 +118,7 @@ export interface FileRoutesByFullPath {...@@ -112,6 +118,7 @@ export interface FileRoutesByFullPath {
112 '/connect': typeof ConnectRoute118 '/connect': typeof ConnectRoute
113 '/pair': typeof PairRoute119 '/pair': typeof PairRoute
114 '/settings': typeof SettingsRouteWithChildren120 '/settings': typeof SettingsRouteWithChildren
121 '/usage': typeof UsageRoute
115 '/connect/callback': typeof ConnectCallbackRoute122 '/connect/callback': typeof ConnectCallbackRoute
116 '/settings/appearance': typeof SettingsAppearanceRoute123 '/settings/appearance': typeof SettingsAppearanceRoute
117 '/settings/archived': typeof SettingsArchivedRoute124 '/settings/archived': typeof SettingsArchivedRoute
...@@ -128,6 +135,7 @@ export interface FileRoutesByTo {...@@ -128,6 +135,7 @@ export interface FileRoutesByTo {
128 '/connect': typeof ConnectRoute135 '/connect': typeof ConnectRoute
129 '/pair': typeof PairRoute136 '/pair': typeof PairRoute
130 '/settings': typeof SettingsRouteWithChildren137 '/settings': typeof SettingsRouteWithChildren
138 '/usage': typeof UsageRoute
131 '/connect/callback': typeof ConnectCallbackRoute139 '/connect/callback': typeof ConnectCallbackRoute
132 '/settings/appearance': typeof SettingsAppearanceRoute140 '/settings/appearance': typeof SettingsAppearanceRoute
133 '/settings/archived': typeof SettingsArchivedRoute141 '/settings/archived': typeof SettingsArchivedRoute
...@@ -147,6 +155,7 @@ export interface FileRoutesById {...@@ -147,6 +155,7 @@ export interface FileRoutesById {
147 '/connect': typeof ConnectRoute155 '/connect': typeof ConnectRoute
148 '/pair': typeof PairRoute156 '/pair': typeof PairRoute
149 '/settings': typeof SettingsRouteWithChildren157 '/settings': typeof SettingsRouteWithChildren
158 '/usage': typeof UsageRoute
150 '/connect_/callback': typeof ConnectCallbackRoute159 '/connect_/callback': typeof ConnectCallbackRoute
151 '/settings/appearance': typeof SettingsAppearanceRoute160 '/settings/appearance': typeof SettingsAppearanceRoute
152 '/settings/archived': typeof SettingsArchivedRoute161 '/settings/archived': typeof SettingsArchivedRoute
...@@ -167,6 +176,7 @@ export interface FileRouteTypes {...@@ -167,6 +176,7 @@ export interface FileRouteTypes {
167 | '/connect'176 | '/connect'
168 | '/pair'177 | '/pair'
169 | '/settings'178 | '/settings'
179 | '/usage'
170 | '/connect/callback'180 | '/connect/callback'
171 | '/settings/appearance'181 | '/settings/appearance'
172 | '/settings/archived'182 | '/settings/archived'
...@@ -183,6 +193,7 @@ export interface FileRouteTypes {...@@ -183,6 +193,7 @@ export interface FileRouteTypes {
183 | '/connect'193 | '/connect'
184 | '/pair'194 | '/pair'
185 | '/settings'195 | '/settings'
196 | '/usage'
186 | '/connect/callback'197 | '/connect/callback'
187 | '/settings/appearance'198 | '/settings/appearance'
188 | '/settings/archived'199 | '/settings/archived'
...@@ -201,6 +212,7 @@ export interface FileRouteTypes {...@@ -201,6 +212,7 @@ export interface FileRouteTypes {
201 | '/connect'212 | '/connect'
202 | '/pair'213 | '/pair'
203 | '/settings'214 | '/settings'
215 | '/usage'
204 | '/connect_/callback'216 | '/connect_/callback'
205 | '/settings/appearance'217 | '/settings/appearance'
206 | '/settings/archived'218 | '/settings/archived'
...@@ -220,11 +232,19 @@ export interface RootRouteChildren {...@@ -220,11 +232,19 @@ export interface RootRouteChildren {
220 ConnectRoute: typeof ConnectRoute232 ConnectRoute: typeof ConnectRoute
221 PairRoute: typeof PairRoute233 PairRoute: typeof PairRoute
222 SettingsRoute: typeof SettingsRouteWithChildren234 SettingsRoute: typeof SettingsRouteWithChildren
235 UsageRoute: typeof UsageRoute
223 ConnectCallbackRoute: typeof ConnectCallbackRoute236 ConnectCallbackRoute: typeof ConnectCallbackRoute
224}237}
225238
226declare module '@tanstack/react-router' {239declare module '@tanstack/react-router' {
227 interface FileRoutesByPath {240 interface FileRoutesByPath {
241 '/usage': {
242 id: '/usage'
243 path: '/usage'
244 fullPath: '/usage'
245 preLoaderRoute: typeof UsageRouteImport
246 parentRoute: typeof rootRouteImport
247 }
228 '/settings': {248 '/settings': {
229 id: '/settings'249 id: '/settings'
230 path: '/settings'250 path: '/settings'
...@@ -385,6 +405,7 @@ const rootRouteChildren: RootRouteChildren = {...@@ -385,6 +405,7 @@ const rootRouteChildren: RootRouteChildren = {
385 ConnectRoute: ConnectRoute,405 ConnectRoute: ConnectRoute,
386 PairRoute: PairRoute,406 PairRoute: PairRoute,
387 SettingsRoute: SettingsRouteWithChildren,407 SettingsRoute: SettingsRouteWithChildren,
408 UsageRoute: UsageRoute,
388 ConnectCallbackRoute: ConnectCallbackRoute,409 ConnectCallbackRoute: ConnectCallbackRoute,
389}410}
390export const routeTree = rootRouteImport411export const routeTree = rootRouteImport
apps/web/src/routes/usage.tsx created+7
...@@ -0,0 +1,7 @@
1import { createFileRoute } from "@tanstack/react-router";
2
3import { UsagePage } from "../components/usage/UsagePage";
4
5export const Route = createFileRoute("/usage")({
6 component: UsagePage,
7});
apps/web/src/state/usage.ts created+126
...@@ -0,0 +1,126 @@
1/**
2 * Multi-environment usage state.
3 *
4 * Every connected environment answers the same typed query; the client merges
5 * the results. Raw transcripts never leave the machine that produced them.
6 *
7 * @module state/usage
8 */
9import { useAtomValue } from "@effect/atom-react";
10import {
11 USAGE_CONTRACT_VERSION,
12 type EnvironmentId,
13 type UsageSummary,
14 type UsageSummaryInput,
15} from "@t3tools/contracts";
16import * as Option from "effect/Option";
17import { AsyncResult, Atom } from "effect/unstable/reactivity";
18import { useCallback, useMemo } from "react";
19
20import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "../usage/usageMerge";
21import { appAtomRegistry } from "../rpc/atomRegistry";
22import { environmentPresentations } from "./presentation";
23import { serverEnvironment } from "./server";
24
25export interface EnvironmentUsageStatus {
26 readonly environmentId: EnvironmentId;
27 readonly label: string;
28 readonly isPending: boolean;
29 readonly error: string | null;
30 readonly summary: UsageSummary | null;
31}
32
33/**
34 * Reads every environment's summary for one window.
35 *
36 * Keyed by the serialised window so switching ranges does not thrash the atom
37 * cache, and so each environment's query is shared with any other reader of the
38 * same window.
39 */
40const usageByWindowAtom = Atom.family((windowKey: string) =>
41 Atom.make((get): readonly EnvironmentUsageStatus[] => {
42 const input = JSON.parse(windowKey) as UsageSummaryInput;
43 const presentations = get(environmentPresentations.presentationsAtom);
44
45 const statuses: EnvironmentUsageStatus[] = [];
46 for (const [environmentId, presentation] of presentations) {
47 const result = get(serverEnvironment.usageSummary({ environmentId, input }));
48 statuses.push({
49 environmentId,
50 label: presentation.entry.target.label,
51 isPending: result.waiting,
52 error: result._tag === "Failure" ? "This environment could not report usage." : null,
53 summary: Option.getOrNull(AsyncResult.value(result)),
54 });
55 }
56 return statuses;
57 }).pipe(Atom.withLabel(`web-usage:window:${windowKey}`)),
58);
59
60export interface UsageView {
61 readonly merged: MergedUsage;
62 readonly environments: readonly EnvironmentUsageStatus[];
63 /** True until at least one environment has answered. */
64 readonly isPending: boolean;
65 /**
66 * True while environments that have not failed are still answering. Failed
67 * environments are reported through their own error rows: totals will not
68 * improve by waiting on them, so they must not read as "still reporting".
69 */
70 readonly isPartial: boolean;
71 readonly refresh: () => void;
72}
73
74export function useUsage(input: UsageSummaryInput): UsageView {
75 const windowKey = useMemo(
76 () =>
77 JSON.stringify({
78 sinceDay: input.sinceDay,
79 untilDay: input.untilDay,
80 timeZone: input.timeZone,
81 }),
82 [input.sinceDay, input.untilDay, input.timeZone],
83 );
84 const atom = usageByWindowAtom(windowKey);
85 const environments = useAtomValue(atom);
86
87 // Refreshing only the derived atom would re-read the per-environment SWR
88 // queries within their stale window and change nothing. Refresh each
89 // environment's query so the button always rescans.
90 const refresh = useCallback(() => {
91 const input = JSON.parse(windowKey) as UsageSummaryInput;
92 for (const environment of environments) {
93 appAtomRegistry.refresh(
94 serverEnvironment.usageSummary({ environmentId: environment.environmentId, input }),
95 );
96 }
97 }, [environments, windowKey]);
98
99 const merged = useMemo(() => {
100 const answered: EnvironmentUsage[] = environments.flatMap((environment) =>
101 environment.summary === null
102 ? []
103 : [
104 {
105 environmentId: environment.environmentId,
106 label: environment.label,
107 summary: environment.summary,
108 },
109 ],
110 );
111 return mergeUsage(answered, USAGE_CONTRACT_VERSION);
112 }, [environments]);
113
114 const answeredCount = environments.filter((environment) => environment.summary !== null).length;
115 const stillReporting = environments.filter(
116 (environment) => environment.summary === null && environment.error === null,
117 ).length;
118
119 return {
120 merged,
121 environments,
122 isPending: answeredCount === 0 && stillReporting > 0,
123 isPartial: answeredCount > 0 && stillReporting > 0,
124 refresh,
125 };
126}
apps/web/src/usage/usageFormat.ts created+107
...@@ -0,0 +1,107 @@
1/**
2 * Display formatting for the usage page.
3 *
4 * @module usageFormat
5 */
6import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts";
7
8const CURRENCY = new Intl.NumberFormat("en-US", {
9 style: "currency",
10 currency: "USD",
11 minimumFractionDigits: 2,
12 maximumFractionDigits: 2,
13});
14
15const INTEGER = new Intl.NumberFormat("en-US");
16
17export function formatUsd(value: number): string {
18 return CURRENCY.format(value);
19}
20
21export function formatCount(value: number): string {
22 return INTEGER.format(Math.round(value));
23}
24
25/**
26 * Compacts a token count to three significant figures with a unit suffix, so
27 * columns of numbers line up at a glance (`19.9B`, `76.7M`, `804K`).
28 */
29export function formatTokens(value: number): string {
30 const abs = Math.abs(value);
31 if (abs >= 1e12) return `${trim(value / 1e12)}T`;
32 if (abs >= 1e9) return `${trim(value / 1e9)}B`;
33 if (abs >= 1e6) return `${trim(value / 1e6)}M`;
34 if (abs >= 1e3) return `${trim(value / 1e3)}K`;
35 return INTEGER.format(Math.round(value));
36}
37
38function trim(value: number): string {
39 const abs = Math.abs(value);
40 const digits = abs >= 100 ? 0 : abs >= 10 ? 1 : 2;
41 return value.toFixed(digits).replace(/\.0+$/, "");
42}
43
44export function formatPercent(share: number, digits = 1): string {
45 return `${(share * 100).toFixed(digits)}%`;
46}
47
48/** `2026-08-07` to `Aug 7`. */
49export function formatDayShort(day: string): string {
50 const [year, month, dayOfMonth] = day.split("-").map((part) => Number(part));
51 if (year === undefined || month === undefined || dayOfMonth === undefined) return day;
52 const MONTHS = [
53 "Jan",
54 "Feb",
55 "Mar",
56 "Apr",
57 "May",
58 "Jun",
59 "Jul",
60 "Aug",
61 "Sep",
62 "Oct",
63 "Nov",
64 "Dec",
65 ];
66 return `${MONTHS[month - 1] ?? ""} ${dayOfMonth}`;
67}
68
69/** Inclusive day list between two `YYYY-MM-DD` bounds. */
70export function enumerateDays(sinceDay: string, untilDay: string): readonly string[] {
71 const days: string[] = [];
72 const start = Date.parse(`${sinceDay}T00:00:00Z`);
73 const end = Date.parse(`${untilDay}T00:00:00Z`);
74 if (Number.isNaN(start) || Number.isNaN(end) || end < start) return days;
75
76 for (let cursor = start; cursor <= end; cursor += 86_400_000) {
77 days.push(new Date(cursor).toISOString().slice(0, 10));
78 }
79 return days;
80}
81
82/**
83 * The window the page requests, expressed in the viewer's own time zone so days
84 * line up with what they actually experienced.
85 */
86export function makeWindow(days: number, now = new Date()): UsageSummaryInput {
87 const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
88 const format = new Intl.DateTimeFormat("en-CA", {
89 timeZone,
90 year: "numeric",
91 month: "2-digit",
92 day: "2-digit",
93 });
94 const untilDay = format.format(now);
95 // Subtracting fixed milliseconds from `now` lands on the wrong calendar day
96 // around a DST transition. Only "today" needs the zone; the window start is
97 // pure calendar arithmetic on that day, done in UTC where days are uniform.
98 const [year = 0, month = 1, dayOfMonth = 1] = untilDay
99 .split("-")
100 .map((part) => Number.parseInt(part, 10));
101 const start = new Date(Date.UTC(year, month - 1, dayOfMonth - (days - 1)));
102 return {
103 sinceDay: UsageDay.make(start.toISOString().slice(0, 10)),
104 untilDay: UsageDay.make(untilDay),
105 timeZone,
106 };
107}
apps/web/src/usage/usageMerge.test.ts created+258
...@@ -0,0 +1,258 @@
1import {
2 USAGE_CONTRACT_VERSION,
3 type EnvironmentId,
4 type UsageBucket,
5 type UsageDay,
6 type UsageProviderKind,
7 type UsageSummary,
8} from "@t3tools/contracts";
9import { describe, expect, it } from "vite-plus/test";
10
11import { mergeUsage, type EnvironmentUsage } from "./usageMerge";
12
13function bucket(overrides: Partial<UsageBucket> = {}): UsageBucket {
14 return {
15 day: "2026-08-07" as UsageDay,
16 provider: "claude",
17 model: "claude-fable-5",
18 totals: {
19 uncachedInputTokens: 100,
20 cachedInputTokens: 1000,
21 cacheCreationTokens: 10,
22 outputTokens: 50,
23 reasoningTokens: 0,
24 },
25 costUsd: 10,
26 cacheSavingsUsd: 2,
27 costSource: "modelPriced",
28 records: 5,
29 unpricedRecords: 0,
30 sessions: 1,
31 ...overrides,
32 };
33}
34
35function summary(
36 buckets: readonly UsageBucket[],
37 sources: readonly {
38 provider: UsageProviderKind;
39 hostId: string;
40 homePath: string;
41 volumeId?: string;
42 distinctSessions?: number;
43 }[],
44 contractVersion: number = USAGE_CONTRACT_VERSION,
45): UsageSummary {
46 return {
47 contractVersion,
48 readAt: "2026-08-07T00:00:00.000Z",
49 timeZone: "UTC",
50 sinceDay: "2026-08-01" as UsageDay,
51 untilDay: "2026-08-31" as UsageDay,
52 buckets,
53 sources: sources.map((source) => ({
54 fingerprint: {
55 hostId: source.hostId,
56 provider: source.provider,
57 resolvedHomePath: source.homePath,
58 volumeId: source.volumeId ?? `vol-${source.hostId}`,
59 },
60 status: "ok" as const,
61 scannedFiles: 1,
62 skippedFiles: 0,
63 malformedRecords: 0,
64 distinctSessions: source.distinctSessions ?? 1,
65 message: null,
66 })),
67 pricing: { status: "fresh", source: "litellm", fetchedAt: null, knownModels: 10 },
68 scanDurationMs: 1,
69 };
70}
71
72function environment(id: string, usageSummary: UsageSummary): EnvironmentUsage {
73 return { environmentId: id as EnvironmentId, label: id, summary: usageSummary };
74}
75
76describe("mergeUsage", () => {
77 it("sums environments that read different transcript directories", () => {
78 const merged = mergeUsage(
79 [
80 environment(
81 "env-a",
82 summary([bucket()], [{ provider: "claude", hostId: "mac", homePath: "/a/.claude" }]),
83 ),
84 environment(
85 "env-b",
86 summary([bucket()], [{ provider: "claude", hostId: "linux", homePath: "/b/.claude" }]),
87 ),
88 ],
89 USAGE_CONTRACT_VERSION,
90 );
91
92 expect(merged.costUsd).toBe(20);
93 expect(merged.records).toBe(10);
94 expect(merged.duplicateSources).toHaveLength(0);
95 });
96
97 it("counts a shared transcript directory once", () => {
98 // Two worktree servers on one machine resolve the same provider home.
99 const shared = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" };
100 const merged = mergeUsage(
101 [
102 environment("env-a", summary([bucket()], [shared])),
103 environment("env-b", summary([bucket()], [shared])),
104 ],
105 USAGE_CONTRACT_VERSION,
106 );
107
108 expect(merged.costUsd).toBe(10);
109 expect(merged.records).toBe(5);
110 expect(merged.sessions).toBe(1);
111 expect(merged.duplicateSources).toHaveLength(1);
112 expect(merged.contributingEnvironments).toEqual(["env-a"]);
113 });
114
115 it("drops only the duplicated provider, keeping the environment's other one", () => {
116 const sharedClaude = {
117 provider: "claude" as const,
118 hostId: "mac",
119 homePath: "/home/theo/.claude",
120 };
121 const merged = mergeUsage(
122 [
123 environment("env-a", summary([bucket()], [sharedClaude])),
124 environment(
125 "env-b",
126 summary(
127 [bucket(), bucket({ provider: "codex", model: "gpt-5.6-sol", costUsd: 4 })],
128 [sharedClaude, { provider: "codex", hostId: "mac", homePath: "/home/theo/.codex" }],
129 ),
130 ),
131 ],
132 USAGE_CONTRACT_VERSION,
133 );
134
135 // env-b's claude bucket is dropped, its codex bucket survives.
136 expect(merged.costUsd).toBe(14);
137 expect(merged.providers.map((provider) => provider.provider).sort()).toEqual([
138 "claude",
139 "codex",
140 ]);
141 });
142
143 it("excludes an environment reporting an older contract version", () => {
144 const merged = mergeUsage(
145 [
146 environment(
147 "env-a",
148 summary([bucket()], [{ provider: "claude", hostId: "mac", homePath: "/a" }]),
149 ),
150 environment(
151 "env-b",
152 summary(
153 [bucket()],
154 [{ provider: "claude", hostId: "linux", homePath: "/b" }],
155 USAGE_CONTRACT_VERSION - 1,
156 ),
157 ),
158 ],
159 USAGE_CONTRACT_VERSION,
160 );
161
162 expect(merged.costUsd).toBe(10);
163 expect(merged.staleEnvironments).toEqual(["env-b"]);
164 });
165
166 it("derives provider shares and cost quality", () => {
167 const merged = mergeUsage(
168 [
169 environment(
170 "env-a",
171 summary(
172 [
173 bucket({ costUsd: 75 }),
174 bucket({ provider: "codex", model: "gpt-5.6-sol", costUsd: 25, unpricedRecords: 5 }),
175 ],
176 [
177 { provider: "claude", hostId: "mac", homePath: "/a/.claude" },
178 { provider: "codex", hostId: "mac", homePath: "/a/.codex" },
179 ],
180 ),
181 ),
182 ],
183 USAGE_CONTRACT_VERSION,
184 );
185
186 expect(merged.providers[0]?.provider).toBe("claude");
187 expect(merged.providers[0]?.costShare).toBeCloseTo(0.75, 5);
188 expect(merged.costQuality.unpricedShare).toBeCloseTo(0.5, 5);
189 expect(merged.costQuality.cacheSavingsUsd).toBe(4);
190 });
191
192 it("keeps two machines apart when hostname and home path collide", () => {
193 // Every Mac resolves /Users/theo/.claude, so a hostname clash used to make
194 // one machine's usage vanish. Filesystem identity separates them.
195 const shape = { provider: "claude" as const, hostId: "mac", homePath: "/Users/theo/.claude" };
196 const merged = mergeUsage(
197 [
198 environment("env-a", summary([bucket()], [{ ...shape, volumeId: "16777220:1234" }])),
199 environment("env-b", summary([bucket()], [{ ...shape, volumeId: "16777221:9999" }])),
200 ],
201 USAGE_CONTRACT_VERSION,
202 );
203
204 expect(merged.costUsd).toBe(20);
205 expect(merged.duplicateSources).toHaveLength(0);
206 });
207
208 it("still collapses two servers reading the same directory", () => {
209 const same = {
210 provider: "claude" as const,
211 hostId: "mac",
212 homePath: "/Users/theo/.claude",
213 volumeId: "16777220:1234",
214 };
215 const merged = mergeUsage(
216 [
217 environment("env-a", summary([bucket()], [same])),
218 environment("env-b", summary([bucket()], [same])),
219 ],
220 USAGE_CONTRACT_VERSION,
221 );
222
223 expect(merged.costUsd).toBe(10);
224 expect(merged.duplicateSources).toHaveLength(1);
225 });
226
227 it("totals sessions from per-directory distinct counts, not per-bucket sums", () => {
228 // One session that spans two days appears in two buckets. Summing bucket
229 // sessions would say 2; the source's distinct count says 1.
230 const merged = mergeUsage(
231 [
232 environment(
233 "env-a",
234 summary(
235 [bucket({ day: "2026-08-06" as UsageDay }), bucket({ day: "2026-08-07" as UsageDay })],
236 [
237 {
238 provider: "claude",
239 hostId: "mac",
240 homePath: "/a/.claude",
241 distinctSessions: 1,
242 },
243 ],
244 ),
245 ),
246 ],
247 USAGE_CONTRACT_VERSION,
248 );
249
250 expect(merged.sessions).toBe(1);
251 });
252
253 it("returns empty totals with no environments", () => {
254 const merged = mergeUsage([], USAGE_CONTRACT_VERSION);
255 expect(merged.costUsd).toBe(0);
256 expect(merged.daily).toHaveLength(0);
257 });
258});
apps/web/src/usage/usageMerge.ts created+353
...@@ -0,0 +1,353 @@
1/**
2 * Merges per-environment usage summaries into the single view the page renders.
3 *
4 * Pure, so the de-duplication and derivation rules can be tested without a
5 * connected environment.
6 *
7 * @module usageMerge
8 */
9import type {
10 EnvironmentId,
11 UsageBucket,
12 UsageProviderKind,
13 UsageSourceFingerprint,
14 UsageSummary,
15} from "@t3tools/contracts";
16
17export interface EnvironmentUsage {
18 readonly environmentId: EnvironmentId;
19 readonly label: string;
20 readonly summary: UsageSummary;
21}
22
23export interface ProviderTotals {
24 readonly provider: UsageProviderKind;
25 readonly costUsd: number;
26 readonly totalTokens: number;
27 readonly records: number;
28 readonly costShare: number;
29 readonly tokenShare: number;
30}
31
32export interface ModelTotals {
33 readonly model: string;
34 readonly provider: UsageProviderKind;
35 readonly costUsd: number;
36 readonly totalTokens: number;
37 readonly records: number;
38 readonly costShare: number;
39}
40
41export interface DailyTotals {
42 readonly day: string;
43 readonly costUsd: number;
44 readonly totalTokens: number;
45 readonly byProvider: ReadonlyMap<UsageProviderKind, { costUsd: number; totalTokens: number }>;
46}
47
48export interface CostQuality {
49 readonly providerReportedShare: number;
50 readonly modelPricedShare: number;
51 readonly unpricedShare: number;
52 readonly cacheSavingsUsd: number;
53}
54
55export interface MergedUsage {
56 readonly costUsd: number;
57 readonly uncachedInputTokens: number;
58 readonly cachedInputTokens: number;
59 readonly cacheCreationTokens: number;
60 readonly outputTokens: number;
61 readonly reasoningTokens: number;
62 readonly totalTokens: number;
63 readonly records: number;
64 readonly sessions: number;
65 readonly providers: readonly ProviderTotals[];
66 readonly models: readonly ModelTotals[];
67 readonly daily: readonly DailyTotals[];
68 readonly costQuality: CostQuality;
69 /** Environments whose data was dropped as a duplicate of another's. */
70 readonly duplicateSources: readonly string[];
71 readonly contributingEnvironments: readonly EnvironmentId[];
72 readonly staleEnvironments: readonly EnvironmentId[];
73}
74
75/**
76 * Two sources are the same physical transcript directory only when host,
77 * provider, path and filesystem identity all agree.
78 *
79 * `volumeId` is what stops two machines that happen to share a hostname and a
80 * home path, which is every Mac in a fleet, from collapsing into one source and
81 * having one of them silently dropped.
82 */
83function fingerprintKey(fingerprint: UsageSourceFingerprint): string {
84 return [
85 fingerprint.hostId,
86 fingerprint.provider,
87 fingerprint.resolvedHomePath,
88 fingerprint.volumeId,
89 ].join(" ");
90}
91
92/**
93 * Decides which environment owns each physical transcript directory.
94 *
95 * Several environments on one machine (worktree servers, for instance) resolve
96 * the same provider home and would otherwise double count every token. The
97 * first environment in a stable order claims a fingerprint; the rest have that
98 * provider's buckets dropped. Environments are sorted by id so the winner does
99 * not change between renders.
100 */
101function claimSources(environments: readonly EnvironmentUsage[]): {
102 readonly ownerByFingerprint: ReadonlyMap<string, EnvironmentId>;
103 readonly duplicates: readonly string[];
104} {
105 const ownerByFingerprint = new Map<string, EnvironmentId>();
106 const duplicates: string[] = [];
107
108 const ordered = [...environments].sort((a, b) => a.environmentId.localeCompare(b.environmentId));
109
110 for (const environment of ordered) {
111 for (const source of environment.summary.sources) {
112 if (source.status === "missing") continue;
113 const key = fingerprintKey(source.fingerprint);
114 if (ownerByFingerprint.has(key)) {
115 duplicates.push(`${environment.label}: ${source.fingerprint.resolvedHomePath}`);
116 continue;
117 }
118 ownerByFingerprint.set(key, environment.environmentId);
119 }
120 }
121
122 return { ownerByFingerprint, duplicates };
123}
124
125/** Sources this environment owns after fingerprint claims, plus their buckets. */
126function ownedContribution(
127 environment: EnvironmentUsage,
128 ownerByFingerprint: ReadonlyMap<string, EnvironmentId>,
129): { readonly buckets: readonly UsageBucket[]; readonly sessions: number } {
130 const ownedProviders = new Set<UsageProviderKind>();
131 let sessions = 0;
132 for (const source of environment.summary.sources) {
133 if (source.status === "missing") continue;
134 const key = fingerprintKey(source.fingerprint);
135 if (ownerByFingerprint.get(key) === environment.environmentId) {
136 ownedProviders.add(source.fingerprint.provider);
137 // Distinct within a directory. Summing per-bucket session counts instead
138 // would count a session once per day and model it spans.
139 sessions += source.distinctSessions;
140 }
141 }
142 return {
143 buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)),
144 sessions,
145 };
146}
147
148function bucketTokens(bucket: UsageBucket): number {
149 // reasoningTokens is a subset of outputTokens and must not be added again.
150 return (
151 bucket.totals.uncachedInputTokens +
152 bucket.totals.cachedInputTokens +
153 bucket.totals.cacheCreationTokens +
154 bucket.totals.outputTokens
155 );
156}
157
158const EMPTY_MERGED: MergedUsage = {
159 costUsd: 0,
160 uncachedInputTokens: 0,
161 cachedInputTokens: 0,
162 cacheCreationTokens: 0,
163 outputTokens: 0,
164 reasoningTokens: 0,
165 totalTokens: 0,
166 records: 0,
167 sessions: 0,
168 providers: [],
169 models: [],
170 daily: [],
171 costQuality: {
172 providerReportedShare: 0,
173 modelPricedShare: 0,
174 unpricedShare: 0,
175 cacheSavingsUsd: 0,
176 },
177 duplicateSources: [],
178 contributingEnvironments: [],
179 staleEnvironments: [],
180};
181
182/**
183 * Merges every connected environment's summary.
184 *
185 * `expectedContractVersion` guards against an environment running older server
186 * code: rather than blocking the page, its data is excluded and its id is
187 * reported so the UI can say coverage is partial.
188 */
189export function mergeUsage(
190 environments: readonly EnvironmentUsage[],
191 expectedContractVersion: number,
192): MergedUsage {
193 if (environments.length === 0) return EMPTY_MERGED;
194
195 const current: EnvironmentUsage[] = [];
196 const staleEnvironments: EnvironmentId[] = [];
197 for (const environment of environments) {
198 if (environment.summary.contractVersion === expectedContractVersion) {
199 current.push(environment);
200 } else {
201 staleEnvironments.push(environment.environmentId);
202 }
203 }
204
205 const { ownerByFingerprint, duplicates } = claimSources(current);
206
207 let costUsd = 0;
208 let uncachedInputTokens = 0;
209 let cachedInputTokens = 0;
210 let cacheCreationTokens = 0;
211 let outputTokens = 0;
212 let reasoningTokens = 0;
213 let records = 0;
214 let sessions = 0;
215 let cacheSavingsUsd = 0;
216 let providerReportedRecords = 0;
217 let unpricedRecords = 0;
218
219 const providerAccumulator = new Map<
220 UsageProviderKind,
221 { costUsd: number; totalTokens: number; records: number }
222 >();
223 const modelAccumulator = new Map<
224 string,
225 { provider: UsageProviderKind; costUsd: number; totalTokens: number; records: number }
226 >();
227 const dailyAccumulator = new Map<
228 string,
229 {
230 costUsd: number;
231 totalTokens: number;
232 byProvider: Map<UsageProviderKind, { costUsd: number; totalTokens: number }>;
233 }
234 >();
235 const contributingEnvironments: EnvironmentId[] = [];
236
237 for (const environment of current) {
238 const { buckets, sessions: environmentSessions } = ownedContribution(
239 environment,
240 ownerByFingerprint,
241 );
242 if (buckets.length > 0) contributingEnvironments.push(environment.environmentId);
243 sessions += environmentSessions;
244
245 for (const bucket of buckets) {
246 const tokens = bucketTokens(bucket);
247
248 costUsd += bucket.costUsd;
249 cacheSavingsUsd += bucket.cacheSavingsUsd;
250 uncachedInputTokens += bucket.totals.uncachedInputTokens;
251 cachedInputTokens += bucket.totals.cachedInputTokens;
252 cacheCreationTokens += bucket.totals.cacheCreationTokens;
253 outputTokens += bucket.totals.outputTokens;
254 reasoningTokens += bucket.totals.reasoningTokens;
255 records += bucket.records;
256 unpricedRecords += bucket.unpricedRecords;
257 if (bucket.costSource === "providerReported") providerReportedRecords += bucket.records;
258
259 const provider = providerAccumulator.get(bucket.provider) ?? {
260 costUsd: 0,
261 totalTokens: 0,
262 records: 0,
263 };
264 provider.costUsd += bucket.costUsd;
265 provider.totalTokens += tokens;
266 provider.records += bucket.records;
267 providerAccumulator.set(bucket.provider, provider);
268
269 const modelKey = `${bucket.provider} ${bucket.model}`;
270 const model = modelAccumulator.get(modelKey) ?? {
271 provider: bucket.provider,
272 costUsd: 0,
273 totalTokens: 0,
274 records: 0,
275 };
276 model.costUsd += bucket.costUsd;
277 model.totalTokens += tokens;
278 model.records += bucket.records;
279 modelAccumulator.set(modelKey, model);
280
281 const day = dailyAccumulator.get(bucket.day) ?? {
282 costUsd: 0,
283 totalTokens: 0,
284 byProvider: new Map<UsageProviderKind, { costUsd: number; totalTokens: number }>(),
285 };
286 day.costUsd += bucket.costUsd;
287 day.totalTokens += tokens;
288 const dayProvider = day.byProvider.get(bucket.provider) ?? { costUsd: 0, totalTokens: 0 };
289 dayProvider.costUsd += bucket.costUsd;
290 dayProvider.totalTokens += tokens;
291 day.byProvider.set(bucket.provider, dayProvider);
292 dailyAccumulator.set(bucket.day, day);
293 }
294 }
295
296 const totalTokens = uncachedInputTokens + cachedInputTokens + cacheCreationTokens + outputTokens;
297
298 const providers: ProviderTotals[] = [...providerAccumulator.entries()]
299 .map(([provider, totals]) => ({
300 provider,
301 costUsd: totals.costUsd,
302 totalTokens: totals.totalTokens,
303 records: totals.records,
304 costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,
305 tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens,
306 }))
307 .sort((a, b) => b.costUsd - a.costUsd);
308
309 const models: ModelTotals[] = [...modelAccumulator.entries()]
310 .map(([key, totals]) => ({
311 model: key.slice(key.indexOf(" ") + 1),
312 provider: totals.provider,
313 costUsd: totals.costUsd,
314 totalTokens: totals.totalTokens,
315 records: totals.records,
316 costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,
317 }))
318 .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens);
319
320 const daily: DailyTotals[] = [...dailyAccumulator.entries()]
321 .map(([day, totals]) => ({
322 day,
323 costUsd: totals.costUsd,
324 totalTokens: totals.totalTokens,
325 byProvider: totals.byProvider,
326 }))
327 .sort((a, b) => a.day.localeCompare(b.day));
328
329 return {
330 costUsd,
331 uncachedInputTokens,
332 cachedInputTokens,
333 cacheCreationTokens,
334 outputTokens,
335 reasoningTokens,
336 totalTokens,
337 records,
338 sessions,
339 providers,
340 models,
341 daily,
342 costQuality: {
343 providerReportedShare: records === 0 ? 0 : providerReportedRecords / records,
344 unpricedShare: records === 0 ? 0 : unpricedRecords / records,
345 modelPricedShare:
346 records === 0 ? 0 : (records - providerReportedRecords - unpricedRecords) / records,
347 cacheSavingsUsd,
348 },
349 duplicateSources: duplicates,
350 contributingEnvironments,
351 staleEnvironments,
352 };
353}
packages/client-runtime/src/state/server.ts+7
...@@ -707,6 +707,13 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -707,6 +707,13 @@ export function createServerEnvironmentAtoms<R, E>(
707 tag: WS_METHODS.serverGetResourceTelemetryHistory,707 tag: WS_METHODS.serverGetResourceTelemetryHistory,
708 staleTimeMs: 5_000,708 staleTimeMs: 5_000,
709 }),709 }),
710 // A cold transcript scan is measured in seconds, so keep the result around
711 // long enough that switching windows or re-rendering does not rescan.
712 usageSummary: createEnvironmentRpcQueryAtomFamily(runtime, {
713 label: "environment-data:server:usage-summary",
714 tag: WS_METHODS.serverGetUsageSummary,
715 staleTimeMs: 60_000,
716 }),
710 configProjection,717 configProjection,
711 welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, {718 welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
712 label: "environment-data:server:welcome",719 label: "environment-data:server:welcome",
packages/contracts/src/index.ts+1
...@@ -28,4 +28,5 @@ export * from "./review.ts";...@@ -28,4 +28,5 @@ export * from "./review.ts";
28export * from "./preview.ts";28export * from "./preview.ts";
29export * from "./previewAutomation.ts";29export * from "./previewAutomation.ts";
30export * from "./resourceTelemetry.ts";30export * from "./resourceTelemetry.ts";
31export * from "./usage.ts";
31export * from "./rpc.ts";32export * from "./rpc.ts";
packages/contracts/src/rpc.ts+9
...@@ -152,6 +152,7 @@ import {...@@ -152,6 +152,7 @@ import {
152 ResourceTelemetryRetryResult,152 ResourceTelemetryRetryResult,
153 ResourceTelemetrySnapshot,153 ResourceTelemetrySnapshot,
154} from "./resourceTelemetry.ts";154} from "./resourceTelemetry.ts";
155import { UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts";
155import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts";156import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts";
156import {157import {
157 SourceControlCloneRepositoryInput,158 SourceControlCloneRepositoryInput,
...@@ -244,6 +245,7 @@ export const WS_METHODS = {...@@ -244,6 +245,7 @@ export const WS_METHODS = {
244 serverReportClientActivity: "server.reportClientActivity",245 serverReportClientActivity: "server.reportClientActivity",
245 serverReportHostPowerState: "server.reportHostPowerState",246 serverReportHostPowerState: "server.reportHostPowerState",
246 serverGetBackgroundPolicy: "server.getBackgroundPolicy",247 serverGetBackgroundPolicy: "server.getBackgroundPolicy",
248 serverGetUsageSummary: "server.getUsageSummary",
247249
248 // Cloud environment methods250 // Cloud environment methods
249 cloudGetRelayClientStatus: "cloud.getRelayClientStatus",251 cloudGetRelayClientStatus: "cloud.getRelayClientStatus",
...@@ -381,6 +383,12 @@ export const WsServerRetryResourceTelemetryRpc = Rpc.make(WS_METHODS.serverRetry...@@ -381,6 +383,12 @@ export const WsServerRetryResourceTelemetryRpc = Rpc.make(WS_METHODS.serverRetry
381 error: EnvironmentAuthorizationError,383 error: EnvironmentAuthorizationError,
382});384});
383385
386export const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSummary, {
387 payload: UsageSummaryInput,
388 success: UsageSummary,
389 error: Schema.Union([EnvironmentAuthorizationError, UsageReadError]),
390});
391
384export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, {392export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, {
385 payload: ServerSignalProcessInput,393 payload: ServerSignalProcessInput,
386 success: ServerSignalProcessResult,394 success: ServerSignalProcessResult,
...@@ -819,6 +827,7 @@ export const WsRpcGroup = RpcGroup.make(...@@ -819,6 +827,7 @@ export const WsRpcGroup = RpcGroup.make(
819 WsServerGetProcessResourceHistoryRpc,827 WsServerGetProcessResourceHistoryRpc,
820 WsServerGetResourceTelemetryHistoryRpc,828 WsServerGetResourceTelemetryHistoryRpc,
821 WsServerRetryResourceTelemetryRpc,829 WsServerRetryResourceTelemetryRpc,
830 WsServerGetUsageSummaryRpc,
822 WsServerSignalProcessRpc,831 WsServerSignalProcessRpc,
823 WsServerReportClientActivityRpc,832 WsServerReportClientActivityRpc,
824 WsServerReportHostPowerStateRpc,833 WsServerReportHostPowerStateRpc,
packages/contracts/src/usage.ts created+194
...@@ -0,0 +1,194 @@
1/**
2 * Usage reporting contract.
3 *
4 * Each environment scans the provider CLIs' own on-disk session transcripts
5 * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`) rather than
6 * relying on T3 Code's own orchestration projections, so usage stays complete
7 * even for turns that were never driven through T3 Code. This mirrors the
8 * approach `ccusage` takes.
9 *
10 * Environments return pre-aggregated `(day, provider, model)` buckets. Raw
11 * transcript records never cross the wire.
12 *
13 * @module usage
14 */
15import * as Schema from "effect/Schema";
16
17import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
18
19/**
20 * Bumped whenever the shape of {@link UsageSummary} changes incompatibly. The
21 * client renders partial coverage when an environment reports an older version
22 * rather than failing the whole page.
23 */
24export const USAGE_CONTRACT_VERSION = 3 as const;
25
26export const UsageProviderKind = Schema.Literals(["claude", "codex"]);
27export type UsageProviderKind = typeof UsageProviderKind.Type;
28
29/**
30 * A calendar day in the reporting time zone, formatted `YYYY-MM-DD`.
31 *
32 * Days are bucketed server-side so that a turn always lands on the day the user
33 * experienced it, not the UTC day.
34 */
35const USAGE_DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
36
37export const UsageDay = TrimmedNonEmptyString.check(Schema.isPattern(USAGE_DAY_PATTERN)).pipe(
38 Schema.brand("UsageDay"),
39);
40export type UsageDay = typeof UsageDay.Type;
41
42/**
43 * Why a bucket's cost is what it is.
44 *
45 * - `providerReported` - the transcript carried an explicit cost figure.
46 * - `modelPriced` - we matched the model against the LiteLLM rate table.
47 * - `unpriced` - tokens are known, rates are not. Counted in totals, excluded
48 * from cost.
49 */
50export const UsageCostSource = Schema.Literals(["providerReported", "modelPriced", "unpriced"]);
51export type UsageCostSource = typeof UsageCostSource.Type;
52
53/**
54 * Token counts for a bucket.
55 *
56 * `cachedInputTokens` and `cacheCreationTokens` are disjoint from
57 * `uncachedInputTokens`; summing all three gives total input. `reasoningTokens`
58 * is a *subset* of `outputTokens` (Codex reports it that way, and Anthropic
59 * folds thinking into output), so it must never be added on top.
60 */
61export const UsageTokenTotals = Schema.Struct({
62 uncachedInputTokens: NonNegativeInt,
63 cachedInputTokens: NonNegativeInt,
64 cacheCreationTokens: NonNegativeInt,
65 outputTokens: NonNegativeInt,
66 reasoningTokens: NonNegativeInt,
67});
68export type UsageTokenTotals = typeof UsageTokenTotals.Type;
69
70/**
71 * One `(day, provider, model)` cell.
72 *
73 * `costUsd` is the raw API-equivalent cost of these tokens. It is not money
74 * spent: subscription plans bill separately. `unpricedRecords` counts records
75 * whose tokens are included in the token totals but which contributed nothing
76 * to `costUsd`.
77 */
78export const UsageBucket = Schema.Struct({
79 day: UsageDay,
80 provider: UsageProviderKind,
81 model: TrimmedNonEmptyString,
82 totals: UsageTokenTotals,
83 costUsd: Schema.Number,
84 /**
85 * What the cached input would have cost at full input rates minus what it
86 * actually cost. Requires the rate table, so it is computed alongside cost
87 * rather than derived on the client.
88 */
89 cacheSavingsUsd: Schema.Number,
90 costSource: UsageCostSource,
91 /** Distinct assistant responses, after de-duplication. */
92 records: NonNegativeInt,
93 unpricedRecords: NonNegativeInt,
94 /** Distinct transcript sessions that contributed to this cell. */
95 sessions: NonNegativeInt,
96});
97export type UsageBucket = typeof UsageBucket.Type;
98
99/**
100 * Identifies the physical transcript directory a source read from.
101 *
102 * Two environments on the same machine (worktree servers, for example) resolve
103 * the same provider home and would otherwise double count. The client drops
104 * duplicate fingerprints before merging.
105 */
106export const UsageSourceFingerprint = Schema.Struct({
107 hostId: TrimmedNonEmptyString,
108 provider: UsageProviderKind,
109 resolvedHomePath: TrimmedNonEmptyString,
110 /**
111 * Filesystem identity of the transcript directory, as `device:inode`.
112 *
113 * Hostname and path alone are not enough: every Mac in a fleet resolves
114 * `/Users/<user>/.claude`, so two machines that happen to share a hostname
115 * would look like one source and have their usage silently dropped. The
116 * device/inode pair is stable for two servers reading the same directory and
117 * effectively never collides across machines. Empty when it cannot be read.
118 */
119 volumeId: Schema.String,
120});
121export type UsageSourceFingerprint = typeof UsageSourceFingerprint.Type;
122
123export const UsageSourceStatus = Schema.Literals(["ok", "missing", "partial", "failed"]);
124export type UsageSourceStatus = typeof UsageSourceStatus.Type;
125
126export const UsageSource = Schema.Struct({
127 fingerprint: UsageSourceFingerprint,
128 status: UsageSourceStatus,
129 scannedFiles: NonNegativeInt,
130 skippedFiles: NonNegativeInt,
131 /** Records that parsed but carried no recognisable usage payload. */
132 malformedRecords: NonNegativeInt,
133 /**
134 * Distinct transcript sessions seen under this directory. Buckets also carry
135 * per-bucket session counts, but a session spans days and models, so summing
136 * those overcounts; this is the figure clients should total.
137 */
138 distinctSessions: NonNegativeInt,
139 message: Schema.NullOr(TrimmedNonEmptyString),
140});
141export type UsageSource = typeof UsageSource.Type;
142
143export const UsagePricingStatus = Schema.Literals(["fresh", "cached", "unavailable"]);
144export type UsagePricingStatus = typeof UsagePricingStatus.Type;
145
146/**
147 * Provenance for the rate table, so the UI can be honest about how good the
148 * cost figures are.
149 */
150export const UsagePricing = Schema.Struct({
151 status: UsagePricingStatus,
152 source: TrimmedNonEmptyString,
153 fetchedAt: Schema.NullOr(Schema.String),
154 knownModels: NonNegativeInt,
155});
156export type UsagePricing = typeof UsagePricing.Type;
157
158export const UsageSummaryInput = Schema.Struct({
159 /** Inclusive first day of the window, in `timeZone`. */
160 sinceDay: UsageDay,
161 /** Inclusive last day of the window, in `timeZone`. */
162 untilDay: UsageDay,
163 /**
164 * IANA zone the client wants days bucketed in. An offset would be wrong for
165 * any window that crosses a DST boundary.
166 */
167 timeZone: TrimmedNonEmptyString,
168});
169export type UsageSummaryInput = typeof UsageSummaryInput.Type;
170
171export const UsageSummary = Schema.Struct({
172 contractVersion: Schema.Number,
173 readAt: Schema.String,
174 timeZone: TrimmedNonEmptyString,
175 sinceDay: UsageDay,
176 untilDay: UsageDay,
177 buckets: Schema.Array(UsageBucket),
178 sources: Schema.Array(UsageSource),
179 pricing: UsagePricing,
180 /** Wall-clock cost of the scan, surfaced in diagnostics. */
181 scanDurationMs: NonNegativeInt,
182});
183export type UsageSummary = typeof UsageSummary.Type;
184
185export class UsageReadError extends Schema.TaggedErrorClass<UsageReadError>()("UsageReadError", {
186 reason: Schema.Literals(["scanFailed", "invalidWindow"]),
187 /** Stable, bounded description. The underlying failure travels in `cause`. */
188 detail: TrimmedNonEmptyString,
189 cause: Schema.optional(Schema.Defect()),
190}) {
191 override get message(): string {
192 return `Usage read failed (${this.reason}): ${this.detail}`;
193 }
194}