From 0f26dd848a8c4fbba2ddadd8581727182f7fac2a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 23:23:45 -0700 Subject: [PATCH 01/12] feat(server): scan provider transcripts for usage and price them Adds a usage contract and a server-side scanner that reads the Claude Code and Codex CLIs' own session transcripts rather than T3 Code's orchestration projections, so usage covers turns driven outside T3 Code. Tokens are priced against LiteLLM's rate table, the same source ccusage uses. Co-Authored-By: Claude Opus 5 (1M context) --- apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/server.test.ts | 2 + apps/server/src/server.ts | 4 + apps/server/src/usage/UsageService.ts | 325 ++++++++++++++++++ apps/server/src/usage/usageAggregation.ts | Bin 0 -> 5521 bytes apps/server/src/usage/usagePricing.ts | 148 ++++++++ .../server/src/usage/usageTranscriptReader.ts | 119 +++++++ apps/server/src/usage/usageTranscripts.ts | 242 +++++++++++++ apps/server/src/ws.ts | 6 + packages/contracts/src/index.ts | 1 + packages/contracts/src/rpc.ts | 9 + packages/contracts/src/usage.ts | 166 +++++++++ 12 files changed, 1023 insertions(+) create mode 100644 apps/server/src/usage/UsageService.ts create mode 100644 apps/server/src/usage/usageAggregation.ts create mode 100644 apps/server/src/usage/usagePricing.ts create mode 100644 apps/server/src/usage/usageTranscriptReader.ts create mode 100644 apps/server/src/usage/usageTranscripts.ts create mode 100644 packages/contracts/src/usage.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 2aa057ee0ca..4ad28691a4f 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -45,6 +45,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, + [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope, [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope, [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, [WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 4ddb01e09dd..a14274fc793 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -149,6 +149,7 @@ import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryR import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClient.ts"; import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; +import * as UsageService from "./usage/UsageService.ts"; import * as Data from "effect/Data"; import { makeOrchestrationIntegrationHarness } from "../integration/OrchestrationEngineHarness.integration.ts"; @@ -825,6 +826,7 @@ const buildAppUnderTest = (options?: { const appLayer = servedRoutesLayer.pipe( Layer.provide(resourceTelemetryLayer), + Layer.provide(UsageService.UsageService.layerTest), Layer.provide( Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({ record: () => Effect.void, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 1d824afbd1b..ff21c07a861 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -101,6 +101,7 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; +import * as UsageService from "./usage/UsageService.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { clearPersistedServerRuntimeState, @@ -158,6 +159,8 @@ const BackgroundLayerLive = BackgroundPolicy.layer.pipe( Layer.provideMerge(ServerSettingsLayerLive), ); +const UsageLayerLive = UsageService.layer.pipe(Layer.provide(ServerSettingsLayerLive)); + const ResourceDiagnosticsLayerLive = Layer.mergeAll( ResourceTelemetryLayerLive, ProcessDiagnostics.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), @@ -410,6 +413,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( // Misc. Layer.provideMerge(BackgroundLayerLive), Layer.provideMerge(ResourceDiagnosticsLayerLive), + Layer.provideMerge(UsageLayerLive), Layer.provideMerge(TraceDiagnostics.layer), Layer.provideMerge(AnalyticsService.layer), Layer.provideMerge(ExternalLauncher.layer), diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts new file mode 100644 index 00000000000..e49ae8e7fb9 --- /dev/null +++ b/apps/server/src/usage/UsageService.ts @@ -0,0 +1,325 @@ +/** + * UsageService - scans provider transcripts and returns priced daily usage. + * + * The scan reads the provider CLIs' own session files rather than T3 Code's + * orchestration projections, so usage covers turns driven outside T3 Code too. + * This is the approach `ccusage` takes. + * + * Transcripts are append-only, so parsed records are memoised per file by + * `(size, mtime)`. A cold 30-day scan of ~1.4 GB lands around 2-3 seconds; warm + * scans only reparse files that changed. + * + * @module UsageService + */ +import * as NodeOS from "node:os"; + +import { + USAGE_CONTRACT_VERSION, + type UsageProviderKind, + type UsageSource, + type UsageSummary, + type UsageSummaryInput, + UsageReadError, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import { ServerConfig } from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; +import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { UsageAggregator } from "./usageAggregation.ts"; +import { parseRateTable, type RateTable } from "./usagePricing.ts"; +import { listTranscriptFiles, readTranscriptRecords } from "./usageTranscriptReader.ts"; +import type { UsageRecord } from "./usageTranscripts.ts"; + +const LITELLM_RATES_URL = + "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"; + +/** Rates move rarely; a day-old table keeps the page working offline. */ +const RATES_TTL_MS = 24 * 60 * 60 * 1000; + +/** + * Files are filtered by mtime before opening. The slack covers a session whose + * last write lands just before local midnight on the window's first day. + */ +const MTIME_SLACK_MS = 36 * 60 * 60 * 1000; + +/** Bounds the memo cache so a long-lived server cannot grow without limit. */ +const MAX_CACHED_RECORDS = 400_000; + +/** On-disk shape of the rate snapshot. */ +const RatesCacheFile = Schema.Struct({ + fetchedAtMs: Schema.Number, + document: Schema.Unknown, +}); +const decodeRatesCache = Schema.decodeUnknownEffect( + Schema.fromJsonString(RatesCacheFile as unknown as Schema.Codec), +); +const encodeRatesCache = Schema.encodeEffect( + Schema.fromJsonString(RatesCacheFile as unknown as Schema.Codec), +); + +interface CachedFile { + readonly size: number; + readonly mtimeMs: number; + readonly records: readonly UsageRecord[]; +} + +export class UsageService extends Context.Service< + UsageService, + { + readonly readSummary: (input: UsageSummaryInput) => Effect.Effect; + } +>()("t3/usage/UsageService") { + /** Empty summary, for suites that only need the RPC surface to resolve. */ + static readonly layerTest = Layer.succeed( + UsageService, + UsageService.of({ + readSummary: (input) => + Effect.succeed({ + contractVersion: USAGE_CONTRACT_VERSION, + readAt: "1970-01-01T00:00:00.000Z", + timeZone: input.timeZone, + sinceDay: input.sinceDay, + untilDay: input.untilDay, + buckets: [], + sources: [], + pricing: { + status: "unavailable", + source: LITELLM_RATES_URL, + fetchedAt: null, + knownModels: 0, + }, + scanDurationMs: 0, + }), + }), + ); +} + +const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig; + const settingsService = yield* ServerSettings.ServerSettingsService; + const httpClient = yield* HttpClient.HttpClient; + + const fileCache = new Map(); + let cachedRecordCount = 0; + + const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); + let rates: RateTable = new Map(); + let ratesFetchedAtMs: number | null = null; + let ratesStatus: UsageSummary["pricing"]["status"] = "unavailable"; + + /** + * Loads the LiteLLM rate table, preferring a fresh copy and falling back to + * the on-disk snapshot. With neither, every model reports as unpriced rather + * than the page failing. + */ + const ensureRates = Effect.fn("UsageService.ensureRates")(function* () { + const now = yield* Clock.currentTimeMillis; + if (ratesFetchedAtMs !== null && now - ratesFetchedAtMs < RATES_TTL_MS) return; + + if (ratesFetchedAtMs === null) { + const fromDisk = yield* fileSystem.readFileString(ratesCachePath).pipe( + Effect.flatMap((raw) => decodeRatesCache(raw)), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (fromDisk !== null) { + const parsed = parseRateTable(fromDisk.document); + if (parsed.size > 0) { + rates = parsed; + ratesFetchedAtMs = fromDisk.fetchedAtMs; + ratesStatus = "cached"; + if (now - fromDisk.fetchedAtMs < RATES_TTL_MS) return; + } + } + } + + const fetched = yield* httpClient.get(LITELLM_RATES_URL).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.timeout(10_000), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (fetched === null) return; + + const parsed = parseRateTable(fetched); + if (parsed.size === 0) return; + + rates = parsed; + ratesFetchedAtMs = now; + ratesStatus = "fresh"; + + yield* encodeRatesCache({ fetchedAtMs: now, document: fetched }).pipe( + Effect.flatMap((serialized) => fileSystem.writeFileString(ratesCachePath, serialized)), + Effect.catchCause(() => Effect.void), + ); + }); + + /** + * Claude's config dir is the home itself when overridden, but a default + * install nests transcripts under `~/.claude/projects`. Probe both. + */ + const resolveClaudeTranscriptDir = (homePath: string) => + Effect.gen(function* () { + const nested = path.join(homePath, ".claude", "projects"); + const nestedExists = yield* fileSystem + .exists(nested) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + return nestedExists ? nested : path.join(homePath, "projects"); + }); + + /** Resolves the transcript directory for each provider. */ + const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* () { + const settings = yield* settingsService.getSettings.pipe( + Effect.catchCause(() => Effect.succeed(null)), + ); + if (settings === null) { + return [] as readonly { provider: UsageProviderKind; dir: string }[]; + } + + const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent); + const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome); + const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex); + + return [ + { provider: "claude" as const, dir: claudeDir }, + { provider: "codex" as const, dir: path.join(codexLayout.sharedHomePath, "sessions") }, + ]; + }); + + /** Parses one transcript, reusing the memoised result when it is unchanged. */ + const readFileRecords = ( + filePath: string, + size: number, + mtimeMs: number, + provider: UsageProviderKind, + ): Effect.Effect => + Effect.gen(function* () { + const cached = fileCache.get(filePath); + if (cached && cached.size === size && cached.mtimeMs === mtimeMs) return cached.records; + + const records = yield* Effect.promise(() => readTranscriptRecords(filePath, provider)); + + if (cachedRecordCount > MAX_CACHED_RECORDS) { + fileCache.clear(); + cachedRecordCount = 0; + } + fileCache.set(filePath, { size, mtimeMs, records }); + cachedRecordCount += records.length; + return records; + }); + + const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { + if (input.sinceDay > input.untilDay) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: `sinceDay '${input.sinceDay}' is after untilDay '${input.untilDay}'`, + }); + } + + const startedAtMs = yield* Clock.currentTimeMillis; + yield* ensureRates(); + + const hostId = NodeOS.hostname(); + // The home resolvers ask for `Path` themselves; satisfy them from the + // instance we already hold so `readSummary` stays context-free. + const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path)); + const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`); + if (Option.isNone(windowStart)) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: `sinceDay '${input.sinceDay}' is not a valid date`, + }); + } + const windowStartMs = DateTime.toEpochMillis(windowStart.value) - MTIME_SLACK_MS; + + const aggregator = new UsageAggregator({ + timeZone: input.timeZone, + sinceDay: input.sinceDay, + untilDay: input.untilDay, + rates, + }); + + const sources: UsageSource[] = []; + + for (const { provider, dir } of dirs) { + const exists = yield* fileSystem + .exists(dir) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + + if (!exists) { + sources.push({ + fingerprint: { hostId, provider, resolvedHomePath: dir }, + status: "missing", + scannedFiles: 0, + skippedFiles: 0, + malformedRecords: 0, + message: "No transcript directory on this environment.", + }); + continue; + } + + const files = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs)); + let scannedFiles = 0; + let skippedFiles = 0; + + for (const file of files) { + const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider); + if (records.length === 0) { + skippedFiles += 1; + continue; + } + scannedFiles += 1; + for (const record of records) aggregator.add(record); + } + + sources.push({ + fingerprint: { hostId, provider, resolvedHomePath: dir }, + status: "ok", + scannedFiles, + skippedFiles, + malformedRecords: 0, + message: null, + }); + } + + const aggregated = aggregator.finish(); + const readAt = yield* DateTime.now; + const finishedAtMs = yield* Clock.currentTimeMillis; + + return { + contractVersion: USAGE_CONTRACT_VERSION, + readAt: DateTime.formatIso(readAt), + timeZone: input.timeZone, + sinceDay: input.sinceDay, + untilDay: input.untilDay, + buckets: aggregated.buckets, + sources, + pricing: { + status: ratesStatus, + source: LITELLM_RATES_URL, + fetchedAt: + ratesFetchedAtMs === null + ? null + : DateTime.formatIso(DateTime.makeUnsafe(ratesFetchedAtMs)), + knownModels: rates.size, + }, + scanDurationMs: Math.max(0, finishedAtMs - startedAtMs), + } satisfies UsageSummary; + }); + + return { readSummary } as const; +}); + +export const layer = Layer.effect(UsageService, make); diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b7483301b0224712620da4bb66a690da430e42f GIT binary patch literal 5521 zcmb_gZEqXL5zc4*iYXnSq)VOL_8T1uR-^_HTGfUnBS;LPT;1)F2OV$O-90mm!vEf9 zW_Rz7q>>b9{lPYOXXo{qw>vwdce*Tf?oW$KEgIu|l{;G0=26wx%Ilda%agO$uTSVT zeP`;z(OOxj3-VSqF1OX%lhwJgxTzX%Xg)5~c1mk&zEp*_Q(Boq*ONItw)vCxE(0fK zHos|now11fYNfw3cBTBBDo1{)$u#vAZ0kxr)|xhIOWjtKU*%{IY5m5R9j|g^4Znv|7On%uIAkmVRh0)Nn>_f*1HP>s9E} zqFvWju0)Up7s#@jymmg=UiqbIp}uKzmdVPogt7wZ)_J`mW96jLeq-!YlKL(x)jGA% zmy3nf3lRbaXD8KaZ7i5>*P5Q`6O()^;hBaX*BnAQzBf<0xi?i$kOW zOr6uK$C?qqab*08R|76-p}p7E(HLQOz&aS;++ zTSz3Z7$-Ys1SCV;WCo_N4fe0rw{AvFyL!}iLgyEef}T%^YVE0%Y@E>xDf$*99tp;z z6bb2^8ohamdOQ-}ji!+vZ=-jo@}6j`m7UQDx@bghl#W+G))MD`L0MWXQx0&>Wsnp19JRe=mM^y&U;7#%8ARZCDdOM`TFxoY6+ z|H(`!3n`uY|37P?m({+t&0bI?m?3&u_-D+K;gUCu{RAG{A!8fhq*S@4+g7Sgr~%?a zFDXAeDgx~49Dw}fii99OiY;Jx)Haf}MacCIZ%mzO_C8#nMF*;8Gd{ZR4e@nw+!d#mlzw6?)*ym&j@j<+?ibJF0!u zuC4vPX0aj8q;H6>QsF`nK0mOcXuK5tM};-(HHylk&K0T)7c=BeU7@xF?;`=Mtw6zb z#iVLPWCJ?x( zn!;=jWq|g5`Mq5A+yT2dO20Dnat>_RxOmi!Xy%!`?``@0L747-yeGgkTe@*VbM}!oS=w8F6fv(P@@X& zwjg@a1-JCnjm6vZ;O@Wc?HpAURjWeN(HLYD;)bD##|5XqXp2JLPfucESI+ZV!G6I( zf;9v_4@37V;wDzzTeXhqG2Kt&i?P(jF66JAhE1VV4|3{|${T_Fuw10*4O+!s7QHed z@n)2CPN~Rbua2+=CEm!*03qQFZJ6s%l>pue-37`X`%F|-#alky%jmV3B7h=AQ~l`82v1XeIDH?o=V!WSK_1)-?1fJ z2fo?&dX{P^{ql>jyEwQ{Wwf_4My)RoG_-DL+n%l{V~FGY>t|@O`|9iG5TYzKlfjX! z9J_h&4-YgUW$-$Pjv16;*cyZDz`#U^IJQlp%c{{uuXwtR5MVc^KDjsP#&`@m<(o9v z3|i&j?5I}`?vi~+&2m7~cS~;fd)b`??-E+}UEqQXrqM+&VI;J1IIo|=$9srNnIt_i z$&1mEP#l~EpB)_9I15~b;Ue5Z9g!3qpM{6eq+2n@dpFbANFrf0CX&Rdg|QYa%Hm0I zOr#2Gy8$8@_k#y_rnQ)u&Vi6o3fpKB-It}4P%-{XHir@J*&W`z%r?3@g5ajeYTYdS zk}l{?r&vNrHmD5U196n(8USCpr zx*o`s`zhGxR;1<%1J7b(NVdnH0wnqW1O5in%wci%dmze0jm7VzWD3OdF`e%Ee z)g48YPkQnQ(^puV%}+= zv|jT!qHS&XV@OezrL5`vwZk=PjV%n{9ne}Z%Ox9t2Wmee0_4tv`CTKtdsuZ&$(sXe^FY!Dgmec6>RE*-@mGBkSP98dH-Lr`J zql@%nFa;&r!BL=+>b>KoEltlc^0cac8Vu0#DFGiZ!dExWJ9vAkD=SNzAY3;JOAnTX zMxvuF53w@u$`0ZT7KaF@Zlh2*39Fj$;jd?yw>5uK!<>yzeUYhr5R(}@c!f6ump*-c zy1{1xjK2hOi`jw)e`I}$eF6%FuNvLwj9<%v+5V7=LN%;GqdtvB57MQ%ddHkQfWDOQ rcoprPz)abx`^wJEVZbAS=b#z?Ca=RcBj%X|3BW$^=dA+gyOaL_-fjuW literal 0 HcmV?d00001 diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts new file mode 100644 index 00000000000..f0e59a87439 --- /dev/null +++ b/apps/server/src/usage/usagePricing.ts @@ -0,0 +1,148 @@ +/** + * Model rate lookup and cost arithmetic. + * + * Rates come from LiteLLM's `model_prices_and_context_window.json`, the same + * table `ccusage` prices against. Everything here is pure: fetching and caching + * the table lives in `UsageService`. + * + * @module usagePricing + */ +import type { UsageCostSource, UsageTokenTotals } from "@t3tools/contracts"; + +/** + * The subset of a LiteLLM entry we price against. All values are USD per token. + * + * LiteLLM also publishes tiered variants (`*_above_272k_tokens`, `*_flex`, + * `*_priority`, `*_batches`). We deliberately price at the base tier: the + * transcripts don't record which tier served a request, so anything else would + * be a guess dressed up as precision. + */ +export interface ModelRate { + readonly inputCostPerToken: number; + readonly outputCostPerToken: number; + readonly cacheReadCostPerToken: number; + readonly cacheCreationCostPerToken: number; +} + +export type RateTable = ReadonlyMap; + +/** Raw shape of one LiteLLM entry, narrowed to the fields we read. */ +interface LiteLlmEntry { + readonly input_cost_per_token?: unknown; + readonly output_cost_per_token?: unknown; + readonly cache_read_input_token_cost?: unknown; + readonly cache_creation_input_token_cost?: unknown; +} + +function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +/** + * Projects the LiteLLM document into a rate table. + * + * Entries without both an input and an output rate are dropped: a half-priced + * model would silently under-report cost, which is worse than reporting the + * model as unpriced. + */ +export function parseRateTable(document: unknown): RateTable { + const table = new Map(); + if (typeof document !== "object" || document === null) return table; + + for (const [name, raw] of Object.entries(document as Record)) { + if (typeof raw !== "object" || raw === null) continue; + const entry = raw as LiteLlmEntry; + const input = finiteNumber(entry.input_cost_per_token); + const output = finiteNumber(entry.output_cost_per_token); + if (input === null || output === null) continue; + + table.set(normalizeModelName(name), { + inputCostPerToken: input, + outputCostPerToken: output, + // Anthropic bills cache reads at a discount and cache writes at a + // premium. When a model omits them, cached input is priced as plain + // input rather than as free. + cacheReadCostPerToken: finiteNumber(entry.cache_read_input_token_cost) ?? input, + cacheCreationCostPerToken: finiteNumber(entry.cache_creation_input_token_cost) ?? input, + }); + } + return table; +} + +/** + * Canonicalises a model name for lookup. + * + * Strips a `provider/` prefix (LiteLLM publishes both `claude-opus-5` and + * `anthropic/claude-opus-5`) and lowercases, since transcripts are inconsistent + * about casing. + */ +export function normalizeModelName(model: string): string { + const trimmed = model.trim().toLowerCase(); + const slash = trimmed.lastIndexOf("/"); + return slash === -1 ? trimmed : trimmed.slice(slash + 1); +} + +/** + * Models we never price, regardless of the table. + * + * `` marks locally generated messages that were never billed. Bare + * family names ("opus", "sonnet") are genuinely ambiguous across generations, + * so we report them as unpriced instead of guessing a generation. + */ +const UNPRICEABLE_MODELS = new Set([ + "", + "synthetic", + "opus", + "sonnet", + "haiku", + "fable", +]); + +export function lookupRate(table: RateTable, model: string): ModelRate | null { + const normalized = normalizeModelName(model); + if (normalized.length === 0 || UNPRICEABLE_MODELS.has(normalized)) return null; + return table.get(normalized) ?? null; +} + +export interface PricedUsage { + readonly costUsd: number; + readonly costSource: UsageCostSource; +} + +/** + * Prices a bucket's tokens. + * + * `reasoningTokens` is intentionally not charged separately: it is already + * counted inside `outputTokens`. + */ +export function priceUsage( + table: RateTable, + model: string, + totals: UsageTokenTotals, + reportedCostUsd: number | null, +): PricedUsage { + if (reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) { + return { costUsd: reportedCostUsd, costSource: "providerReported" }; + } + + const rate = lookupRate(table, model); + if (rate === null) return { costUsd: 0, costSource: "unpriced" }; + + const costUsd = + totals.uncachedInputTokens * rate.inputCostPerToken + + totals.cachedInputTokens * rate.cacheReadCostPerToken + + totals.cacheCreationTokens * rate.cacheCreationCostPerToken + + totals.outputTokens * rate.outputCostPerToken; + + return { costUsd, costSource: "modelPriced" }; +} + +/** + * What the cached input would have cost at full input rates, minus what it + * actually cost. Drives the "cache savings" figure. + */ +export function cacheSavingsUsd(table: RateTable, model: string, totals: UsageTokenTotals): number { + const rate = lookupRate(table, model); + if (rate === null) return 0; + return totals.cachedInputTokens * (rate.inputCostPerToken - rate.cacheReadCostPerToken); +} diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts new file mode 100644 index 00000000000..987c9592b1f --- /dev/null +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -0,0 +1,119 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Raw filesystem access for transcript scanning. + * + * Isolated here so the rest of the usage code stays on Effect's `FileSystem`. + * The direct `node:fs` streaming is deliberate: a cold 30-day window is ~1.4 GB + * across ~1,500 files, and `readline` over a read stream is roughly an order of + * magnitude cheaper than materialising each file. The equivalent Effect stream + * pipeline is idiomatic but not fast enough to sit behind a page load. + * + * @module usageTranscriptReader + */ +import { createReadStream } from "node:fs"; +import { readdir, stat } from "node:fs/promises"; +import { createInterface } from "node:readline"; +import { join } from "node:path"; + +import type { UsageProviderKind } from "@t3tools/contracts"; + +import { + initialCodexScanState, + mightCarryUsage, + parseClaudeLine, + parseCodexLine, + type UsageRecord, +} from "./usageTranscripts.ts"; + +export interface TranscriptFile { + readonly path: string; + readonly size: number; + readonly mtimeMs: number; +} + +/** + * Lists `.jsonl` transcripts under `root` last modified at or after `sinceMs`. + * + * Errors on individual entries are swallowed: session files rotate and get + * removed while the walk is in flight, and a partial listing is far better than + * failing the page. + */ +export async function listTranscriptFiles( + root: string, + sinceMs: number, +): Promise { + const found: TranscriptFile[] = []; + + const walk = async (dir: string): Promise => { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const child = join(dir, entry.name); + if (entry.isDirectory()) { + await walk(child); + continue; + } + if (!entry.name.endsWith(".jsonl")) continue; + try { + const stats = await stat(child); + if (stats.mtimeMs >= sinceMs) { + found.push({ path: child, size: stats.size, mtimeMs: stats.mtimeMs }); + } + } catch { + // Vanished between readdir and stat. + } + } + }; + + await walk(root); + return found; +} + +/** + * Streams one transcript and returns the usage records it contains. + * + * Codex carries the active model on `turn_context` lines that hold no usage of + * their own, so those still have to pass through the reducer to keep model + * attribution correct. + */ +export async function readTranscriptRecords( + filePath: string, + provider: UsageProviderKind, +): Promise { + const records: UsageRecord[] = []; + const codexState = initialCodexScanState(); + + try { + const lines = createInterface({ + input: createReadStream(filePath, { encoding: "utf8" }), + crlfDelay: Infinity, + }); + + for await (const line of lines) { + if (provider === "codex") { + if ( + !mightCarryUsage(line, provider) && + !line.includes('"turn_context"') && + !line.includes('"session_meta"') + ) { + continue; + } + const record = parseCodexLine(line, codexState); + if (record !== null) records.push(record); + continue; + } + + if (!mightCarryUsage(line, provider)) continue; + const record = parseClaudeLine(line); + if (record !== null) records.push(record); + } + } catch { + return []; + } + + return records; +} diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts new file mode 100644 index 00000000000..5e0b913ae0b --- /dev/null +++ b/apps/server/src/usage/usageTranscripts.ts @@ -0,0 +1,242 @@ +/** + * Pure parsers for the provider CLIs' on-disk session transcripts. + * + * Both parsers are line-at-a-time reducers so callers can stream large files + * without materialising them. Neither touches the filesystem. + * + * @module usageTranscripts + */ +import type { UsageProviderKind, UsageTokenTotals } from "@t3tools/contracts"; + +export interface UsageRecord { + readonly provider: UsageProviderKind; + readonly timestampMs: number; + readonly model: string; + readonly sessionId: string; + readonly totals: UsageTokenTotals; + readonly reportedCostUsd: number | null; + /** + * Key for cross-file de-duplication, or `null` when the record is inherently + * unique and needs no dedup. + */ + readonly dedupeKey: string | null; +} + +const EMPTY_TOTALS: UsageTokenTotals = { + uncachedInputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, +}; + +function int(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; +} + +function parseTimestampMs(value: unknown): number | null { + if (typeof value !== "string") return null; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; +} + +export function addTotals(a: UsageTokenTotals, b: UsageTokenTotals): UsageTokenTotals { + return { + uncachedInputTokens: a.uncachedInputTokens + b.uncachedInputTokens, + cachedInputTokens: a.cachedInputTokens + b.cachedInputTokens, + cacheCreationTokens: a.cacheCreationTokens + b.cacheCreationTokens, + outputTokens: a.outputTokens + b.outputTokens, + reasoningTokens: a.reasoningTokens + b.reasoningTokens, + }; +} + +export function totalTokens(totals: UsageTokenTotals): number { + // reasoningTokens is a subset of outputTokens and must not be added again. + return ( + totals.uncachedInputTokens + + totals.cachedInputTokens + + totals.cacheCreationTokens + + totals.outputTokens + ); +} + +/** + * Cheap substring gate applied before `JSON.parse`. + * + * Transcripts are mostly tool output; only a minority of lines carry usage. On + * a 30-day window this skips roughly half the lines outright and is worth about + * an order of magnitude. + */ +export function mightCarryUsage(line: string, provider: UsageProviderKind): boolean { + return provider === "claude" ? line.includes('"usage"') : line.includes('"token_count"'); +} + +/* -------------------------------------------------------------------------- */ +/* Claude Code */ +/* -------------------------------------------------------------------------- */ + +/** + * Parses one line of a Claude Code transcript. + * + * T3 Code writes one record per assistant *content block*, and every one of + * those records repeats the same complete `usage` object for the parent + * message. Summing them overcounts by roughly 2.4x on a real workload, so the + * caller must drop repeats by `dedupeKey` and keep the first. + */ +export function parseClaudeLine(line: string): UsageRecord | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + + const record = parsed as Record; + if (record["type"] !== "assistant") return null; + + const message = record["message"]; + if (typeof message !== "object" || message === null) return null; + const messageRecord = message as Record; + + const usage = messageRecord["usage"]; + if (typeof usage !== "object" || usage === null) return null; + const usageRecord = usage as Record; + + const timestampMs = parseTimestampMs(record["timestamp"]); + if (timestampMs === null) return null; + + const model = typeof messageRecord["model"] === "string" ? messageRecord["model"] : ""; + if (model.length === 0) return null; + + const messageId = typeof messageRecord["id"] === "string" ? messageRecord["id"] : null; + const requestId = typeof record["requestId"] === "string" ? record["requestId"] : null; + // Matches ccusage: prefer the message/request pair, fall back to whichever + // half exists. Records with neither cannot be de-duplicated. + const dedupeKey = + messageId === null && requestId === null ? null : `${messageId ?? ""}:${requestId ?? ""}`; + + const cost = record["costUSD"]; + + return { + provider: "claude", + timestampMs, + model, + sessionId: typeof record["sessionId"] === "string" ? record["sessionId"] : "", + totals: { + uncachedInputTokens: int(usageRecord["input_tokens"]), + cachedInputTokens: int(usageRecord["cache_read_input_tokens"]), + cacheCreationTokens: int(usageRecord["cache_creation_input_tokens"]), + outputTokens: int(usageRecord["output_tokens"]), + // Anthropic folds thinking tokens into output and does not break them out. + reasoningTokens: 0, + }, + reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null, + dedupeKey, + }; +} + +/* -------------------------------------------------------------------------- */ +/* Codex */ +/* -------------------------------------------------------------------------- */ + +/** + * Rolling state for a single Codex rollout file. + * + * Codex `token_count` events carry no model, so the model is carried forward + * from the most recent `turn_context`. Sessions that switch models mid-run + * attribute correctly from the switch onward. + */ +export interface CodexScanState { + model: string; + sessionId: string; + lastUsageSignature: string | null; +} + +export function initialCodexScanState(): CodexScanState { + return { model: "", sessionId: "", lastUsageSignature: null }; +} + +/** + * Feeds one line of a Codex rollout into `state`, returning a record when the + * line was a usage event. + * + * Deltas come from `last_token_usage`. Summing those across a session + * reconciles with the session's final `total_token_usage`, provided + * consecutive duplicate events are dropped, which this does. + */ +export function parseCodexLine(line: string, state: CodexScanState): UsageRecord | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + + const record = parsed as Record; + const payload = record["payload"]; + if (typeof payload !== "object" || payload === null) return null; + const payloadRecord = payload as Record; + const payloadType = payloadRecord["type"]; + + if (record["type"] === "session_meta") { + const id = payloadRecord["id"] ?? payloadRecord["session_id"]; + if (typeof id === "string") state.sessionId = id; + return null; + } + + if (record["type"] === "turn_context") { + if (typeof payloadRecord["model"] === "string") state.model = payloadRecord["model"]; + return null; + } + + if (payloadType !== "token_count") return null; + + const info = payloadRecord["info"]; + if (typeof info !== "object" || info === null) return null; + const last = (info as Record)["last_token_usage"]; + if (typeof last !== "object" || last === null) return null; + const lastRecord = last as Record; + + // Codex re-emits an unchanged token_count on some stream boundaries. Summing + // those would double count, so identical consecutive payloads are skipped. + const signature = JSON.stringify(lastRecord); + if (signature === state.lastUsageSignature) return null; + state.lastUsageSignature = signature; + + const timestampMs = parseTimestampMs(record["timestamp"]); + if (timestampMs === null) return null; + if (state.model.length === 0) return null; + + const inputTokens = int(lastRecord["input_tokens"]); + const cachedInputTokens = int(lastRecord["cached_input_tokens"]); + const cacheCreationTokens = int(lastRecord["cache_write_input_tokens"]); + const outputTokens = int(lastRecord["output_tokens"]); + + const totals: UsageTokenTotals = { + // Codex reports `input_tokens` inclusive of the cached portion. + uncachedInputTokens: Math.max(0, inputTokens - cachedInputTokens - cacheCreationTokens), + cachedInputTokens, + cacheCreationTokens, + outputTokens, + // Reported inside output_tokens, surfaced separately for the token mix. + reasoningTokens: Math.min(outputTokens, int(lastRecord["reasoning_output_tokens"])), + }; + + if (totalTokens(totals) === 0) return null; + + return { + provider: "codex", + timestampMs, + model: state.model, + sessionId: state.sessionId, + totals, + // Codex does not report cost in the rollout. + reportedCostUsd: null, + // Rollout files are unique per session, so events need no global dedup. + dedupeKey: null, + }; +} + +export { EMPTY_TOTALS }; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a6b155c296f..6d518fe16cf 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -105,6 +105,7 @@ import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; +import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; @@ -412,6 +413,7 @@ const makeWsRpcLayer = ( const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; + const usage = yield* UsageService.UsageService; const relayClient = yield* RelayClient.RelayClient; const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ @@ -1529,6 +1531,10 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }, ), + [WS_METHODS.serverGetUsageSummary]: (input) => + observeRpcEffect(WS_METHODS.serverGetUsageSummary, usage.readSummary(input), { + "rpc.aggregate": "server", + }), [WS_METHODS.serverRetryResourceTelemetry]: (_input) => observeRpcEffect(WS_METHODS.serverRetryResourceTelemetry, resourceTelemetry.retry, { "rpc.aggregate": "server", diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f0ee1889177..6181391eca3 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -28,4 +28,5 @@ export * from "./review.ts"; export * from "./preview.ts"; export * from "./previewAutomation.ts"; export * from "./resourceTelemetry.ts"; +export * from "./usage.ts"; export * from "./rpc.ts"; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index db40b10fed9..59255639995 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -152,6 +152,7 @@ import { ResourceTelemetryRetryResult, ResourceTelemetrySnapshot, } from "./resourceTelemetry.ts"; +import { UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; import { SourceControlCloneRepositoryInput, @@ -244,6 +245,7 @@ export const WS_METHODS = { serverReportClientActivity: "server.reportClientActivity", serverReportHostPowerState: "server.reportHostPowerState", serverGetBackgroundPolicy: "server.getBackgroundPolicy", + serverGetUsageSummary: "server.getUsageSummary", // Cloud environment methods cloudGetRelayClientStatus: "cloud.getRelayClientStatus", @@ -381,6 +383,12 @@ export const WsServerRetryResourceTelemetryRpc = Rpc.make(WS_METHODS.serverRetry error: EnvironmentAuthorizationError, }); +export const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSummary, { + payload: UsageSummaryInput, + success: UsageSummary, + error: Schema.Union([EnvironmentAuthorizationError, UsageReadError]), +}); + export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { payload: ServerSignalProcessInput, success: ServerSignalProcessResult, @@ -819,6 +827,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetProcessResourceHistoryRpc, WsServerGetResourceTelemetryHistoryRpc, WsServerRetryResourceTelemetryRpc, + WsServerGetUsageSummaryRpc, WsServerSignalProcessRpc, WsServerReportClientActivityRpc, WsServerReportHostPowerStateRpc, diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts new file mode 100644 index 00000000000..7e8d37df9c9 --- /dev/null +++ b/packages/contracts/src/usage.ts @@ -0,0 +1,166 @@ +/** + * Usage reporting contract. + * + * Each environment scans the provider CLIs' own on-disk session transcripts + * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`) rather than + * relying on T3 Code's own orchestration projections, so usage stays complete + * even for turns that were never driven through T3 Code. This mirrors the + * approach `ccusage` takes. + * + * Environments return pre-aggregated `(day, provider, model)` buckets. Raw + * transcript records never cross the wire. + * + * @module usage + */ +import * as Schema from "effect/Schema"; + +import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +/** + * Bumped whenever the shape of {@link UsageSummary} changes incompatibly. The + * client renders partial coverage when an environment reports an older version + * rather than failing the whole page. + */ +export const USAGE_CONTRACT_VERSION = 1 as const; + +export const UsageProviderKind = Schema.Literals(["claude", "codex"]); +export type UsageProviderKind = typeof UsageProviderKind.Type; + +/** + * A calendar day in the reporting time zone, formatted `YYYY-MM-DD`. + * + * Days are bucketed server-side so that a turn always lands on the day the user + * experienced it, not the UTC day. + */ +export const UsageDay = TrimmedNonEmptyString.pipe(Schema.brand("UsageDay")); +export type UsageDay = typeof UsageDay.Type; + +/** + * Why a bucket's cost is what it is. + * + * - `providerReported` - the transcript carried an explicit cost figure. + * - `modelPriced` - we matched the model against the LiteLLM rate table. + * - `unpriced` - tokens are known, rates are not. Counted in totals, excluded + * from cost. + */ +export const UsageCostSource = Schema.Literals(["providerReported", "modelPriced", "unpriced"]); +export type UsageCostSource = typeof UsageCostSource.Type; + +/** + * Token counts for a bucket. + * + * `cachedInputTokens` and `cacheCreationTokens` are disjoint from + * `uncachedInputTokens`; summing all three gives total input. `reasoningTokens` + * is a *subset* of `outputTokens` (Codex reports it that way, and Anthropic + * folds thinking into output), so it must never be added on top. + */ +export const UsageTokenTotals = Schema.Struct({ + uncachedInputTokens: NonNegativeInt, + cachedInputTokens: NonNegativeInt, + cacheCreationTokens: NonNegativeInt, + outputTokens: NonNegativeInt, + reasoningTokens: NonNegativeInt, +}); +export type UsageTokenTotals = typeof UsageTokenTotals.Type; + +/** + * One `(day, provider, model)` cell. + * + * `costUsd` is the raw API-equivalent cost of these tokens. It is not money + * spent: subscription plans bill separately. `unpricedRecords` counts records + * whose tokens are included in the token totals but which contributed nothing + * to `costUsd`. + */ +export const UsageBucket = Schema.Struct({ + day: UsageDay, + provider: UsageProviderKind, + model: TrimmedNonEmptyString, + totals: UsageTokenTotals, + costUsd: Schema.Number, + costSource: UsageCostSource, + /** Distinct assistant responses, after de-duplication. */ + records: NonNegativeInt, + unpricedRecords: NonNegativeInt, + /** Distinct transcript sessions that contributed to this cell. */ + sessions: NonNegativeInt, +}); +export type UsageBucket = typeof UsageBucket.Type; + +/** + * Identifies the physical transcript directory a source read from. + * + * Two environments on the same machine (worktree servers, for example) resolve + * the same provider home and would otherwise double count. The client drops + * duplicate fingerprints before merging. + */ +export const UsageSourceFingerprint = Schema.Struct({ + hostId: TrimmedNonEmptyString, + provider: UsageProviderKind, + resolvedHomePath: TrimmedNonEmptyString, +}); +export type UsageSourceFingerprint = typeof UsageSourceFingerprint.Type; + +export const UsageSourceStatus = Schema.Literals(["ok", "missing", "partial", "failed"]); +export type UsageSourceStatus = typeof UsageSourceStatus.Type; + +export const UsageSource = Schema.Struct({ + fingerprint: UsageSourceFingerprint, + status: UsageSourceStatus, + scannedFiles: NonNegativeInt, + skippedFiles: NonNegativeInt, + /** Records that parsed but carried no recognisable usage payload. */ + malformedRecords: NonNegativeInt, + message: Schema.NullOr(TrimmedNonEmptyString), +}); +export type UsageSource = typeof UsageSource.Type; + +export const UsagePricingStatus = Schema.Literals(["fresh", "cached", "unavailable"]); +export type UsagePricingStatus = typeof UsagePricingStatus.Type; + +/** + * Provenance for the rate table, so the UI can be honest about how good the + * cost figures are. + */ +export const UsagePricing = Schema.Struct({ + status: UsagePricingStatus, + source: TrimmedNonEmptyString, + fetchedAt: Schema.NullOr(Schema.String), + knownModels: NonNegativeInt, +}); +export type UsagePricing = typeof UsagePricing.Type; + +export const UsageSummaryInput = Schema.Struct({ + /** Inclusive first day of the window, in `timeZone`. */ + sinceDay: UsageDay, + /** Inclusive last day of the window, in `timeZone`. */ + untilDay: UsageDay, + /** + * IANA zone the client wants days bucketed in. An offset would be wrong for + * any window that crosses a DST boundary. + */ + timeZone: TrimmedNonEmptyString, +}); +export type UsageSummaryInput = typeof UsageSummaryInput.Type; + +export const UsageSummary = Schema.Struct({ + contractVersion: Schema.Number, + readAt: Schema.String, + timeZone: TrimmedNonEmptyString, + sinceDay: UsageDay, + untilDay: UsageDay, + buckets: Schema.Array(UsageBucket), + sources: Schema.Array(UsageSource), + pricing: UsagePricing, + /** Wall-clock cost of the scan, surfaced in diagnostics. */ + scanDurationMs: NonNegativeInt, +}); +export type UsageSummary = typeof UsageSummary.Type; + +export class UsageReadError extends Schema.TaggedErrorClass()("UsageReadError", { + reason: Schema.Literals(["scanFailed", "invalidWindow"]), + detail: TrimmedNonEmptyString, +}) { + override get message(): string { + return `Usage read failed (${this.reason}): ${this.detail}`; + } +} From 3e7c77bd503c36a4129f2c894f9d0c46c8d3625d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 23:32:02 -0700 Subject: [PATCH 02/12] feat(web): add the Usage page Fans the usage query out across every connected environment and merges the results client-side, de-duplicating environments that resolve the same provider transcript directory. Co-Authored-By: Claude Opus 5 (1M context) --- .../server/src/usage/usageAggregation.test.ts | 119 +++++++ apps/server/src/usage/usageAggregation.ts | Bin 5521 -> 5739 bytes .../server/src/usage/usageTranscripts.test.ts | 142 ++++++++ .../src/components/sidebar/SidebarChrome.tsx | 15 +- apps/web/src/components/usage/UsagePage.tsx | 337 ++++++++++++++++++ .../components/usage/UsageProviderChart.tsx | 154 ++++++++ .../src/components/usage/usageProviders.ts | 18 + apps/web/src/routeTree.gen.ts | 21 ++ apps/web/src/routes/usage.tsx | 7 + apps/web/src/state/usage.ts | 107 ++++++ apps/web/src/usage/usageFormat.ts | 99 +++++ apps/web/src/usage/usageMerge.ts | Bin 0 -> 9518 bytes packages/client-runtime/src/state/server.ts | 7 + packages/contracts/src/usage.ts | 6 + 14 files changed, 1031 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/usage/usageAggregation.test.ts create mode 100644 apps/server/src/usage/usageTranscripts.test.ts create mode 100644 apps/web/src/components/usage/UsagePage.tsx create mode 100644 apps/web/src/components/usage/UsageProviderChart.tsx create mode 100644 apps/web/src/components/usage/usageProviders.ts create mode 100644 apps/web/src/routes/usage.tsx create mode 100644 apps/web/src/state/usage.ts create mode 100644 apps/web/src/usage/usageFormat.ts create mode 100644 apps/web/src/usage/usageMerge.ts diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts new file mode 100644 index 00000000000..75f611e2cd6 --- /dev/null +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { UsageAggregator } from "./usageAggregation.ts"; +import type { RateTable } from "./usagePricing.ts"; +import type { UsageRecord } from "./usageTranscripts.ts"; + +const rates: RateTable = new Map([ + [ + "claude-fable-5", + { + inputCostPerToken: 1e-5, + outputCostPerToken: 5e-5, + cacheReadCostPerToken: 1e-6, + cacheCreationCostPerToken: 1.25e-5, + }, + ], +]); + +function record(overrides: Partial = {}): UsageRecord { + return { + provider: "claude", + // 2026-08-07T04:05Z is still Aug 6 in Los Angeles. + timestampMs: Date.parse("2026-08-07T04:05:13.944Z"), + model: "claude-fable-5", + sessionId: "session-a", + totals: { + uncachedInputTokens: 100, + cachedInputTokens: 1000, + cacheCreationTokens: 10, + outputTokens: 50, + reasoningTokens: 0, + }, + reportedCostUsd: null, + dedupeKey: null, + ...overrides, + }; +} + +function aggregate(records: readonly UsageRecord[], timeZone = "UTC") { + const aggregator = new UsageAggregator({ + timeZone, + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + }); + for (const item of records) aggregator.add(item); + return aggregator.finish(); +} + +describe("UsageAggregator", () => { + it("keeps only the first record for a repeated dedupe key", () => { + const result = aggregate([ + record({ dedupeKey: "msg_1:" }), + record({ dedupeKey: "msg_1:" }), + record({ dedupeKey: "msg_1:" }), + ]); + + expect(result.duplicatesDropped).toBe(2); + expect(result.buckets).toHaveLength(1); + expect(result.buckets[0]?.records).toBe(1); + expect(result.buckets[0]?.totals.outputTokens).toBe(50); + }); + + it("still sums records that carry no dedupe key", () => { + const result = aggregate([record(), record()]); + + expect(result.duplicatesDropped).toBe(0); + expect(result.buckets[0]?.totals.outputTokens).toBe(100); + }); + + it("buckets by the day in the requested time zone", () => { + const utc = aggregate([record()], "UTC"); + const losAngeles = aggregate([record()], "America/Los_Angeles"); + + expect(utc.buckets[0]?.day).toBe("2026-08-07"); + expect(losAngeles.buckets[0]?.day).toBe("2026-08-06"); + }); + + it("prices against the rate table", () => { + const result = aggregate([record()]); + + // 100*1e-5 + 1000*1e-6 + 10*1.25e-5 + 50*5e-5 + expect(result.buckets[0]?.costUsd).toBeCloseTo(0.004625, 9); + expect(result.buckets[0]?.costSource).toBe("modelPriced"); + }); + + it("counts tokens but not cost for a model with no rate", () => { + const result = aggregate([record({ model: "kimi-k3" })]); + + expect(result.buckets[0]?.costUsd).toBe(0); + expect(result.buckets[0]?.costSource).toBe("unpriced"); + expect(result.buckets[0]?.unpricedRecords).toBe(1); + expect(result.buckets[0]?.totals.outputTokens).toBe(50); + }); + + it("prefers a reported cost over the rate table", () => { + const result = aggregate([record({ reportedCostUsd: 1.25 })]); + + expect(result.buckets[0]?.costUsd).toBe(1.25); + expect(result.buckets[0]?.costSource).toBe("providerReported"); + }); + + it("drops records outside the window", () => { + const result = aggregate([record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") })]); + + expect(result.outOfWindow).toBe(1); + expect(result.buckets).toHaveLength(0); + }); + + it("separates providers and models into their own buckets", () => { + const result = aggregate([ + record(), + record({ provider: "codex", model: "gpt-5.6-sol" }), + record({ model: "claude-opus-5" }), + ]); + + expect(result.buckets).toHaveLength(3); + }); +}); diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 9b7483301b0224712620da4bb66a690da430e42f..9c5a2036048aa7217d283076138ff680b59b7776 100644 GIT binary patch delta 184 zcmbQJ{aR?B;KZ`by!7JG;uM|D%a|51^B{|Ewq&znM-@rtUB~ExtOlr4 zLEBaVO-Q38BePgfIlrJJGe56buPCu3wOB`?C^b31C`B(fKP5G1@;N@)&4T=uOvX+{FO|bQ-s)A0Nj)d;s5{u diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts new file mode 100644 index 00000000000..aa0c63b37c9 --- /dev/null +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + initialCodexScanState, + parseClaudeLine, + parseCodexLine, + totalTokens, +} from "./usageTranscripts.ts"; + +/** Shaped after a real Claude Code assistant record. */ +function claudeLine(overrides: { + messageId: string; + contentType: string; + model?: string; + outputTokens?: number; +}): string { + return JSON.stringify({ + type: "assistant", + timestamp: "2026-08-07T04:05:13.944Z", + sessionId: "5a128faa-8253-489e-b935-6c08e8e670c0", + cwd: "/home/theo/project", + message: { + id: overrides.messageId, + role: "assistant", + model: overrides.model ?? "claude-fable-5", + content: [{ type: overrides.contentType }], + usage: { + input_tokens: 2, + cache_creation_input_tokens: 66818, + cache_read_input_tokens: 1000, + output_tokens: overrides.outputTokens ?? 286, + }, + }, + }); +} + +describe("parseClaudeLine", () => { + it("extracts token totals and a dedupe key", () => { + const record = parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text" })); + + expect(record).not.toBeNull(); + expect(record?.provider).toBe("claude"); + expect(record?.model).toBe("claude-fable-5"); + expect(record?.totals).toEqual({ + uncachedInputTokens: 2, + cachedInputTokens: 1000, + cacheCreationTokens: 66818, + outputTokens: 286, + reasoningTokens: 0, + }); + expect(record?.dedupeKey).toBe("msg_1:"); + }); + + it("gives every content block of one message the same dedupe key", () => { + // T3 Code writes one record per content block, each repeating the parent + // message's full usage. Summing them would overcount ~2.4x on real data. + const text = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "text" })); + const toolUse = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "tool_use" })); + + expect(text?.dedupeKey).toBe(toolUse?.dedupeKey); + expect(text?.totals).toEqual(toolUse?.totals); + }); + + it("ignores records that are not assistant messages", () => { + expect(parseClaudeLine(JSON.stringify({ type: "user", message: {} }))).toBeNull(); + expect(parseClaudeLine("not json")).toBeNull(); + }); +}); + +describe("parseCodexLine", () => { + const sessionMeta = JSON.stringify({ + type: "session_meta", + timestamp: "2026-08-01T05:17:41.289Z", + payload: { type: "session_meta", id: "019fbbc1-b12c-7360-a685-28c181f0025f" }, + }); + const turnContext = JSON.stringify({ + type: "turn_context", + timestamp: "2026-08-01T05:17:42.694Z", + payload: { type: "turn_context", model: "gpt-5.6-sol" }, + }); + const tokenCount = (inputTokens: number, cached: number, output: number, reasoning: number) => + JSON.stringify({ + type: "event_msg", + timestamp: "2026-08-01T05:17:49.919Z", + payload: { + type: "token_count", + info: { + last_token_usage: { + input_tokens: inputTokens, + cached_input_tokens: cached, + cache_write_input_tokens: 0, + output_tokens: output, + reasoning_output_tokens: reasoning, + }, + }, + }, + }); + + it("attributes usage to the model from the preceding turn context", () => { + const state = initialCodexScanState(); + parseCodexLine(sessionMeta, state); + parseCodexLine(turnContext, state); + const record = parseCodexLine(tokenCount(19239, 11008, 299, 116), state); + + expect(record?.provider).toBe("codex"); + expect(record?.model).toBe("gpt-5.6-sol"); + expect(record?.sessionId).toBe("019fbbc1-b12c-7360-a685-28c181f0025f"); + // Codex reports input_tokens inclusive of the cached portion. + expect(record?.totals.uncachedInputTokens).toBe(19239 - 11008); + expect(record?.totals.cachedInputTokens).toBe(11008); + expect(record?.totals.reasoningTokens).toBe(116); + }); + + it("skips a repeated token_count so deltas are not double counted", () => { + const state = initialCodexScanState(); + parseCodexLine(turnContext, state); + const first = parseCodexLine(tokenCount(100, 0, 10, 0), state); + const repeat = parseCodexLine(tokenCount(100, 0, 10, 0), state); + + expect(first).not.toBeNull(); + expect(repeat).toBeNull(); + }); + + it("drops usage that arrives before any model is known", () => { + const state = initialCodexScanState(); + expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull(); + }); +}); + +describe("totalTokens", () => { + it("does not add reasoning on top of output", () => { + expect( + totalTokens({ + uncachedInputTokens: 10, + cachedInputTokens: 20, + cacheCreationTokens: 30, + outputTokens: 40, + reasoningTokens: 25, + }), + ).toBe(100); + }); +}); diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index df06c431fd2..a8d3ef41416 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -1,4 +1,4 @@ -import { SettingsIcon } from "lucide-react"; +import { ChartNoAxesColumnIcon, SettingsIcon } from "lucide-react"; import { memo, useCallback } from "react"; import { Link, useNavigate } from "@tanstack/react-router"; @@ -118,11 +118,24 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() { void navigate({ to: "/settings" }); }, [isMobile, navigate, setOpenMobile]); + const handleUsageClick = useCallback(() => { + if (isMobile) { + setOpenMobile(false); + } + void navigate({ to: "/usage" }); + }, [isMobile, navigate, setOpenMobile]); + return ( + + + + Usage + + diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx new file mode 100644 index 00000000000..f855b0ff54f --- /dev/null +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -0,0 +1,337 @@ +import { RefreshCwIcon } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { cn } from "../../lib/utils"; +import { useUsage } from "../../state/usage"; +import { + enumerateDays, + formatCount, + formatDayShort, + formatPercent, + formatTokens, + formatUsd, + makeWindow, +} from "../../usage/usageFormat"; +import { ScrollArea } from "../ui/scroll-area"; +import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; +import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_ORDER } from "./usageProviders"; + +const WINDOW_OPTIONS = [ + { days: 7, label: "7 days" }, + { days: 30, label: "30 days" }, + { days: 90, label: "90 days" }, +] as const; + +export function UsagePage() { + const [windowDays, setWindowDays] = useState(30); + const [metric, setMetric] = useState("tokens"); + + // Recomputed only when the window length changes, so a re-render does not + // shift the range and refetch every environment. + const window = useMemo(() => makeWindow(windowDays), [windowDays]); + const { merged, environments, isPending, isPartial, refresh } = useUsage(window); + + const days = useMemo( + () => enumerateDays(window.sinceDay, window.untilDay), + [window.sinceDay, window.untilDay], + ); + const recentDays = useMemo(() => [...merged.daily].reverse().slice(0, 8), [merged.daily]); + + const activeDays = merged.daily.filter((day) => day.totalTokens > 0).length; + const dailyAverage = activeDays === 0 ? 0 : merged.totalTokens / activeDays; + const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens; + const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput; + + return ( + +
+
+
+

Usage

+

+ {formatDayShort(window.sinceDay)} to {formatDayShort(window.untilDay)} ยท{" "} + {formatPercent(1 - merged.costQuality.unpricedShare, 0)} of responses priced +

+
+
+
+ {WINDOW_OPTIONS.map((option) => ( + + ))} +
+ +
+
+ + + + {isPending ? ( +

+ Scanning provider transcriptsโ€ฆ +

+ ) : ( + <> + {/* Cost first: the financial answer, then the provider split. */} +
+
+
+ + Raw token cost + + + {formatUsd(merged.costUsd)} + + + What these tokens would cost at API rates. Not what you were billed. + +
+ + {merged.providers.map((provider) => ( +
+
+ + + {PROVIDER_LABEL[provider.provider]} + + + {formatUsd(provider.costUsd)} + +
+
+
+
+ + {formatPercent(provider.costShare)} of cost ยท{" "} + {formatTokens(provider.totalTokens)} tokens + +
+ ))} +
+ +
+
+

+ Daily {metric === "tokens" ? "processed tokens" : "cost"} +

+
+
+ {(["tokens", "cost"] as const).map((option) => ( + + ))} +
+ +
+
+ +
+
+ +
+ + + + + +
+ +
+
+

Recent daily cost

+ + + + + {PROVIDER_ORDER.map((provider) => ( + + ))} + + + + + + {recentDays.length === 0 ? ( + + + + ) : ( + recentDays.map((day) => ( + + + {PROVIDER_ORDER.map((provider) => ( + + ))} + + + + )) + )} + +
Day + {PROVIDER_LABEL[provider]} + TotalTokens
+ No activity in this window. +
{formatDayShort(day.day)} + {formatUsd(day.byProvider.get(provider)?.costUsd ?? 0)} + + {formatUsd(day.costUsd)} + + {formatTokens(day.totalTokens)} +
+
+ +
+

Cost quality

+
+ + + + +
+
+
+ + )} +
+
+ ); +} + +function Metric({ + label, + value, + detail, +}: { + readonly label: string; + readonly value: string; + readonly detail: string; +}) { + return ( +
+ {label} + {value} + {detail} +
+ ); +} + +function QualityRow({ label, value }: { readonly label: string; readonly value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +/** + * Says plainly when the totals are incomplete: an environment still answering, + * one that failed, or one whose transcripts another environment already + * reported. + */ +function UsageCoverageNotice({ + environments, + duplicateSources, + isPartial, +}: { + readonly environments: readonly { label: string; error: string | null; isPending: boolean }[]; + readonly duplicateSources: readonly string[]; + readonly isPartial: boolean; +}) { + const failed = environments.filter((environment) => environment.error !== null); + if (failed.length === 0 && duplicateSources.length === 0 && !isPartial) return null; + + return ( +
+ {isPartial ? Some environments are still reporting. Totals are partial. : null} + {failed.map((environment) => ( + {environment.label} could not report usage. + ))} + {duplicateSources.length > 0 ? ( + + Counted once across environments sharing a transcript directory:{" "} + {duplicateSources.join(", ")} + + ) : null} +
+ ); +} diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx new file mode 100644 index 00000000000..e1da33f339d --- /dev/null +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -0,0 +1,154 @@ +import type { UsageProviderKind } from "@t3tools/contracts"; +import { useMemo } from "react"; + +import type { DailyTotals } from "../../usage/usageMerge"; +import { formatDayShort, formatTokens, formatUsd } from "../../usage/usageFormat"; +import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_ORDER } from "./usageProviders"; + +const VIEW_WIDTH = 960; +const VIEW_HEIGHT = 260; +const PADDING_TOP = 12; +const PADDING_BOTTOM = 24; + +export type UsageChartMetric = "tokens" | "cost"; + +interface UsageProviderChartProps { + readonly days: readonly string[]; + readonly daily: readonly DailyTotals[]; + readonly metric: UsageChartMetric; +} + +function valueFor( + daily: DailyTotals | undefined, + provider: UsageProviderKind, + metric: UsageChartMetric, +): number { + const entry = daily?.byProvider.get(provider); + if (entry === undefined) return 0; + return metric === "tokens" ? entry.totalTokens : entry.costUsd; +} + +/** + * Stacked daily area, one band per provider. + * + * Rendered as a plain SVG rather than pulled from a charting library: the page + * needs one chart, and a static path avoids shipping a dependency and any + * repainting animation. + */ +export function UsageProviderChart({ days, daily, metric }: UsageProviderChartProps) { + const byDay = useMemo(() => new Map(daily.map((entry) => [entry.day, entry])), [daily]); + + const { paths, peak } = useMemo(() => { + if (days.length === 0) return { paths: [], peak: 0 }; + + const stacked = days.map((day) => { + const entry = byDay.get(day); + let running = 0; + return PROVIDER_ORDER.map((provider) => { + const base = running; + running += valueFor(entry, provider, metric); + return { provider, base, top: running }; + }); + }); + + const peakValue = stacked.reduce( + (max, columns) => Math.max(max, columns[columns.length - 1]?.top ?? 0), + 0, + ); + const scale = peakValue === 0 ? 0 : (VIEW_HEIGHT - PADDING_TOP - PADDING_BOTTOM) / peakValue; + const stepX = days.length === 1 ? 0 : VIEW_WIDTH / (days.length - 1); + const toY = (value: number) => VIEW_HEIGHT - PADDING_BOTTOM - value * scale; + + const built = PROVIDER_ORDER.map((provider, providerIndex) => { + const top = stacked + .map((columns, dayIndex) => { + const column = columns[providerIndex]; + return `${dayIndex === 0 ? "M" : "L"}${(dayIndex * stepX).toFixed(2)},${toY(column?.top ?? 0).toFixed(2)}`; + }) + .join(" "); + const bottom = stacked + .map((columns, dayIndex) => { + const reversed = stacked.length - 1 - dayIndex; + const column = stacked[reversed]?.[providerIndex]; + return `L${(reversed * stepX).toFixed(2)},${toY(column?.base ?? 0).toFixed(2)}`; + }) + .join(" "); + return { provider, d: `${top} ${bottom} Z` }; + }); + + return { paths: built, peak: peakValue }; + }, [byDay, days, metric]); + + const firstDay = days[0]; + const middleDay = days[Math.floor(days.length / 2)]; + const lastDay = days[days.length - 1]; + + return ( +
+
+
+ {metric === "tokens" ? formatTokens(peak) : formatUsd(peak)} + {metric === "tokens" ? formatTokens(peak / 2) : formatUsd(peak / 2)} + 0 +
+ + {[0, 0.25, 0.5, 0.75, 1].map((fraction) => { + const y = PADDING_TOP + (VIEW_HEIGHT - PADDING_TOP - PADDING_BOTTOM) * fraction; + return ( + + ); + })} + {paths.map(({ provider, d }) => ( + + ))} + +
+
+ {firstDay === undefined ? "" : formatDayShort(firstDay)} + {middleDay === undefined ? "" : formatDayShort(middleDay)} + {lastDay === undefined ? "" : formatDayShort(lastDay)} +
+
+ ); +} + +export function UsageChartLegend() { + return ( +
+ {PROVIDER_ORDER.map((provider) => ( + + + {PROVIDER_LABEL[provider]} + + ))} +
+ ); +} diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts new file mode 100644 index 00000000000..50c9c78a92f --- /dev/null +++ b/apps/web/src/components/usage/usageProviders.ts @@ -0,0 +1,18 @@ +import type { UsageProviderKind } from "@t3tools/contracts"; + +/** + * Stacking and table order. Codex sits under Claude Code so the larger band + * reads as the top surface, matching the reference layout. + */ +export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; + +export const PROVIDER_LABEL: Record = { + claude: "Claude Code", + codex: "Codex", +}; + +/** Claude's brand orange against a neutral white for Codex. */ +export const PROVIDER_COLOR: Record = { + claude: "#d97757", + codex: "#e6e6e6", +}; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 3da96820ab9..eb31a8de91c 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -9,6 +9,7 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as UsageRouteImport } from './routes/usage' import { Route as SettingsRouteImport } from './routes/settings' import { Route as PairRouteImport } from './routes/pair' import { Route as ConnectRouteImport } from './routes/connect' @@ -26,6 +27,11 @@ import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' +const UsageRoute = UsageRouteImport.update({ + id: '/usage', + path: '/usage', + getParentRoute: () => rootRouteImport, +} as any) const SettingsRoute = SettingsRouteImport.update({ id: '/settings', path: '/settings', @@ -112,6 +118,7 @@ export interface FileRoutesByFullPath { '/connect': typeof ConnectRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/usage': typeof UsageRoute '/connect/callback': typeof ConnectCallbackRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute @@ -128,6 +135,7 @@ export interface FileRoutesByTo { '/connect': typeof ConnectRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/usage': typeof UsageRoute '/connect/callback': typeof ConnectCallbackRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute @@ -147,6 +155,7 @@ export interface FileRoutesById { '/connect': typeof ConnectRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/usage': typeof UsageRoute '/connect_/callback': typeof ConnectCallbackRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute @@ -167,6 +176,7 @@ export interface FileRouteTypes { | '/connect' | '/pair' | '/settings' + | '/usage' | '/connect/callback' | '/settings/appearance' | '/settings/archived' @@ -183,6 +193,7 @@ export interface FileRouteTypes { | '/connect' | '/pair' | '/settings' + | '/usage' | '/connect/callback' | '/settings/appearance' | '/settings/archived' @@ -201,6 +212,7 @@ export interface FileRouteTypes { | '/connect' | '/pair' | '/settings' + | '/usage' | '/connect_/callback' | '/settings/appearance' | '/settings/archived' @@ -220,11 +232,19 @@ export interface RootRouteChildren { ConnectRoute: typeof ConnectRoute PairRoute: typeof PairRoute SettingsRoute: typeof SettingsRouteWithChildren + UsageRoute: typeof UsageRoute ConnectCallbackRoute: typeof ConnectCallbackRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/usage': { + id: '/usage' + path: '/usage' + fullPath: '/usage' + preLoaderRoute: typeof UsageRouteImport + parentRoute: typeof rootRouteImport + } '/settings': { id: '/settings' path: '/settings' @@ -385,6 +405,7 @@ const rootRouteChildren: RootRouteChildren = { ConnectRoute: ConnectRoute, PairRoute: PairRoute, SettingsRoute: SettingsRouteWithChildren, + UsageRoute: UsageRoute, ConnectCallbackRoute: ConnectCallbackRoute, } export const routeTree = rootRouteImport diff --git a/apps/web/src/routes/usage.tsx b/apps/web/src/routes/usage.tsx new file mode 100644 index 00000000000..c617e434b2e --- /dev/null +++ b/apps/web/src/routes/usage.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { UsagePage } from "../components/usage/UsagePage"; + +export const Route = createFileRoute("/usage")({ + component: UsagePage, +}); diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts new file mode 100644 index 00000000000..6e610358ef6 --- /dev/null +++ b/apps/web/src/state/usage.ts @@ -0,0 +1,107 @@ +/** + * Multi-environment usage state. + * + * Every connected environment answers the same typed query; the client merges + * the results. Raw transcripts never leave the machine that produced them. + * + * @module state/usage + */ +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; +import { + USAGE_CONTRACT_VERSION, + type EnvironmentId, + type UsageSummary, + type UsageSummaryInput, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useMemo } from "react"; + +import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "../usage/usageMerge"; +import { environmentPresentations } from "./presentation"; +import { serverEnvironment } from "./server"; + +export interface EnvironmentUsageStatus { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly isPending: boolean; + readonly error: string | null; + readonly summary: UsageSummary | null; +} + +/** + * Reads every environment's summary for one window. + * + * Keyed by the serialised window so switching ranges does not thrash the atom + * cache, and so each environment's query is shared with any other reader of the + * same window. + */ +const usageByWindowAtom = Atom.family((windowKey: string) => + Atom.make((get): readonly EnvironmentUsageStatus[] => { + const input = JSON.parse(windowKey) as UsageSummaryInput; + const presentations = get(environmentPresentations.presentationsAtom); + + const statuses: EnvironmentUsageStatus[] = []; + for (const [environmentId, presentation] of presentations) { + const result = get(serverEnvironment.usageSummary({ environmentId, input })); + statuses.push({ + environmentId, + label: presentation.entry.target.label, + isPending: result.waiting, + error: result._tag === "Failure" ? "This environment could not report usage." : null, + summary: Option.getOrNull(AsyncResult.value(result)), + }); + } + return statuses; + }).pipe(Atom.withLabel(`web-usage:window:${windowKey}`)), +); + +export interface UsageView { + readonly merged: MergedUsage; + readonly environments: readonly EnvironmentUsageStatus[]; + /** True until at least one environment has answered. */ + readonly isPending: boolean; + /** True while some environments are still answering but others have. */ + readonly isPartial: boolean; + readonly refresh: () => void; +} + +export function useUsage(input: UsageSummaryInput): UsageView { + const windowKey = useMemo( + () => + JSON.stringify({ + sinceDay: input.sinceDay, + untilDay: input.untilDay, + timeZone: input.timeZone, + }), + [input.sinceDay, input.untilDay, input.timeZone], + ); + const atom = usageByWindowAtom(windowKey); + const environments = useAtomValue(atom); + const refresh = useAtomRefresh(atom); + + const merged = useMemo(() => { + const answered: EnvironmentUsage[] = environments.flatMap((environment) => + environment.summary === null + ? [] + : [ + { + environmentId: environment.environmentId, + label: environment.label, + summary: environment.summary, + }, + ], + ); + return mergeUsage(answered, USAGE_CONTRACT_VERSION); + }, [environments]); + + const answeredCount = environments.filter((environment) => environment.summary !== null).length; + + return { + merged, + environments, + isPending: answeredCount === 0 && environments.some((environment) => environment.isPending), + isPartial: answeredCount > 0 && answeredCount < environments.length, + refresh, + }; +} diff --git a/apps/web/src/usage/usageFormat.ts b/apps/web/src/usage/usageFormat.ts new file mode 100644 index 00000000000..3f0911ef22e --- /dev/null +++ b/apps/web/src/usage/usageFormat.ts @@ -0,0 +1,99 @@ +/** + * Display formatting for the usage page. + * + * @module usageFormat + */ +import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; + +const CURRENCY = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +const INTEGER = new Intl.NumberFormat("en-US"); + +export function formatUsd(value: number): string { + return CURRENCY.format(value); +} + +export function formatCount(value: number): string { + return INTEGER.format(Math.round(value)); +} + +/** + * Compacts a token count to three significant figures with a unit suffix, so + * columns of numbers line up at a glance (`19.9B`, `76.7M`, `804K`). + */ +export function formatTokens(value: number): string { + const abs = Math.abs(value); + if (abs >= 1e12) return `${trim(value / 1e12)}T`; + if (abs >= 1e9) return `${trim(value / 1e9)}B`; + if (abs >= 1e6) return `${trim(value / 1e6)}M`; + if (abs >= 1e3) return `${trim(value / 1e3)}K`; + return INTEGER.format(Math.round(value)); +} + +function trim(value: number): string { + const abs = Math.abs(value); + const digits = abs >= 100 ? 0 : abs >= 10 ? 1 : 2; + return value.toFixed(digits).replace(/\.0+$/, ""); +} + +export function formatPercent(share: number, digits = 1): string { + return `${(share * 100).toFixed(digits)}%`; +} + +/** `2026-08-07` to `Aug 7`. */ +export function formatDayShort(day: string): string { + const [year, month, dayOfMonth] = day.split("-").map((part) => Number(part)); + if (year === undefined || month === undefined || dayOfMonth === undefined) return day; + const MONTHS = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ]; + return `${MONTHS[month - 1] ?? ""} ${dayOfMonth}`; +} + +/** Inclusive day list between two `YYYY-MM-DD` bounds. */ +export function enumerateDays(sinceDay: string, untilDay: string): readonly string[] { + const days: string[] = []; + const start = Date.parse(`${sinceDay}T00:00:00Z`); + const end = Date.parse(`${untilDay}T00:00:00Z`); + if (Number.isNaN(start) || Number.isNaN(end) || end < start) return days; + + for (let cursor = start; cursor <= end; cursor += 86_400_000) { + days.push(new Date(cursor).toISOString().slice(0, 10)); + } + return days; +} + +/** + * The window the page requests, expressed in the viewer's own time zone so days + * line up with what they actually experienced. + */ +export function makeWindow(days: number, now = new Date()): UsageSummaryInput { + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + const format = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + return { + sinceDay: UsageDay.make(format.format(new Date(now.getTime() - (days - 1) * 86_400_000))), + untilDay: UsageDay.make(format.format(now)), + timeZone, + }; +} diff --git a/apps/web/src/usage/usageMerge.ts b/apps/web/src/usage/usageMerge.ts new file mode 100644 index 0000000000000000000000000000000000000000..cae5c05b9bc8efc39188a896a59d0f012772a352 GIT binary patch literal 9518 zcmbtaZExJT5$FKy&RBus=OH&}~z1=*chb&uv|o z_6%~hO`5g#$*YqS^SiqcN;1uDni+kgyUU5um^81ea<5-j}$rWy*81 zZ3C@GJKi$;bN`ee=Bty#j4bl7Pj#D?HkM=?9v%1yDeEA*E~{<(*+SN;+pJ6z?X}GJ zQ~k|Uc6PrpS>5FGd(8KGoi=9nWXnuP+jB4cH7&~h-1G7j8k*7GpLxz|FIqaMkHMlJ z((PZJon^~85k0_rW4+Ogvgq)7-f-`5_+MRG7VUoCY99@|oykWkQVxXpFy&QKWXFiQ zYWp3EEX~&DI=zP%+Q<#&tl{D?pK<%KGPI|6mAfOqthQZy#F_p8)jNa{L(UOyK$+v$ zfrYIrJo^Zf8Iwlwam@d%u@)lE2ZsbFc6&q+2lL+kMDo=cN3M z*_}OmrXk7FZe3eLdD^D5OD*M1z1;$j@n=c`?S|@GV0(=e(fni+Mq%ZKJ=JyHHJQ;5 z^3gdF58zs?x)z#O;H}T8<;#3@+yan{A~<>uE_@5`bIIuz_n8LKP_@6A{Q~c98KAw4 zrz8d$R@U{#T&3+=R+jQd2-vo3D*F2L!E-wN<>`a);J@Yc;j04xD4sLkuO>qtVzn39 zTIUL?ca8@&caL*3DkBBjVWAbx<$=)1Zrb8^<-73?f^ zw?bad>JGI8i?Ijt0n8Knw0691i^gJSkPKJCop39kL%uK#Wo24y*vXVxUO6csR7>mh z9woy%ZCN%^>ra+eF0c1~n#9hGT!YGlQIl4CDsm}Pb_ME;27(*Q{NTxyty8!I3X^tc zOr?vQv%zJYkio4N>OGJ6oP)(LH}I1O9U!o*Y2NPtKKNgpT*L#FQ(xvt8*?$HZyuuS z;4Vbf0?kctqYJ7~&~YA{ziP4M%lz>^nsJj{00ta{J&m&$vb(?HEyIugnw9Z^v zdN`-24>Q7!~F;F$SKzub(%J--T-KHS)a`n74NCF^S+<|?|pfqyV){$R*_KVW9u zD-M?#f^o3Skc?f_he~j#N5bUJyCV<_D4@(af~NpG-k^ihlQho-O?2>!u#qeGo~*AC zoC?_u5K_Vs7YqN}0jYDk-p($t{ch4cCgk)2=SqbVxUxOVQm!1za`WrA37bMWWSXW02F8(l(iC2Ix7CP{{2` zpqn%Uk5k3Xz$~3^25^~ImG{y2AFe+A=f5A`fBeV$U(fx>Bs4e|Ac8afU4Q#=AG{L2js9eM}%}~GORj1=zh|zADkRvR%EWFRS=OLYYF`ROwG?5!1|L=SBFWLuSQ|l=~xNH zql6i1KrREnVzT3=Pw&FG=iexi(Xf4`yDr60!@Dzl90M#Bt<-cCca2n+T*&&;n)zku z$AQ@B7_f}m6=N@yJKva;t7{&nol?=Fc;#-3lhW^5*}1sE+>AF+u@KDi=hDU~N$DE${g4+!^~!d7L@@#wE;EfM z0Sv85OExJ@b=R&Pq`u@wNJkCD@EBs5fx8*dI`tP6;+j zcUDTHk^Ft_2xoG$z~CG{Ma(;nu!5}qJ-)zFmrE)jQ=*ab!H2?e+|o|YjS`WsO8eT# zOT4brX~?si?*N+uV!KJw=C;6U74aO$6;x(Ko%y@SEG?L{ous#6+$JN zsiOWR3dNfj!NK{F`$L!KWDy?=rj-!(9z=5^OWD%1&_6!|WxY%u;$mxd6f_^xc+ulQ zE#4f$%B#xP9$XhkRl5qw)0y%-gegNpU?6Drnt)}GiT(YsbtX|y@Z1@a!aWK?TsQ9q zD=fa>kE%Hzx2#x++vh@;gcY&nI3N^ZJpGjeb74Omdc2gA^HW@yoSu+GXGgFxe>x_T z^AWC04(eSwF-Dxpp%KAw&co3_A7WPsdT<&jWJyTIXfsfbcI3d|mCnk(l=AZn!6pGW z3^K+`0&2rGy~xfv$1n%i6BY__67HD1OAh@9y?N88wB9E}1btsrdDZh;P=<=DEe_*~8>m$0JYT@PP{28-ul0 z9VnLnzt%KBOa5_1+?7r}KbUs#p=z<@+K>PB+J}JOS3DD`4rQuy$y)u#HK&$+GCV*l zMeOJ`BiP1%6lbfKk(l!49}>4GJ}Nf9ONlsZd(a58kTgQK^@!Ji9^)#Q-TULHgwx_& z%$8oy(Ffpjo&JNad=biE8hhhr6^n;a@QPpcMTmQ-6iy#6-I32pO82x96W0H97W04d6Z#Lc;~0^g z9b7M55V@Uj7SfNQcj3{UD@6P?Jfw;~`p@wal1F(=rar-I`fMGEwvans^yR`>$``#@ p;m##Z1b5~eW=H-#Zgix+TlB-D;aipHcYciIcPCvoFT2kL{{wx-nT`Me literal 0 HcmV?d00001 diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 8c61a939e9e..f579453c27f 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -707,6 +707,13 @@ export function createServerEnvironmentAtoms( tag: WS_METHODS.serverGetResourceTelemetryHistory, staleTimeMs: 5_000, }), + // A cold transcript scan is measured in seconds, so keep the result around + // long enough that switching windows or re-rendering does not rescan. + usageSummary: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:usage-summary", + tag: WS_METHODS.serverGetUsageSummary, + staleTimeMs: 60_000, + }), configProjection, welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:server:welcome", diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 7e8d37df9c9..2cbc80d9b68 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -77,6 +77,12 @@ export const UsageBucket = Schema.Struct({ model: TrimmedNonEmptyString, totals: UsageTokenTotals, costUsd: Schema.Number, + /** + * What the cached input would have cost at full input rates minus what it + * actually cost. Requires the rate table, so it is computed alongside cost + * rather than derived on the client. + */ + cacheSavingsUsd: Schema.Number, costSource: UsageCostSource, /** Distinct assistant responses, after de-duplication. */ records: NonNegativeInt, From 3dc2c6cf58044e5ed8dac41f9aee903956cd94bd Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 23:34:45 -0700 Subject: [PATCH 03/12] test(usage): cover cross-environment merge and lint fixes Co-Authored-By: Claude Opus 5 (1M context) --- .../server/src/usage/usageTranscriptReader.ts | 18 +- apps/web/src/components/usage/UsagePage.tsx | 2 +- apps/web/src/usage/usageMerge.test.ts | 188 ++++++++++++++++++ 3 files changed, 198 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/usage/usageMerge.test.ts diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index 987c9592b1f..9b0fe633cd7 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -10,10 +10,10 @@ * * @module usageTranscriptReader */ -import { createReadStream } from "node:fs"; -import { readdir, stat } from "node:fs/promises"; -import { createInterface } from "node:readline"; -import { join } from "node:path"; +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeReadline from "node:readline"; import type { UsageProviderKind } from "@t3tools/contracts"; @@ -47,19 +47,19 @@ export async function listTranscriptFiles( const walk = async (dir: string): Promise => { let entries; try { - entries = await readdir(dir, { withFileTypes: true }); + entries = await NodeFSP.readdir(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { - const child = join(dir, entry.name); + const child = NodePath.join(dir, entry.name); if (entry.isDirectory()) { await walk(child); continue; } if (!entry.name.endsWith(".jsonl")) continue; try { - const stats = await stat(child); + const stats = await NodeFSP.stat(child); if (stats.mtimeMs >= sinceMs) { found.push({ path: child, size: stats.size, mtimeMs: stats.mtimeMs }); } @@ -88,8 +88,8 @@ export async function readTranscriptRecords( const codexState = initialCodexScanState(); try { - const lines = createInterface({ - input: createReadStream(filePath, { encoding: "utf8" }), + const lines = NodeReadline.createInterface({ + input: NodeFS.createReadStream(filePath, { encoding: "utf8" }), crlfDelay: Infinity, }); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index f855b0ff54f..fdcaf548d25 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -35,7 +35,7 @@ export function UsagePage() { () => enumerateDays(window.sinceDay, window.untilDay), [window.sinceDay, window.untilDay], ); - const recentDays = useMemo(() => [...merged.daily].reverse().slice(0, 8), [merged.daily]); + const recentDays = useMemo(() => merged.daily.toReversed().slice(0, 8), [merged.daily]); const activeDays = merged.daily.filter((day) => day.totalTokens > 0).length; const dailyAverage = activeDays === 0 ? 0 : merged.totalTokens / activeDays; diff --git a/apps/web/src/usage/usageMerge.test.ts b/apps/web/src/usage/usageMerge.test.ts new file mode 100644 index 00000000000..de47919d2b8 --- /dev/null +++ b/apps/web/src/usage/usageMerge.test.ts @@ -0,0 +1,188 @@ +import { + USAGE_CONTRACT_VERSION, + type EnvironmentId, + type UsageBucket, + type UsageDay, + type UsageProviderKind, + type UsageSummary, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { mergeUsage, type EnvironmentUsage } from "./usageMerge"; + +function bucket(overrides: Partial = {}): UsageBucket { + return { + day: "2026-08-07" as UsageDay, + provider: "claude", + model: "claude-fable-5", + totals: { + uncachedInputTokens: 100, + cachedInputTokens: 1000, + cacheCreationTokens: 10, + outputTokens: 50, + reasoningTokens: 0, + }, + costUsd: 10, + cacheSavingsUsd: 2, + costSource: "modelPriced", + records: 5, + unpricedRecords: 0, + sessions: 1, + ...overrides, + }; +} + +function summary( + buckets: readonly UsageBucket[], + sources: readonly { provider: UsageProviderKind; hostId: string; homePath: string }[], + contractVersion: number = USAGE_CONTRACT_VERSION, +): UsageSummary { + return { + contractVersion, + readAt: "2026-08-07T00:00:00.000Z", + timeZone: "UTC", + sinceDay: "2026-08-01" as UsageDay, + untilDay: "2026-08-31" as UsageDay, + buckets, + sources: sources.map((source) => ({ + fingerprint: { + hostId: source.hostId, + provider: source.provider, + resolvedHomePath: source.homePath, + }, + status: "ok" as const, + scannedFiles: 1, + skippedFiles: 0, + malformedRecords: 0, + message: null, + })), + pricing: { status: "fresh", source: "litellm", fetchedAt: null, knownModels: 10 }, + scanDurationMs: 1, + }; +} + +function environment(id: string, usageSummary: UsageSummary): EnvironmentUsage { + return { environmentId: id as EnvironmentId, label: id, summary: usageSummary }; +} + +describe("mergeUsage", () => { + it("sums environments that read different transcript directories", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary([bucket()], [{ provider: "claude", hostId: "mac", homePath: "/a/.claude" }]), + ), + environment( + "env-b", + summary([bucket()], [{ provider: "claude", hostId: "linux", homePath: "/b/.claude" }]), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(20); + expect(merged.records).toBe(10); + expect(merged.duplicateSources).toHaveLength(0); + }); + + it("counts a shared transcript directory once", () => { + // Two worktree servers on one machine resolve the same provider home. + const shared = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" }; + const merged = mergeUsage( + [ + environment("env-a", summary([bucket()], [shared])), + environment("env-b", summary([bucket()], [shared])), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(10); + expect(merged.records).toBe(5); + expect(merged.duplicateSources).toHaveLength(1); + expect(merged.contributingEnvironments).toEqual(["env-a"]); + }); + + it("drops only the duplicated provider, keeping the environment's other one", () => { + const sharedClaude = { + provider: "claude" as const, + hostId: "mac", + homePath: "/home/theo/.claude", + }; + const merged = mergeUsage( + [ + environment("env-a", summary([bucket()], [sharedClaude])), + environment( + "env-b", + summary( + [bucket(), bucket({ provider: "codex", model: "gpt-5.6-sol", costUsd: 4 })], + [sharedClaude, { provider: "codex", hostId: "mac", homePath: "/home/theo/.codex" }], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + // env-b's claude bucket is dropped, its codex bucket survives. + expect(merged.costUsd).toBe(14); + expect(merged.providers.map((provider) => provider.provider).sort()).toEqual([ + "claude", + "codex", + ]); + }); + + it("excludes an environment reporting an older contract version", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary([bucket()], [{ provider: "claude", hostId: "mac", homePath: "/a" }]), + ), + environment( + "env-b", + summary( + [bucket()], + [{ provider: "claude", hostId: "linux", homePath: "/b" }], + USAGE_CONTRACT_VERSION - 1, + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(10); + expect(merged.staleEnvironments).toEqual(["env-b"]); + }); + + it("derives provider shares and cost quality", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [ + bucket({ costUsd: 75 }), + bucket({ provider: "codex", model: "gpt-5.6-sol", costUsd: 25, unpricedRecords: 5 }), + ], + [ + { provider: "claude", hostId: "mac", homePath: "/a/.claude" }, + { provider: "codex", hostId: "mac", homePath: "/a/.codex" }, + ], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.providers[0]?.provider).toBe("claude"); + expect(merged.providers[0]?.costShare).toBeCloseTo(0.75, 5); + expect(merged.costQuality.unpricedShare).toBeCloseTo(0.5, 5); + expect(merged.costQuality.cacheSavingsUsd).toBe(4); + }); + + it("returns empty totals with no environments", () => { + const merged = mergeUsage([], USAGE_CONTRACT_VERSION); + expect(merged.costUsd).toBe(0); + expect(merged.daily).toHaveLength(0); + }); +}); From 5937c2ab4e2dfca24a632379a3210069ff235d20 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 23:45:02 -0700 Subject: [PATCH 04/12] fix(web): usage chart scale, hover, and model-first breakdown Leads with cost, adds a real gridline-aligned y-axis and a hover readout, smooths the daily series with monotone cubic interpolation, and defaults the breakdown table to cost by model. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/usage/UsagePage.tsx | 155 ++++++--- .../components/usage/UsageProviderChart.tsx | 315 ++++++++++++++---- apps/web/src/usage/usageMerge.ts | Bin 9518 -> 10759 bytes 3 files changed, 358 insertions(+), 112 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index fdcaf548d25..3a927862bf8 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -24,7 +24,8 @@ const WINDOW_OPTIONS = [ export function UsagePage() { const [windowDays, setWindowDays] = useState(30); - const [metric, setMetric] = useState("tokens"); + const [metric, setMetric] = useState("cost"); + const [breakdown, setBreakdown] = useState<"model" | "day">("model"); // Recomputed only when the window length changes, so a re-render does not // shift the range and refetch every environment. @@ -49,8 +50,7 @@ export function UsagePage() {

Usage

- {formatDayShort(window.sinceDay)} to {formatDayShort(window.untilDay)} ยท{" "} - {formatPercent(1 - merged.costQuality.unpricedShare, 0)} of responses priced + {formatDayShort(window.sinceDay)} to {formatDayShort(window.untilDay)}

@@ -201,50 +201,119 @@ export function UsagePage() {
-

Recent daily cost

- - - - - {PROVIDER_ORDER.map((provider) => ( - - ))} - - - - - - {recentDays.length === 0 ? ( - - +
+

Breakdown

+
+ {(["model", "day"] as const).map((option) => ( + + ))} +
+
+ + {breakdown === "model" ? ( +
Day - {PROVIDER_LABEL[provider]} - TotalTokens
- No activity in this window. -
+ + + + + + - ) : ( - recentDays.map((day) => ( - - - {PROVIDER_ORDER.map((provider) => ( - - ))} - + + {merged.models.length === 0 ? ( + + - + ) : ( + merged.models.map((model) => ( + + + + + + + )) + )} + +
ModelCostShareTokens
{formatDayShort(day.day)} - {formatUsd(day.byProvider.get(provider)?.costUsd ?? 0)} - - {formatUsd(day.costUsd)} +
+ No activity in this window. - {formatTokens(day.totalTokens)} +
+ + + {model.model} + + + {formatUsd(model.costUsd)} + + {formatPercent(model.costShare)} + + {formatTokens(model.totalTokens)} +
+ ) : ( + + + + + {PROVIDER_ORDER.map((provider) => ( + + ))} + + + + + + {recentDays.length === 0 ? ( + + - )) - )} - -
Day + {PROVIDER_LABEL[provider]} + TotalTokens
+ No activity in this window.
+ ) : ( + recentDays.map((day) => ( + + {formatDayShort(day.day)} + {PROVIDER_ORDER.map((provider) => ( + + {formatUsd(day.byProvider.get(provider)?.costUsd ?? 0)} + + ))} + + {formatUsd(day.costUsd)} + + + {formatTokens(day.totalTokens)} + + + )) + )} + + + )}
diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index e1da33f339d..12e9dca6d2d 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -1,5 +1,5 @@ import type { UsageProviderKind } from "@t3tools/contracts"; -import { useMemo } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import type { DailyTotals } from "../../usage/usageMerge"; import { formatDayShort, formatTokens, formatUsd } from "../../usage/usageFormat"; @@ -7,8 +7,7 @@ import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_ORDER } from "./usageProviders const VIEW_WIDTH = 960; const VIEW_HEIGHT = 260; -const PADDING_TOP = 12; -const PADDING_BOTTOM = 24; +const TICK_COUNT = 4; export type UsageChartMetric = "tokens" | "cost"; @@ -18,6 +17,11 @@ interface UsageProviderChartProps { readonly metric: UsageChartMetric; } +interface Point { + readonly x: number; + readonly y: number; +} + function valueFor( daily: DailyTotals | undefined, provider: UsageProviderKind, @@ -29,17 +33,98 @@ function valueFor( } /** - * Stacked daily area, one band per provider. + * Monotone cubic tangents (Fritsch-Carlson). * - * Rendered as a plain SVG rather than pulled from a charting library: the page - * needs one chart, and a static path avoids shipping a dependency and any - * repainting animation. + * Plain cubic smoothing overshoots on spiky daily data and would dip the area + * below zero between points, which reads as negative spend. This variant is + * shape-preserving, so a smoothed series never leaves the range of its samples. */ +function monotoneTangents(points: readonly Point[]): readonly number[] { + const count = points.length; + if (count < 2) return [0]; + + const slopes: number[] = []; + for (let index = 0; index < count - 1; index += 1) { + const dx = (points[index + 1]?.x ?? 0) - (points[index]?.x ?? 0); + const dy = (points[index + 1]?.y ?? 0) - (points[index]?.y ?? 0); + slopes.push(dx === 0 ? 0 : dy / dx); + } + + const tangents: number[] = Array.from({ length: count }, () => 0); + tangents[0] = slopes[0] ?? 0; + tangents[count - 1] = slopes[count - 2] ?? 0; + for (let index = 1; index < count - 1; index += 1) { + const previous = slopes[index - 1] ?? 0; + const next = slopes[index] ?? 0; + tangents[index] = previous * next <= 0 ? 0 : (previous + next) / 2; + } + + for (let index = 0; index < count - 1; index += 1) { + const slope = slopes[index] ?? 0; + if (slope === 0) { + tangents[index] = 0; + tangents[index + 1] = 0; + continue; + } + const a = (tangents[index] ?? 0) / slope; + const b = (tangents[index + 1] ?? 0) / slope; + const magnitude = a * a + b * b; + if (magnitude > 9) { + const scale = 3 / Math.sqrt(magnitude); + tangents[index] = scale * a * slope; + tangents[index + 1] = scale * b * slope; + } + } + + return tangents; +} + +/** Smoothed polyline through `points`, as a sequence of cubic segments. */ +function smoothSegments(points: readonly Point[], startCommand: "M" | "L"): string { + if (points.length === 0) return ""; + const first = points[0]; + if (first === undefined) return ""; + if (points.length === 1) return `${startCommand}${first.x.toFixed(2)},${first.y.toFixed(2)}`; + + const tangents = monotoneTangents(points); + let path = `${startCommand}${first.x.toFixed(2)},${first.y.toFixed(2)}`; + + for (let index = 0; index < points.length - 1; index += 1) { + const from = points[index]; + const to = points[index + 1]; + if (from === undefined || to === undefined) continue; + const dx = to.x - from.x; + const c1x = from.x + dx / 3; + const c1y = from.y + ((tangents[index] ?? 0) * dx) / 3; + const c2x = to.x - dx / 3; + const c2y = to.y - ((tangents[index + 1] ?? 0) * dx) / 3; + path += ` C${c1x.toFixed(2)},${c1y.toFixed(2)} ${c2x.toFixed(2)},${c2y.toFixed(2)} ${to.x.toFixed(2)},${to.y.toFixed(2)}`; + } + + return path; +} + +/** Rounds a scale maximum up to a readable 1/2/5 x 10^n step. */ +function niceTicks(peak: number, count: number): readonly number[] { + if (peak <= 0) return [0]; + const rawStep = peak / count; + const magnitude = 10 ** Math.floor(Math.log10(rawStep)); + const normalized = rawStep / magnitude; + const step = (normalized > 5 ? 10 : normalized > 2 ? 5 : normalized > 1 ? 2 : 1) * magnitude; + const ticks: number[] = []; + for (let value = 0; value <= peak + step * 0.001; value += step) ticks.push(value); + return ticks; +} + export function UsageProviderChart({ days, daily, metric }: UsageProviderChartProps) { const byDay = useMemo(() => new Map(daily.map((entry) => [entry.day, entry])), [daily]); + const [hoverIndex, setHoverIndex] = useState(null); + const plotRef = useRef(null); - const { paths, peak } = useMemo(() => { - if (days.length === 0) return { paths: [], peak: 0 }; + const { paths, ticks, scaleMax, stepX } = useMemo(() => { + if (days.length === 0) { + return { paths: [], ticks: [0] as readonly number[], scaleMax: 0, stepX: 0 }; + } const stacked = days.map((day) => { const entry = byDay.get(day); @@ -47,90 +132,182 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr return PROVIDER_ORDER.map((provider) => { const base = running; running += valueFor(entry, provider, metric); - return { provider, base, top: running }; + return { base, top: running }; }); }); - const peakValue = stacked.reduce( + const peak = stacked.reduce( (max, columns) => Math.max(max, columns[columns.length - 1]?.top ?? 0), 0, ); - const scale = peakValue === 0 ? 0 : (VIEW_HEIGHT - PADDING_TOP - PADDING_BOTTOM) / peakValue; - const stepX = days.length === 1 ? 0 : VIEW_WIDTH / (days.length - 1); - const toY = (value: number) => VIEW_HEIGHT - PADDING_BOTTOM - value * scale; + const tickValues = niceTicks(peak, TICK_COUNT); + const max = tickValues[tickValues.length - 1] ?? 0; + const step = days.length === 1 ? 0 : VIEW_WIDTH / (days.length - 1); + const toY = (value: number) => + max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * VIEW_HEIGHT; const built = PROVIDER_ORDER.map((provider, providerIndex) => { - const top = stacked - .map((columns, dayIndex) => { - const column = columns[providerIndex]; - return `${dayIndex === 0 ? "M" : "L"}${(dayIndex * stepX).toFixed(2)},${toY(column?.top ?? 0).toFixed(2)}`; - }) - .join(" "); - const bottom = stacked - .map((columns, dayIndex) => { - const reversed = stacked.length - 1 - dayIndex; - const column = stacked[reversed]?.[providerIndex]; - return `L${(reversed * stepX).toFixed(2)},${toY(column?.base ?? 0).toFixed(2)}`; - }) - .join(" "); - return { provider, d: `${top} ${bottom} Z` }; + const top: Point[] = stacked.map((columns, dayIndex) => ({ + x: dayIndex * step, + y: toY(columns[providerIndex]?.top ?? 0), + })); + const bottom: Point[] = stacked + .map((columns, dayIndex) => ({ + x: dayIndex * step, + y: toY(columns[providerIndex]?.base ?? 0), + })) + .toReversed(); + + return { + provider, + area: `${smoothSegments(top, "M")} ${smoothSegments(bottom, "L")} Z`, + line: smoothSegments(top, "M"), + }; }); - return { paths: built, peak: peakValue }; + return { paths: built, ticks: tickValues, scaleMax: max, stepX: step }; }, [byDay, days, metric]); - const firstDay = days[0]; - const middleDay = days[Math.floor(days.length / 2)]; - const lastDay = days[days.length - 1]; + const format = metric === "tokens" ? formatTokens : formatUsd; + + const handleMove = useCallback( + (event: React.MouseEvent) => { + const bounds = plotRef.current?.getBoundingClientRect(); + if (bounds === undefined || bounds.width === 0 || days.length === 0) return; + const fraction = (event.clientX - bounds.left) / bounds.width; + const index = Math.round(fraction * (days.length - 1)); + setHoverIndex(Math.min(days.length - 1, Math.max(0, index))); + }, + [days.length], + ); + + const hoveredDay = hoverIndex === null ? undefined : days[hoverIndex]; + const hoveredEntry = hoveredDay === undefined ? undefined : byDay.get(hoveredDay); + const hoverLeft = days.length <= 1 ? 0 : ((hoverIndex ?? 0) / (days.length - 1)) * 100; return (
-
-
- {metric === "tokens" ? formatTokens(peak) : formatUsd(peak)} - {metric === "tokens" ? formatTokens(peak / 2) : formatUsd(peak / 2)} - 0 +
+ {/* Axis labels sit outside the plot so they stay aligned to gridlines. */} +
+ {ticks.map((tick) => ( + + {tick === 0 ? "0" : format(tick)} + + ))}
- setHoverIndex(null)} > - {[0, 0.25, 0.5, 0.75, 1].map((fraction) => { - const y = PADDING_TOP + (VIEW_HEIGHT - PADDING_TOP - PADDING_BOTTOM) * fraction; - return ( + + {ticks.map((tick) => { + const y = + scaleMax === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (tick / scaleMax) * VIEW_HEIGHT; + return ( + + ); + })} + + {paths.map(({ provider, area, line }) => ( + + + + + ))} + + {hoverIndex === null ? null : ( - ); - })} - {paths.map(({ provider, d }) => ( - - ))} - + )} + + + {hoveredDay === undefined ? null : ( +
60 ? "translateX(-100%)" : "translateX(0)", + }} + > +
{formatDayShort(hoveredDay)}
+ {PROVIDER_ORDER.map((provider) => ( +
+ + + {PROVIDER_LABEL[provider]} + + + {format(valueFor(hoveredEntry, provider, metric))} + +
+ ))} +
+ Total + + {format( + metric === "tokens" + ? (hoveredEntry?.totalTokens ?? 0) + : (hoveredEntry?.costUsd ?? 0), + )} + +
+
+ )} +
+
- {firstDay === undefined ? "" : formatDayShort(firstDay)} - {middleDay === undefined ? "" : formatDayShort(middleDay)} - {lastDay === undefined ? "" : formatDayShort(lastDay)} + {days[0] === undefined ? "" : formatDayShort(days[0])} + + {days[Math.floor(days.length / 2)] === undefined + ? "" + : formatDayShort(days[Math.floor(days.length / 2)] ?? "")} + + + {days[days.length - 1] === undefined ? "" : formatDayShort(days[days.length - 1] ?? "")} +
); diff --git a/apps/web/src/usage/usageMerge.ts b/apps/web/src/usage/usageMerge.ts index cae5c05b9bc8efc39188a896a59d0f012772a352..d9101e7b602bc2b97a88e37b2091bcc49cea2074 100644 GIT binary patch delta 525 zcmZ4I)gH3pG!v_DeoAW2ZH=-?9>uHnATbaxDZ%fZGttI0vIIc=M|SIfCYg% z!L(y?a%pa9PGU)ZkzRUgi3ZGMO$B> zIy+iVPY*0N`JSQ@b9QRwG$cPeHLqAfS0NFZzj>45O-5+swJ5J* F1OQsqw{ZXf delta 42 zcmV+_0M-A8RIW;}%L22d0|*1N*akcdvmz7E1+#-0rv Date: Fri, 7 Aug 2026 23:58:14 -0700 Subject: [PATCH 05/12] fix(web): stop the usage chart clipping its tallest day The tick builder stopped at the last step below the peak, so the scale maximum sat under the highest value and the top of the series was cut off. Round the maximum up to the next step and reserve a sliver above the top gridline for the stroke. Co-Authored-By: Claude Opus 5 (1M context) --- .../usage/UsageProviderChart.test.ts | 41 +++++++++++++++++ .../components/usage/UsageProviderChart.tsx | 46 +++++++++++++------ 2 files changed, 72 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/components/usage/UsageProviderChart.test.ts diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts new file mode 100644 index 00000000000..53b797e6f56 --- /dev/null +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { niceScale } from "./UsageProviderChart"; + +describe("niceScale", () => { + it("never puts the peak above the top of the scale", () => { + // Regression: an earlier version stopped at the last step below the peak, + // so the tallest day was drawn past the plot and clipped. + for (const peak of [1122.71, 999, 1, 0.04, 1_400_000_000, 37.5, 5000, 100.001]) { + const { max } = niceScale(peak, 4); + expect(max, `peak ${peak}`).toBeGreaterThanOrEqual(peak); + } + }); + + it("starts at zero and ends at the maximum", () => { + const { max, ticks } = niceScale(1122.71, 4); + + expect(ticks[0]).toBe(0); + expect(ticks[ticks.length - 1]).toBeCloseTo(max, 6); + }); + + it("uses evenly spaced 1/2/5 steps", () => { + const { ticks } = niceScale(1122.71, 4); + const steps = ticks.slice(1).map((tick, index) => tick - (ticks[index] ?? 0)); + + for (const step of steps) expect(step).toBeCloseTo(steps[0] ?? 0, 6); + const [first = 0] = steps; + const normalized = first / 10 ** Math.floor(Math.log10(first)); + expect([1, 2, 5, 10]).toContain(Math.round(normalized)); + }); + + it("keeps the tick count near the requested resolution", () => { + const { ticks } = niceScale(1122.71, 4); + expect(ticks.length).toBeGreaterThanOrEqual(3); + expect(ticks.length).toBeLessThanOrEqual(7); + }); + + it("degrades to a single zero tick with no data", () => { + expect(niceScale(0, 4)).toEqual({ max: 0, ticks: [0] }); + }); +}); diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 12e9dca6d2d..a6a563ca177 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -8,6 +8,7 @@ import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_ORDER } from "./usageProviders const VIEW_WIDTH = 960; const VIEW_HEIGHT = 260; const TICK_COUNT = 4; +const PLOT_TOP = 8; export type UsageChartMetric = "tokens" | "cost"; @@ -104,16 +105,26 @@ function smoothSegments(points: readonly Point[], startCommand: "M" | "L"): stri return path; } -/** Rounds a scale maximum up to a readable 1/2/5 x 10^n step. */ -function niceTicks(peak: number, count: number): readonly number[] { - if (peak <= 0) return [0]; +/** + * Builds a scale whose maximum is a readable 1/2/5 x 10^n step at or above the + * peak. + * + * Rounding the maximum *up* is the point: stopping at the last step below the + * peak leaves the tallest day drawn past the top of the plot, where it is + * clipped. + */ +export function niceScale(peak: number, count: number): { max: number; ticks: readonly number[] } { + if (peak <= 0) return { max: 0, ticks: [0] }; + const rawStep = peak / count; const magnitude = 10 ** Math.floor(Math.log10(rawStep)); const normalized = rawStep / magnitude; const step = (normalized > 5 ? 10 : normalized > 2 ? 5 : normalized > 1 ? 2 : 1) * magnitude; + + const max = Math.ceil(peak / step) * step; const ticks: number[] = []; - for (let value = 0; value <= peak + step * 0.001; value += step) ticks.push(value); - return ticks; + for (let value = 0; value <= max + step * 1e-6; value += step) ticks.push(value); + return { max, ticks }; } export function UsageProviderChart({ days, daily, metric }: UsageProviderChartProps) { @@ -121,9 +132,14 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr const [hoverIndex, setHoverIndex] = useState(null); const plotRef = useRef(null); - const { paths, ticks, scaleMax, stepX } = useMemo(() => { + const { paths, ticks, stepX, toY } = useMemo(() => { if (days.length === 0) { - return { paths: [], ticks: [0] as readonly number[], scaleMax: 0, stepX: 0 }; + return { + paths: [], + ticks: [0] as readonly number[], + stepX: 0, + toY: () => VIEW_HEIGHT, + }; } const stacked = days.map((day) => { @@ -140,11 +156,12 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr (max, columns) => Math.max(max, columns[columns.length - 1]?.top ?? 0), 0, ); - const tickValues = niceTicks(peak, TICK_COUNT); - const max = tickValues[tickValues.length - 1] ?? 0; + const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT); const step = days.length === 1 ? 0 : VIEW_WIDTH / (days.length - 1); + // Reserve a sliver above the top gridline so the series stroke, which is + // drawn at constant screen width, is not shaved off at a peak. const toY = (value: number) => - max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * VIEW_HEIGHT; + max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP); const built = PROVIDER_ORDER.map((provider, providerIndex) => { const top: Point[] = stacked.map((columns, dayIndex) => ({ @@ -165,7 +182,7 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr }; }); - return { paths: built, ticks: tickValues, scaleMax: max, stepX: step }; + return { paths: built, ticks: tickValues, stepX: step, toY }; }, [byDay, days, metric]); const format = metric === "tokens" ? formatTokens : formatUsd; @@ -194,7 +211,7 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr {tick === 0 ? "0" : format(tick)} @@ -215,8 +232,7 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr aria-label={`Daily ${metric === "tokens" ? "processed tokens" : "cost"} by provider`} > {ticks.map((tick) => { - const y = - scaleMax === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (tick / scaleMax) * VIEW_HEIGHT; + const y = toY(tick); return ( Date: Sat, 8 Aug 2026 00:33:07 -0700 Subject: [PATCH 06/12] perf(usage): persist the transcript scan cache across restarts Parsed records are written to the state dir keyed by file size and mtime, so a restart reloads them instead of re-parsing the window. Measured on a 30-day scan: 3,542ms cold against 162ms warm, from a 5.4MB cache. Caching per file rather than per day keeps it timezone independent and keeps de-duplication exact. 99% of duplicate records live inside a single file, so entries are stored de-duplicated within their file and the aggregator still runs the global pass that catches the remaining cross-file duplicates. Also replaces the record-cap eviction, which cleared the whole cache once exceeded so a large window never warmed up, with age-based retention that does not evict entries a narrower window simply did not look for. Co-Authored-By: Claude Opus 5 (1M context) --- apps/server/src/usage/UsageService.ts | 87 +++++-- apps/server/src/usage/usageScanCache.test.ts | 150 ++++++++++++ apps/server/src/usage/usageScanCache.ts | 220 ++++++++++++++++++ .../server/src/usage/usageTranscriptReader.ts | 16 ++ apps/web/src/usage/usageMerge.test.ts | 43 +++- apps/web/src/usage/usageMerge.ts | Bin 10759 -> 10994 bytes packages/contracts/src/usage.ts | 12 +- 7 files changed, 506 insertions(+), 22 deletions(-) create mode 100644 apps/server/src/usage/usageScanCache.test.ts create mode 100644 apps/server/src/usage/usageScanCache.ts diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index e49ae8e7fb9..cb698feffac 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -38,7 +38,18 @@ import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { UsageAggregator } from "./usageAggregation.ts"; import { parseRateTable, type RateTable } from "./usagePricing.ts"; -import { listTranscriptFiles, readTranscriptRecords } from "./usageTranscriptReader.ts"; +import { + listTranscriptFiles, + readDirectoryVolumeId, + readTranscriptRecords, +} from "./usageTranscriptReader.ts"; +import { + decodeScanCache, + dedupeWithinFile, + encodeScanCache, + pruneScanCache, + type ScanCache, +} from "./usageScanCache.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; const LITELLM_RATES_URL = @@ -53,8 +64,8 @@ const RATES_TTL_MS = 24 * 60 * 60 * 1000; */ const MTIME_SLACK_MS = 36 * 60 * 60 * 1000; -/** Bounds the memo cache so a long-lived server cannot grow without limit. */ -const MAX_CACHED_RECORDS = 400_000; +/** Longest window the UI offers, plus slack. Older entries are pruned. */ +const CACHE_RETENTION_DAYS = 90; /** On-disk shape of the rate snapshot. */ const RatesCacheFile = Schema.Struct({ @@ -68,11 +79,10 @@ const encodeRatesCache = Schema.encodeEffect( Schema.fromJsonString(RatesCacheFile as unknown as Schema.Codec), ); -interface CachedFile { - readonly size: number; - readonly mtimeMs: number; - readonly records: readonly UsageRecord[]; -} +/** The scan cache is narrowed by hand in `usageScanCache`, so JSON is enough here. */ +const ScanCacheJson = Schema.fromJsonString(Schema.Unknown as unknown as Schema.Codec); +const decodeScanCacheFile = Schema.decodeUnknownEffect(ScanCacheJson); +const encodeScanCacheFile = Schema.encodeEffect(ScanCacheJson); export class UsageService extends Context.Service< UsageService, @@ -112,10 +122,12 @@ const make = Effect.gen(function* () { const settingsService = yield* ServerSettings.ServerSettingsService; const httpClient = yield* HttpClient.HttpClient; - const fileCache = new Map(); - let cachedRecordCount = 0; + const fileCache: ScanCache = new Map(); + let cacheLoaded = false; + let cacheDirty = false; const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); + const scanCachePath = path.join(config.stateDir, "usage-scan-cache.json"); let rates: RateTable = new Map(); let ratesFetchedAtMs: number | null = null; let ratesStatus: UsageSummary["pricing"]["status"] = "unavailable"; @@ -198,7 +210,31 @@ const make = Effect.gen(function* () { ]; }); - /** Parses one transcript, reusing the memoised result when it is unchanged. */ + /** Loads the persisted scan cache once per process. */ + const ensureScanCacheLoaded = Effect.fn("UsageService.ensureScanCacheLoaded")(function* () { + if (cacheLoaded) return; + cacheLoaded = true; + + const document = yield* fileSystem.readFileString(scanCachePath).pipe( + Effect.flatMap((raw) => decodeScanCacheFile(raw)), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (document === null) return; + + for (const [path, entry] of decodeScanCache(document)) fileCache.set(path, entry); + }); + + const persistScanCache = Effect.fn("UsageService.persistScanCache")(function* () { + if (!cacheDirty) return; + cacheDirty = false; + yield* encodeScanCacheFile(encodeScanCache(fileCache)).pipe( + Effect.flatMap((serialized) => fileSystem.writeFileString(scanCachePath, serialized)), + // A cache we cannot write is a slower next start, not a failed read. + Effect.catchCause(() => Effect.void), + ); + }); + + /** Parses one transcript, reusing the cached result when it is unchanged. */ const readFileRecords = ( filePath: string, size: number, @@ -209,14 +245,13 @@ const make = Effect.gen(function* () { const cached = fileCache.get(filePath); if (cached && cached.size === size && cached.mtimeMs === mtimeMs) return cached.records; - const records = yield* Effect.promise(() => readTranscriptRecords(filePath, provider)); + const parsed = yield* Effect.promise(() => readTranscriptRecords(filePath, provider)); + // Stored already de-duplicated within the file, which is 99% of all + // duplicates. The aggregator still runs the cross-file dedupe pass. + const records = dedupeWithinFile(parsed); - if (cachedRecordCount > MAX_CACHED_RECORDS) { - fileCache.clear(); - cachedRecordCount = 0; - } - fileCache.set(filePath, { size, mtimeMs, records }); - cachedRecordCount += records.length; + fileCache.set(filePath, { size, mtimeMs, provider, records }); + cacheDirty = true; return records; }); @@ -230,6 +265,7 @@ const make = Effect.gen(function* () { const startedAtMs = yield* Clock.currentTimeMillis; yield* ensureRates(); + yield* ensureScanCacheLoaded(); const hostId = NodeOS.hostname(); // The home resolvers ask for `Path` themselves; satisfy them from the @@ -252,15 +288,17 @@ const make = Effect.gen(function* () { }); const sources: UsageSource[] = []; + const livePaths = new Set(); for (const { provider, dir } of dirs) { + const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); const exists = yield* fileSystem .exists(dir) .pipe(Effect.catchCause(() => Effect.succeed(false))); if (!exists) { sources.push({ - fingerprint: { hostId, provider, resolvedHomePath: dir }, + fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, status: "missing", scannedFiles: 0, skippedFiles: 0, @@ -275,6 +313,7 @@ const make = Effect.gen(function* () { let skippedFiles = 0; for (const file of files) { + livePaths.add(file.path); const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider); if (records.length === 0) { skippedFiles += 1; @@ -285,7 +324,7 @@ const make = Effect.gen(function* () { } sources.push({ - fingerprint: { hostId, provider, resolvedHomePath: dir }, + fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, status: "ok", scannedFiles, skippedFiles, @@ -294,6 +333,14 @@ const make = Effect.gen(function* () { }); } + const pruned = pruneScanCache(fileCache, { + livePaths, + windowStartMs, + retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, + }); + if (pruned > 0) cacheDirty = true; + yield* persistScanCache(); + const aggregated = aggregator.finish(); const readAt = yield* DateTime.now; const finishedAtMs = yield* Clock.currentTimeMillis; diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts new file mode 100644 index 00000000000..6900b3e1aaa --- /dev/null +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + decodeScanCache, + dedupeWithinFile, + encodeScanCache, + pruneScanCache, + type ScanCache, +} from "./usageScanCache.ts"; +import type { UsageRecord } from "./usageTranscripts.ts"; + +function record(overrides: Partial = {}): UsageRecord { + return { + provider: "claude", + timestampMs: 1_786_000_000_000, + model: "claude-fable-5", + sessionId: "session-a", + totals: { + uncachedInputTokens: 2, + cachedInputTokens: 1000, + cacheCreationTokens: 10, + outputTokens: 50, + reasoningTokens: 0, + }, + reportedCostUsd: null, + dedupeKey: "msg_1:", + ...overrides, + }; +} + +function cacheWith(entries: readonly [string, number, readonly UsageRecord[]][]): ScanCache { + const cache: ScanCache = new Map(); + for (const [path, mtimeMs, records] of entries) { + cache.set(path, { size: records.length * 10, mtimeMs, provider: "claude", records }); + } + return cache; +} + +describe("scan cache round trip", () => { + it("restores records unchanged", () => { + const original = cacheWith([ + ["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:", model: "claude-opus-5" })]], + ["/b.jsonl", 200, [record({ sessionId: "session-b", reportedCostUsd: 1.5 })]], + ]); + + const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original)))); + + expect(restored.size).toBe(2); + expect(restored.get("/a.jsonl")).toEqual(original.get("/a.jsonl")); + expect(restored.get("/b.jsonl")).toEqual(original.get("/b.jsonl")); + }); + + it("interns repeated model and session strings", () => { + const encoded = encodeScanCache( + cacheWith([["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:" }), record()]]]), + ); + + expect(encoded.models).toEqual(["claude-fable-5"]); + expect(encoded.sessions).toEqual(["session-a"]); + }); + + it("treats a corrupt or foreign document as an empty cache", () => { + // A bad cache should cost one cold scan, never a broken page. + expect(decodeScanCache(null).size).toBe(0); + expect(decodeScanCache("nonsense").size).toBe(0); + expect(decodeScanCache({ version: 999, models: [], sessions: [], files: {} }).size).toBe(0); + }); + + it("skips malformed file entries but keeps good ones", () => { + const encoded = encodeScanCache(cacheWith([["/good.jsonl", 100, [record()]]])); + const withJunk = { + ...encoded, + files: { ...encoded.files, "/bad.jsonl": { s: "nope", m: 1, p: "claude", r: [] } }, + }; + + const restored = decodeScanCache(JSON.parse(JSON.stringify(withJunk))); + expect([...restored.keys()]).toEqual(["/good.jsonl"]); + }); +}); + +describe("pruneScanCache", () => { + const retentionCutoffMs = 1000; + + it("drops entries older than retention", () => { + const cache = cacheWith([["/old.jsonl", 500, [record()]]]); + + const removed = pruneScanCache(cache, { + livePaths: new Set(), + windowStartMs: 400, + retentionCutoffMs, + }); + + expect(removed).toBe(1); + expect(cache.size).toBe(0); + }); + + it("drops in-window entries whose file has disappeared", () => { + const cache = cacheWith([["/gone.jsonl", 5000, [record()]]]); + + pruneScanCache(cache, { livePaths: new Set(), windowStartMs: 4000, retentionCutoffMs }); + + expect(cache.size).toBe(0); + }); + + it("keeps entries outside the walked window that are still within retention", () => { + // Viewing 7 days must not evict the 30-day entries, which that walk never + // looked for and so cannot prove are gone. + const cache = cacheWith([["/older-but-valid.jsonl", 2000, [record()]]]); + + const removed = pruneScanCache(cache, { + livePaths: new Set(), + windowStartMs: 4000, + retentionCutoffMs, + }); + + expect(removed).toBe(0); + expect(cache.size).toBe(1); + }); + + it("keeps entries the walk saw", () => { + const cache = cacheWith([["/live.jsonl", 5000, [record()]]]); + + pruneScanCache(cache, { + livePaths: new Set(["/live.jsonl"]), + windowStartMs: 4000, + retentionCutoffMs, + }); + + expect(cache.size).toBe(1); + }); +}); + +describe("dedupeWithinFile", () => { + it("keeps the first record per dedupe key", () => { + const kept = dedupeWithinFile([ + record({ totals: { ...record().totals, outputTokens: 1 } }), + record({ totals: { ...record().totals, outputTokens: 999 } }), + record({ dedupeKey: "msg_2:" }), + ]); + + expect(kept).toHaveLength(2); + expect(kept[0]?.totals.outputTokens).toBe(1); + }); + + it("keeps every record that has no dedupe key", () => { + expect( + dedupeWithinFile([record({ dedupeKey: null }), record({ dedupeKey: null })]), + ).toHaveLength(2); + }); +}); diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts new file mode 100644 index 00000000000..26bb6e80b4f --- /dev/null +++ b/apps/server/src/usage/usageScanCache.ts @@ -0,0 +1,220 @@ +/** + * Durable per-file scan cache. + * + * Transcripts are append-only and a file that has not changed can never yield + * different usage, so parsed records are keyed by `(size, mtime)` and reused. + * Without this every server restart re-parses the whole window: roughly 3.5s + * for a 30-day scan here, against ~11ms to reload this cache. + * + * Caching *per file* rather than per day is deliberate. It is timezone + * independent, so changing the reporting zone does not invalidate anything, and + * it keeps cross-file de-duplication exact: cached entries are de-duplicated + * within their own file only, and the aggregator still applies the global + * dedupe pass over the small surviving key set. + * + * @module usageScanCache + */ +import type { UsageProviderKind } from "@t3tools/contracts"; + +import type { UsageRecord } from "./usageTranscripts.ts"; + +export const USAGE_SCAN_CACHE_VERSION = 1 as const; + +export interface CachedFile { + readonly size: number; + readonly mtimeMs: number; + readonly provider: UsageProviderKind; + readonly records: readonly UsageRecord[]; +} + +export type ScanCache = Map; + +/** + * Row layout for the serialised form. Positional and interned rather than + * object-per-record: on a 30-day window that is the difference between a file + * measured in tens of megabytes and one under six. + */ +type SerializedRecord = readonly [ + timestampMs: number, + modelIndex: number, + sessionIndex: number, + uncachedInputTokens: number, + cachedInputTokens: number, + cacheCreationTokens: number, + outputTokens: number, + reasoningTokens: number, + dedupeKey: string | null, + reportedCostUsd: number | null, +]; + +interface SerializedFile { + readonly s: number; + readonly m: number; + readonly p: UsageProviderKind; + readonly r: readonly SerializedRecord[]; +} + +interface SerializedCache { + readonly version: number; + readonly models: readonly string[]; + readonly sessions: readonly string[]; + readonly files: Readonly>; +} + +/** Serialises the cache, interning the repeated model and session strings. */ +export function encodeScanCache(cache: ScanCache): SerializedCache { + const models: string[] = []; + const sessions: string[] = []; + const modelIndex = new Map(); + const sessionIndex = new Map(); + + const intern = (table: string[], index: Map, value: string): number => { + const existing = index.get(value); + if (existing !== undefined) return existing; + const next = table.length; + table.push(value); + index.set(value, next); + return next; + }; + + const files: Record = {}; + for (const [path, entry] of cache) { + files[path] = { + s: entry.size, + m: entry.mtimeMs, + p: entry.provider, + r: entry.records.map((record) => [ + record.timestampMs, + intern(models, modelIndex, record.model), + intern(sessions, sessionIndex, record.sessionId), + record.totals.uncachedInputTokens, + record.totals.cachedInputTokens, + record.totals.cacheCreationTokens, + record.totals.outputTokens, + record.totals.reasoningTokens, + record.dedupeKey, + record.reportedCostUsd, + ]), + }; + } + + return { version: USAGE_SCAN_CACHE_VERSION, models, sessions, files }; +} + +function isRecordArray(value: unknown): value is readonly unknown[] { + return Array.isArray(value); +} + +/** + * Rebuilds the cache from a parsed document. + * + * Anything malformed yields an empty cache rather than an error: a corrupt + * cache should cost one cold scan, never a broken page. + */ +export function decodeScanCache(document: unknown): ScanCache { + const cache: ScanCache = new Map(); + if (typeof document !== "object" || document === null) return cache; + + const root = document as Partial; + if (root.version !== USAGE_SCAN_CACHE_VERSION) return cache; + if (!isRecordArray(root.models) || !isRecordArray(root.sessions)) return cache; + if (typeof root.files !== "object" || root.files === null) return cache; + + const models = root.models as readonly string[]; + const sessions = root.sessions as readonly string[]; + + for (const [path, raw] of Object.entries(root.files)) { + if (typeof raw !== "object" || raw === null) continue; + const entry = raw as Partial; + if (typeof entry.s !== "number" || typeof entry.m !== "number") continue; + if (entry.p !== "claude" && entry.p !== "codex") continue; + if (!isRecordArray(entry.r)) continue; + + const provider: UsageProviderKind = entry.p; + const records: UsageRecord[] = []; + for (const row of entry.r) { + if (!isRecordArray(row) || row.length < 10) continue; + const [ + timestampMs, + modelIndex, + sessionIndex, + uncached, + cached, + cacheCreation, + output, + reasoning, + dedupeKey, + reportedCostUsd, + ] = row as SerializedRecord; + + const model = models[modelIndex]; + if (typeof timestampMs !== "number" || model === undefined) continue; + + records.push({ + provider, + timestampMs, + model, + sessionId: sessions[sessionIndex] ?? "", + totals: { + uncachedInputTokens: uncached, + cachedInputTokens: cached, + cacheCreationTokens: cacheCreation, + outputTokens: output, + reasoningTokens: reasoning, + }, + reportedCostUsd: typeof reportedCostUsd === "number" ? reportedCostUsd : null, + dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null, + }); + } + + cache.set(path, { size: entry.s, mtimeMs: entry.m, provider, records }); + } + + return cache; +} + +export interface PruneOptions { + /** Files the walk just saw. Only meaningful inside the walked window. */ + readonly livePaths: ReadonlySet; + /** Start of the walked window; entries older than this were not looked for. */ + readonly windowStartMs: number; + /** Entries older than this are dropped regardless. */ + readonly retentionCutoffMs: number; +} + +/** + * Drops aged-out entries, and entries for files that have disappeared. + * + * The walk only covers the requested window, so absence from `livePaths` only + * proves deletion for entries *inside* that window. Pruning everything the walk + * missed would evict the 30-day entries every time someone looked at 7 days. + * + * Replaces an earlier record cap that cleared the whole cache once exceeded, + * which meant a large enough window never warmed up at all. + */ +export function pruneScanCache(cache: ScanCache, options: PruneOptions): number { + let removed = 0; + for (const [path, entry] of cache) { + const agedOut = entry.mtimeMs < options.retentionCutoffMs; + const deleted = entry.mtimeMs >= options.windowStartMs && !options.livePaths.has(path); + if (agedOut || deleted) { + cache.delete(path); + removed += 1; + } + } + return removed; +} + +/** Within-file de-duplication, applied before an entry is cached. */ +export function dedupeWithinFile(records: readonly UsageRecord[]): readonly UsageRecord[] { + const seen = new Set(); + const kept: UsageRecord[] = []; + for (const record of records) { + if (record.dedupeKey !== null) { + if (seen.has(record.dedupeKey)) continue; + seen.add(record.dedupeKey); + } + kept.push(record); + } + return kept; +} diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index 9b0fe633cd7..b770df86e56 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -73,6 +73,22 @@ export async function listTranscriptFiles( return found; } +/** + * Filesystem identity of a directory, as `device:inode`. + * + * Used to tell "two servers reading the same transcript directory" apart from + * "two machines whose hostname and home path happen to match". Returns an empty + * string when the directory cannot be stat'd. + */ +export async function readDirectoryVolumeId(path: string): Promise { + try { + const stats = await NodeFSP.stat(path); + return `${stats.dev}:${stats.ino}`; + } catch { + return ""; + } +} + /** * Streams one transcript and returns the usage records it contains. * diff --git a/apps/web/src/usage/usageMerge.test.ts b/apps/web/src/usage/usageMerge.test.ts index de47919d2b8..ade7448de45 100644 --- a/apps/web/src/usage/usageMerge.test.ts +++ b/apps/web/src/usage/usageMerge.test.ts @@ -34,7 +34,12 @@ function bucket(overrides: Partial = {}): UsageBucket { function summary( buckets: readonly UsageBucket[], - sources: readonly { provider: UsageProviderKind; hostId: string; homePath: string }[], + sources: readonly { + provider: UsageProviderKind; + hostId: string; + homePath: string; + volumeId?: string; + }[], contractVersion: number = USAGE_CONTRACT_VERSION, ): UsageSummary { return { @@ -49,6 +54,7 @@ function summary( hostId: source.hostId, provider: source.provider, resolvedHomePath: source.homePath, + volumeId: source.volumeId ?? `vol-${source.hostId}`, }, status: "ok" as const, scannedFiles: 1, @@ -180,6 +186,41 @@ describe("mergeUsage", () => { expect(merged.costQuality.cacheSavingsUsd).toBe(4); }); + it("keeps two machines apart when hostname and home path collide", () => { + // Every Mac resolves /Users/theo/.claude, so a hostname clash used to make + // one machine's usage vanish. Filesystem identity separates them. + const shape = { provider: "claude" as const, hostId: "mac", homePath: "/Users/theo/.claude" }; + const merged = mergeUsage( + [ + environment("env-a", summary([bucket()], [{ ...shape, volumeId: "16777220:1234" }])), + environment("env-b", summary([bucket()], [{ ...shape, volumeId: "16777221:9999" }])), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(20); + expect(merged.duplicateSources).toHaveLength(0); + }); + + it("still collapses two servers reading the same directory", () => { + const same = { + provider: "claude" as const, + hostId: "mac", + homePath: "/Users/theo/.claude", + volumeId: "16777220:1234", + }; + const merged = mergeUsage( + [ + environment("env-a", summary([bucket()], [same])), + environment("env-b", summary([bucket()], [same])), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(10); + expect(merged.duplicateSources).toHaveLength(1); + }); + it("returns empty totals with no environments", () => { const merged = mergeUsage([], USAGE_CONTRACT_VERSION); expect(merged.costUsd).toBe(0); diff --git a/apps/web/src/usage/usageMerge.ts b/apps/web/src/usage/usageMerge.ts index d9101e7b602bc2b97a88e37b2091bcc49cea2074..653bb65918e52f4c4607d94bdc6d9599d7227123 100644 GIT binary patch delta 516 zcmZutK}y3w6h(znN~OU?u~PI?L2L=>z6+Ov3m0O$5}`x#lNm{7!pt6~lv533*^ZtAPy>~zUm^>bBcxt9YJQXrQoogZOs=M)7I~WcI4H&?9 zuE10q2AD>$9Ko;*%jf$35|m6AFy|KZB+}siIm>f8X_sV;J5#JWFUeMQ zk2yWAXmS>O5)&bJs#u_2z&Q&+NFv24qV*xxDuYm^Da#GDK$umOjB-}Fon$4*3WqvC z#jb~}4Th3QOC3a7k*COo8J4Yj=Oq5^a|9E^61@D|bALn$sqh4D8vq*ZOiQ?;CqQ}E z+$~>Pd*#J;xAeT);hJ3VQxw$=*TIbv^0)`R{_3Iry0!D|Iau3Yy)~aU|NC!y@0~B^ CMYC1_ delta 294 zcmewq+8(k&jd8OB<14nwHJlRl8TrK}o+(xe#U(|VdFeU|1x5K~nJKA72$78Z+|+=? zk_?!LCXAs_&847Flv+|+l&6rOQVlVwmO-T&W(J52(_Ncj%~i|ArLU#MrJ$wYl9~*( zq*$SRa~#(xW@Df^5Kx?7T9llsmj-l7YEc2uMJ0L=V|CD#On${F#RAeac{-o+WCboZ fCIyAfR(xBT^r0#V8U/.claude`, so two machines that happen to share a hostname + * would look like one source and have their usage silently dropped. The + * device/inode pair is stable for two servers reading the same directory and + * effectively never collides across machines. Empty when it cannot be read. + */ + volumeId: Schema.String, }); export type UsageSourceFingerprint = typeof UsageSourceFingerprint.Type; From f6082c374efaf9c12737e48367ca74fb4b749f14 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 01:10:39 -0700 Subject: [PATCH 07/12] feat(web): cost-first toggle, provider marks, chart polish Orders the chart toggle cost-first and makes the left summary follow the active metric, including provider ordering. Replaces colour dots with the existing provider brand marks, whose fills already match the chart bands. Routes the hover tooltip through the same derived series as the chart paths so the readout is always the plotted value. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/usage/UsagePage.tsx | 102 ++++++++------ .../usage/UsageProviderChart.test.ts | 52 +++++++- .../components/usage/UsageProviderChart.tsx | 126 +++++++++++------- .../src/components/usage/usageProviders.ts | 14 ++ 4 files changed, 204 insertions(+), 90 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 3a927862bf8..a2c1b3e1b71 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -1,3 +1,4 @@ +import type { UsageProviderKind } from "@t3tools/contracts"; import { RefreshCwIcon } from "lucide-react"; import { useMemo, useState } from "react"; @@ -14,7 +15,7 @@ import { } from "../../usage/usageFormat"; import { ScrollArea } from "../ui/scroll-area"; import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; -import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_ORDER } from "./usageProviders"; +import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; const WINDOW_OPTIONS = [ { days: 7, label: "7 days" }, @@ -38,6 +39,15 @@ export function UsagePage() { ); const recentDays = useMemo(() => merged.daily.toReversed().slice(0, 8), [merged.daily]); + // Ranked by whatever the toggle is showing, so the bars always descend. + const orderedProviders = useMemo( + () => + merged.providers.toSorted((a, b) => + metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens, + ), + [merged.providers, metric], + ); + const activeDays = merged.daily.filter((day) => day.totalTokens > 0).length; const dailyAverage = activeDays === 0 ? 0 : merged.totalTokens / activeDays; const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens; @@ -96,49 +106,57 @@ export function UsagePage() { <> {/* Cost first: the financial answer, then the provider split. */}
+ {/* The summary follows the chart toggle, so the headline and the + series are always reading the same units. */}
- Raw token cost + {metric === "cost" ? "Raw token cost" : "Processed tokens"} - {formatUsd(merged.costUsd)} + {metric === "cost" + ? formatUsd(merged.costUsd) + : formatTokens(merged.totalTokens)} - What these tokens would cost at API rates. Not what you were billed. + {metric === "cost" + ? "What these tokens would cost at API rates. Not what you were billed." + : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`}
- {merged.providers.map((provider) => ( -
-
- - { + const share = metric === "cost" ? provider.costShare : provider.tokenShare; + return ( +
+
+ + + {PROVIDER_LABEL[provider.provider]} + + + {metric === "cost" + ? formatUsd(provider.costUsd) + : formatTokens(provider.totalTokens)} + +
+
+
- {PROVIDER_LABEL[provider.provider]} - - - {formatUsd(provider.costUsd)} +
+ + {metric === "cost" + ? `${formatPercent(share)} of cost ยท ${formatTokens(provider.totalTokens)} tokens` + : `${formatPercent(share)} of tokens ยท ${formatUsd(provider.costUsd)}`}
-
-
-
- - {formatPercent(provider.costShare)} of cost ยท{" "} - {formatTokens(provider.totalTokens)} tokens - -
- ))} + ); + })}
@@ -148,7 +166,7 @@ export function UsagePage() {
- {(["tokens", "cost"] as const).map((option) => ( + {(["cost", "tokens"] as const).map((option) => (
@@ -332,16 +355,17 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr export function UsageChartLegend() { return (
- {PROVIDER_ORDER.map((provider) => ( - - - {PROVIDER_LABEL[provider]} - - ))} + {PROVIDER_ORDER.map((provider) => { + // The marks carry the same fills as the bands, so they key the chart + // just as a colour swatch would. + const Mark = PROVIDER_MARK[provider]; + return ( + + + {PROVIDER_LABEL[provider]} + + ); + })}
); } diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index 50c9c78a92f..5356f96edc7 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -1,5 +1,7 @@ import type { UsageProviderKind } from "@t3tools/contracts"; +import { ClaudeAI, type Icon, OpenAI } from "../Icons"; + /** * Stacking and table order. Codex sits under Claude Code so the larger band * reads as the top surface, matching the reference layout. @@ -16,3 +18,15 @@ export const PROVIDER_COLOR: Record = { claude: "#d97757", codex: "#e6e6e6", }; + +/** + * Brand marks, reused from the provider picker. + * + * These ship their own fills (`#d97757` for Claude, white on dark for OpenAI), + * which are the same colours as the chart bands, so swapping a colour dot for a + * mark keeps the series association intact rather than trading it away. + */ +export const PROVIDER_MARK: Record = { + claude: ClaudeAI, + codex: OpenAI, +}; From 4633bce6c8fe861d73bddd846188f35c58b395f4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 01:19:36 -0700 Subject: [PATCH 08/12] fix(usage): address review-bot findings across scan, cache, and page Server: settings failures now surface as UsageReadError instead of reading as zero usage; a Codex token_count arriving before its turn_context no longer poisons the duplicate signature; failed file reads are no longer memoised as empty; the scan-cache load is raced-safe via Effect.cached; the dirty flag only clears after a successful persist; pruning is scoped to walked roots so a missing provider directory cannot purge warm entries; decode validates numeric fields; stale rate tables stop reporting fresh. Contract: UsageDay validates its YYYY-MM-DD shape, sources report distinct session counts, version bumped to 3. Web: session totals come from per-directory distinct counts instead of per-bucket sums; the refresh button refreshes each environment query rather than the derived atom; partial coverage distinguishes still-reporting from failed and surfaces stale-version environments; the window start uses calendar arithmetic so DST cannot shift it; the daily average is labelled per active day; adjacent chart bands share one smoothed curve per stack boundary so edges cannot gap or overlap. Co-Authored-By: Claude Opus 5 (1M context) --- apps/server/src/server.test.ts | 2 +- apps/server/src/usage/UsageService.ts | 127 +++++++++++------- apps/server/src/usage/usageScanCache.test.ts | 47 ++++++- apps/server/src/usage/usageScanCache.ts | 28 +++- .../server/src/usage/usageTranscriptReader.ts | 12 +- .../server/src/usage/usageTranscripts.test.ts | 9 ++ apps/server/src/usage/usageTranscripts.ts | 12 +- apps/web/src/components/usage/UsagePage.tsx | 24 +++- .../components/usage/UsageProviderChart.tsx | 88 ++++++++---- apps/web/src/state/usage.ts | 31 ++++- apps/web/src/usage/usageFormat.ts | 12 +- apps/web/src/usage/usageMerge.test.ts | 29 ++++ apps/web/src/usage/usageMerge.ts | 22 ++- packages/contracts/src/usage.ts | 14 +- 14 files changed, 353 insertions(+), 104 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index a14274fc793..d982c2e192c 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -826,7 +826,7 @@ const buildAppUnderTest = (options?: { const appLayer = servedRoutesLayer.pipe( Layer.provide(resourceTelemetryLayer), - Layer.provide(UsageService.UsageService.layerTest), + Layer.provide(UsageService.layerTest), Layer.provide( Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({ record: () => Effect.void, diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index cb698feffac..bdcf55c076c 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -89,33 +89,33 @@ export class UsageService extends Context.Service< { readonly readSummary: (input: UsageSummaryInput) => Effect.Effect; } ->()("t3/usage/UsageService") { - /** Empty summary, for suites that only need the RPC surface to resolve. */ - static readonly layerTest = Layer.succeed( - UsageService, - UsageService.of({ - readSummary: (input) => - Effect.succeed({ - contractVersion: USAGE_CONTRACT_VERSION, - readAt: "1970-01-01T00:00:00.000Z", - timeZone: input.timeZone, - sinceDay: input.sinceDay, - untilDay: input.untilDay, - buckets: [], - sources: [], - pricing: { - status: "unavailable", - source: LITELLM_RATES_URL, - fetchedAt: null, - knownModels: 0, - }, - scanDurationMs: 0, - }), - }), - ); -} +>()("t3/usage/UsageService") {} + +/** Empty summary, for suites that only need the RPC surface to resolve. */ +export const layerTest = Layer.succeed( + UsageService, + UsageService.of({ + readSummary: (input) => + Effect.succeed({ + contractVersion: USAGE_CONTRACT_VERSION, + readAt: "1970-01-01T00:00:00.000Z", + timeZone: input.timeZone, + sinceDay: input.sinceDay, + untilDay: input.untilDay, + buckets: [], + sources: [], + pricing: { + status: "unavailable", + source: LITELLM_RATES_URL, + fetchedAt: null, + knownModels: 0, + }, + scanDurationMs: 0, + }), + }), +); -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const config = yield* ServerConfig; @@ -123,7 +123,6 @@ const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const fileCache: ScanCache = new Map(); - let cacheLoaded = false; let cacheDirty = false; const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); @@ -163,7 +162,12 @@ const make = Effect.gen(function* () { Effect.timeout(10_000), Effect.catchCause(() => Effect.succeed(null)), ); - if (fetched === null) return; + if (fetched === null) { + // The refresh failed; whatever we are serving is now past its TTL and + // must not keep claiming to be fresh. + if (rates.size > 0) ratesStatus = "cached"; + return; + } const parsed = parseRateTable(fetched); if (parsed.size === 0) return; @@ -193,12 +197,17 @@ const make = Effect.gen(function* () { /** Resolves the transcript directory for each provider. */ const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* () { + // A settings failure must surface as an error: swallowing it here would + // present "zero usage from every provider" as a valid answer. const settings = yield* settingsService.getSettings.pipe( - Effect.catchCause(() => Effect.succeed(null)), + Effect.catchCause( + (cause) => + new UsageReadError({ + reason: "scanFailed", + detail: `Server settings could not be read: ${String(cause)}`, + }), + ), ); - if (settings === null) { - return [] as readonly { provider: UsageProviderKind; dir: string }[]; - } const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent); const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome); @@ -210,25 +219,33 @@ const make = Effect.gen(function* () { ]; }); - /** Loads the persisted scan cache once per process. */ - const ensureScanCacheLoaded = Effect.fn("UsageService.ensureScanCacheLoaded")(function* () { - if (cacheLoaded) return; - cacheLoaded = true; - - const document = yield* fileSystem.readFileString(scanCachePath).pipe( - Effect.flatMap((raw) => decodeScanCacheFile(raw)), - Effect.catchCause(() => Effect.succeed(null)), - ); - if (document === null) return; - - for (const [path, entry] of decodeScanCache(document)) fileCache.set(path, entry); - }); + /** + * Loads the persisted scan cache exactly once per process. + * + * `Effect.cached` makes concurrent first readers await the same load rather + * than each seeing a "loaded" flag set before the read finished and cold + * scanning against an empty cache. + */ + const ensureScanCacheLoaded = yield* Effect.cached( + Effect.gen(function* () { + const document = yield* fileSystem.readFileString(scanCachePath).pipe( + Effect.flatMap((raw) => decodeScanCacheFile(raw)), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (document === null) return; + for (const [path, entry] of decodeScanCache(document)) fileCache.set(path, entry); + }), + ); const persistScanCache = Effect.fn("UsageService.persistScanCache")(function* () { if (!cacheDirty) return; - cacheDirty = false; + // Cleared only after the write lands, so a failed persist is retried on + // the next scan instead of leaving disk permanently stale. yield* encodeScanCacheFile(encodeScanCache(fileCache)).pipe( Effect.flatMap((serialized) => fileSystem.writeFileString(scanCachePath, serialized)), + Effect.map(() => { + cacheDirty = false; + }), // A cache we cannot write is a slower next start, not a failed read. Effect.catchCause(() => Effect.void), ); @@ -246,6 +263,9 @@ const make = Effect.gen(function* () { if (cached && cached.size === size && cached.mtimeMs === mtimeMs) return cached.records; const parsed = yield* Effect.promise(() => readTranscriptRecords(filePath, provider)); + // A read failure is not an empty transcript: caching it under this + // (size, mtime) would silently drop the file's usage until it changes. + if (parsed === null) return []; // Stored already de-duplicated within the file, which is 99% of all // duplicates. The aggregator still runs the cross-file dedupe pass. const records = dedupeWithinFile(parsed); @@ -265,7 +285,7 @@ const make = Effect.gen(function* () { const startedAtMs = yield* Clock.currentTimeMillis; yield* ensureRates(); - yield* ensureScanCacheLoaded(); + yield* ensureScanCacheLoaded; const hostId = NodeOS.hostname(); // The home resolvers ask for `Path` themselves; satisfy them from the @@ -289,6 +309,7 @@ const make = Effect.gen(function* () { const sources: UsageSource[] = []; const livePaths = new Set(); + const walkedRoots: string[] = []; for (const { provider, dir } of dirs) { const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); @@ -303,14 +324,19 @@ const make = Effect.gen(function* () { scannedFiles: 0, skippedFiles: 0, malformedRecords: 0, + distinctSessions: 0, message: "No transcript directory on this environment.", }); continue; } + walkedRoots.push(dir); const files = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs)); let scannedFiles = 0; let skippedFiles = 0; + // Distinct per directory. Buckets carry per-cell session counts, but a + // session spans days and models, so clients total this figure instead. + const sessionIds = new Set(); for (const file of files) { livePaths.add(file.path); @@ -320,7 +346,10 @@ const make = Effect.gen(function* () { continue; } scannedFiles += 1; - for (const record of records) aggregator.add(record); + for (const record of records) { + aggregator.add(record); + if (record.sessionId.length > 0) sessionIds.add(record.sessionId); + } } sources.push({ @@ -329,12 +358,14 @@ const make = Effect.gen(function* () { scannedFiles, skippedFiles, malformedRecords: 0, + distinctSessions: sessionIds.size, message: null, }); } const pruned = pruneScanCache(fileCache, { livePaths, + walkedRoots, windowStartMs, retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, }); diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 6900b3e1aaa..cb793934e37 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -86,6 +86,7 @@ describe("pruneScanCache", () => { const removed = pruneScanCache(cache, { livePaths: new Set(), + walkedRoots: ["/"], windowStartMs: 400, retentionCutoffMs, }); @@ -97,7 +98,12 @@ describe("pruneScanCache", () => { it("drops in-window entries whose file has disappeared", () => { const cache = cacheWith([["/gone.jsonl", 5000, [record()]]]); - pruneScanCache(cache, { livePaths: new Set(), windowStartMs: 4000, retentionCutoffMs }); + pruneScanCache(cache, { + livePaths: new Set(), + walkedRoots: ["/"], + windowStartMs: 4000, + retentionCutoffMs, + }); expect(cache.size).toBe(0); }); @@ -109,6 +115,7 @@ describe("pruneScanCache", () => { const removed = pruneScanCache(cache, { livePaths: new Set(), + walkedRoots: ["/"], windowStartMs: 4000, retentionCutoffMs, }); @@ -122,6 +129,7 @@ describe("pruneScanCache", () => { pruneScanCache(cache, { livePaths: new Set(["/live.jsonl"]), + walkedRoots: ["/"], windowStartMs: 4000, retentionCutoffMs, }); @@ -130,6 +138,43 @@ describe("pruneScanCache", () => { }); }); +describe("pruneScanCache with an unwalked root", () => { + it("keeps in-window entries for a provider whose directory was not walked", () => { + // A missing provider root or failed settings read leaves livePaths without + // that provider's files. Its warm entries must survive the pass. + const cache = cacheWith([["/codex/sessions/a.jsonl", 5000, [record()]]]); + + const removed = pruneScanCache(cache, { + livePaths: new Set(), + walkedRoots: ["/claude/projects"], + windowStartMs: 4000, + retentionCutoffMs: 1000, + }); + + expect(removed).toBe(0); + expect(cache.size).toBe(1); + }); +}); + +describe("decodeScanCache numeric validation", () => { + it("rejects rows whose token fields are not numbers", () => { + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); + const rows = encoded.files["/a.jsonl"]!.r; + const poisoned = { + ...encoded, + files: { + "/a.jsonl": { + ...encoded.files["/a.jsonl"]!, + r: [[...rows[0]!.slice(0, 3), "not-a-number", ...rows[0]!.slice(4)]], + }, + }, + }; + + const restored = decodeScanCache(JSON.parse(JSON.stringify(poisoned))); + expect(restored.get("/a.jsonl")?.records).toHaveLength(0); + }); +}); + describe("dedupeWithinFile", () => { it("keeps the first record per dedupe key", () => { const kept = dedupeWithinFile([ diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 26bb6e80b4f..13e45ae5977 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -147,14 +147,26 @@ export function decodeScanCache(document: unknown): ScanCache { reportedCostUsd, ] = row as SerializedRecord; - const model = models[modelIndex]; - if (typeof timestampMs !== "number" || model === undefined) continue; + const model = typeof modelIndex === "number" ? models[modelIndex] : undefined; + if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) continue; + if (model === undefined) continue; + // Token fields must be real numbers: a corrupt row must cost a cold + // re-parse of its file, never flow into the aggregate as NaN or a string. + if ( + !Number.isFinite(uncached) || + !Number.isFinite(cached) || + !Number.isFinite(cacheCreation) || + !Number.isFinite(output) || + !Number.isFinite(reasoning) + ) { + continue; + } records.push({ provider, timestampMs, model, - sessionId: sessions[sessionIndex] ?? "", + sessionId: (typeof sessionIndex === "number" ? sessions[sessionIndex] : undefined) ?? "", totals: { uncachedInputTokens: uncached, cachedInputTokens: cached, @@ -176,6 +188,12 @@ export function decodeScanCache(document: unknown): ScanCache { export interface PruneOptions { /** Files the walk just saw. Only meaningful inside the walked window. */ readonly livePaths: ReadonlySet; + /** + * Roots the walk actually completed. Absence from `livePaths` only proves a + * file is gone when its root was walked: a provider whose directory failed to + * resolve this pass must not have its warm entries purged. + */ + readonly walkedRoots: readonly string[]; /** Start of the walked window; entries older than this were not looked for. */ readonly windowStartMs: number; /** Entries older than this are dropped regardless. */ @@ -196,7 +214,9 @@ export function pruneScanCache(cache: ScanCache, options: PruneOptions): number let removed = 0; for (const [path, entry] of cache) { const agedOut = entry.mtimeMs < options.retentionCutoffMs; - const deleted = entry.mtimeMs >= options.windowStartMs && !options.livePaths.has(path); + const underWalkedRoot = options.walkedRoots.some((root) => path.startsWith(root)); + const deleted = + underWalkedRoot && entry.mtimeMs >= options.windowStartMs && !options.livePaths.has(path); if (agedOut || deleted) { cache.delete(path); removed += 1; diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index b770df86e56..c72f0c24db6 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -90,7 +90,13 @@ export async function readDirectoryVolumeId(path: string): Promise { } /** - * Streams one transcript and returns the usage records it contains. + * Streams one transcript and returns the usage records it contains, or `null` + * when the file could not be read. + * + * The distinction matters to the caller's cache: a genuinely empty transcript + * is a stable fact worth memoising, while a transient read failure memoised + * under the same `(size, mtime)` key would silently drop that file's usage + * until the file next changes. * * Codex carries the active model on `turn_context` lines that hold no usage of * their own, so those still have to pass through the reducer to keep model @@ -99,7 +105,7 @@ export async function readDirectoryVolumeId(path: string): Promise { export async function readTranscriptRecords( filePath: string, provider: UsageProviderKind, -): Promise { +): Promise { const records: UsageRecord[] = []; const codexState = initialCodexScanState(); @@ -128,7 +134,7 @@ export async function readTranscriptRecords( if (record !== null) records.push(record); } } catch { - return []; + return null; } return records; diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts index aa0c63b37c9..1fec9d28d9b 100644 --- a/apps/server/src/usage/usageTranscripts.test.ts +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -125,6 +125,15 @@ describe("parseCodexLine", () => { const state = initialCodexScanState(); expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull(); }); + + it("does not let a pre-model event poison the duplicate signature", () => { + // A token_count before its turn_context is dropped; the identical event + // re-emitted once the model is known must still be counted. + const state = initialCodexScanState(); + expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull(); + parseCodexLine(turnContext, state); + expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).not.toBeNull(); + }); }); describe("totalTokens", () => { diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index 5e0b913ae0b..338713d8b1b 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -199,16 +199,20 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord if (typeof last !== "object" || last === null) return null; const lastRecord = last as Record; + // Only an event that is otherwise eligible may consume the duplicate + // signature. A token_count arriving before its turn_context (no model yet) + // must not poison it, or the re-emitted copy after the model is known would + // be skipped as a duplicate and those tokens never counted. + const timestampMs = parseTimestampMs(record["timestamp"]); + if (timestampMs === null) return null; + if (state.model.length === 0) return null; + // Codex re-emits an unchanged token_count on some stream boundaries. Summing // those would double count, so identical consecutive payloads are skipped. const signature = JSON.stringify(lastRecord); if (signature === state.lastUsageSignature) return null; state.lastUsageSignature = signature; - const timestampMs = parseTimestampMs(record["timestamp"]); - if (timestampMs === null) return null; - if (state.model.length === 0) return null; - const inputTokens = int(lastRecord["input_tokens"]); const cachedInputTokens = int(lastRecord["cached_input_tokens"]); const cacheCreationTokens = int(lastRecord["cache_write_input_tokens"]); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index a2c1b3e1b71..325ee96e3ba 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -95,6 +95,7 @@ export function UsagePage() { @@ -193,7 +194,7 @@ export function UsagePage() { environment.error !== null); - if (failed.length === 0 && duplicateSources.length === 0 && !isPartial) return null; + const stale = environments.filter((environment) => + staleEnvironments.includes(environment.environmentId), + ); + if (failed.length === 0 && stale.length === 0 && duplicateSources.length === 0 && !isPartial) { + return null; + } return (
@@ -421,6 +434,11 @@ function UsageCoverageNotice({ {failed.map((environment) => ( {environment.label} could not report usage. ))} + {stale.map((environment) => ( + + {environment.label} runs an older server version and is excluded from totals. + + ))} {duplicateSources.length > 0 ? ( Counted once across environments sharing a transcript directory:{" "} diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 6b8592d39a5..d1ffce25e65 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -91,28 +91,64 @@ function monotoneTangents(points: readonly Point[]): readonly number[] { return tangents; } -/** Smoothed polyline through `points`, as a sequence of cubic segments. */ -function smoothSegments(points: readonly Point[], startCommand: "M" | "L"): string { - if (points.length === 0) return ""; - const first = points[0]; - if (first === undefined) return ""; - if (points.length === 1) return `${startCommand}${first.x.toFixed(2)},${first.y.toFixed(2)}`; +/** One cubic segment of a smoothed boundary. */ +interface CurveSegment { + readonly from: Point; + readonly c1: Point; + readonly c2: Point; + readonly to: Point; +} +/** Smoothed polyline through `points`, as explicit cubic control points. */ +function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { + if (points.length < 2) return []; const tangents = monotoneTangents(points); - let path = `${startCommand}${first.x.toFixed(2)},${first.y.toFixed(2)}`; + const segments: CurveSegment[] = []; for (let index = 0; index < points.length - 1; index += 1) { const from = points[index]; const to = points[index + 1]; if (from === undefined || to === undefined) continue; const dx = to.x - from.x; - const c1x = from.x + dx / 3; - const c1y = from.y + ((tangents[index] ?? 0) * dx) / 3; - const c2x = to.x - dx / 3; - const c2y = to.y - ((tangents[index + 1] ?? 0) * dx) / 3; - path += ` C${c1x.toFixed(2)},${c1y.toFixed(2)} ${c2x.toFixed(2)},${c2y.toFixed(2)} ${to.x.toFixed(2)},${to.y.toFixed(2)}`; + segments.push({ + from, + c1: { x: from.x + dx / 3, y: from.y + ((tangents[index] ?? 0) * dx) / 3 }, + c2: { x: to.x - dx / 3, y: to.y - ((tangents[index + 1] ?? 0) * dx) / 3 }, + to, + }); } + return segments; +} +function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string { + const first = segments[0]; + if (first === undefined) return ""; + let path = `${startCommand}${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`; + for (const segment of segments) { + 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)}`; + } + return path; +} + +/** + * The same curve walked end to start. A cubic reverses exactly by swapping its + * control points, so this traces the identical geometry. + * + * Bands must use this rather than re-smoothing their base points in reverse: + * the tangent clamp in `monotoneTangents` runs left to right, so smoothing is + * not perfectly symmetric under reversal, and independently smoothed edges of + * adjacent bands could hairline-gap or overlap. Sharing one curve per stack + * boundary makes that geometrically impossible. + */ +function reversedCurvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string { + const last = segments[segments.length - 1]; + if (last === undefined) return ""; + let path = `${startCommand}${last.to.x.toFixed(2)},${last.to.y.toFixed(2)}`; + for (let index = segments.length - 1; index >= 0; index -= 1) { + const segment = segments[index]; + if (segment === undefined) continue; + 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)}`; + } return path; } @@ -189,22 +225,26 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr const toY = (value: number) => max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP); - const built = PROVIDER_ORDER.map((provider, providerIndex) => { - const top: Point[] = stacked.map((column, dayIndex) => ({ - x: dayIndex * step, - y: toY(column.bands[providerIndex]?.top ?? 0), - })); - const bottom: Point[] = stacked - .map((column, dayIndex) => ({ + // One smoothed curve per stack boundary (baseline, then each provider's + // cumulative top). Band k is the region between boundary k and k+1, both + // drawn from these shared control points. + const boundaries = [ + stacked.map((_, dayIndex) => ({ x: dayIndex * step, y: toY(0) })), + ...PROVIDER_ORDER.map((_, providerIndex) => + stacked.map((column, dayIndex) => ({ x: dayIndex * step, - y: toY(column.bands[providerIndex]?.base ?? 0), - })) - .toReversed(); + y: toY(column.bands[providerIndex]?.top ?? 0), + })), + ), + ].map(smoothCurve); + const built = PROVIDER_ORDER.map((provider, providerIndex) => { + const top = boundaries[providerIndex + 1] ?? []; + const base = boundaries[providerIndex] ?? []; return { provider, - area: `${smoothSegments(top, "M")} ${smoothSegments(bottom, "L")} Z`, - line: smoothSegments(top, "M"), + area: `${curvePath(top, "M")} ${reversedCurvePath(base, "L")} Z`, + line: curvePath(top, "M"), }; }); diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 6e610358ef6..57114ade152 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -6,7 +6,7 @@ * * @module state/usage */ -import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; +import { useAtomValue } from "@effect/atom-react"; import { USAGE_CONTRACT_VERSION, type EnvironmentId, @@ -15,9 +15,10 @@ import { } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "../usage/usageMerge"; +import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentPresentations } from "./presentation"; import { serverEnvironment } from "./server"; @@ -61,7 +62,11 @@ export interface UsageView { readonly environments: readonly EnvironmentUsageStatus[]; /** True until at least one environment has answered. */ readonly isPending: boolean; - /** True while some environments are still answering but others have. */ + /** + * True while environments that have not failed are still answering. Failed + * environments are reported through their own error rows: totals will not + * improve by waiting on them, so they must not read as "still reporting". + */ readonly isPartial: boolean; readonly refresh: () => void; } @@ -78,7 +83,18 @@ export function useUsage(input: UsageSummaryInput): UsageView { ); const atom = usageByWindowAtom(windowKey); const environments = useAtomValue(atom); - const refresh = useAtomRefresh(atom); + + // Refreshing only the derived atom would re-read the per-environment SWR + // queries within their stale window and change nothing. Refresh each + // environment's query so the button always rescans. + const refresh = useCallback(() => { + const input = JSON.parse(windowKey) as UsageSummaryInput; + for (const environment of environments) { + appAtomRegistry.refresh( + serverEnvironment.usageSummary({ environmentId: environment.environmentId, input }), + ); + } + }, [environments, windowKey]); const merged = useMemo(() => { const answered: EnvironmentUsage[] = environments.flatMap((environment) => @@ -96,12 +112,15 @@ export function useUsage(input: UsageSummaryInput): UsageView { }, [environments]); const answeredCount = environments.filter((environment) => environment.summary !== null).length; + const stillReporting = environments.filter( + (environment) => environment.summary === null && environment.error === null, + ).length; return { merged, environments, - isPending: answeredCount === 0 && environments.some((environment) => environment.isPending), - isPartial: answeredCount > 0 && answeredCount < environments.length, + isPending: answeredCount === 0 && stillReporting > 0, + isPartial: answeredCount > 0 && stillReporting > 0, refresh, }; } diff --git a/apps/web/src/usage/usageFormat.ts b/apps/web/src/usage/usageFormat.ts index 3f0911ef22e..c7c21605837 100644 --- a/apps/web/src/usage/usageFormat.ts +++ b/apps/web/src/usage/usageFormat.ts @@ -91,9 +91,17 @@ export function makeWindow(days: number, now = new Date()): UsageSummaryInput { month: "2-digit", day: "2-digit", }); + const untilDay = format.format(now); + // Subtracting fixed milliseconds from `now` lands on the wrong calendar day + // around a DST transition. Only "today" needs the zone; the window start is + // pure calendar arithmetic on that day, done in UTC where days are uniform. + const [year = 0, month = 1, dayOfMonth = 1] = untilDay + .split("-") + .map((part) => Number.parseInt(part, 10)); + const start = new Date(Date.UTC(year, month - 1, dayOfMonth - (days - 1))); return { - sinceDay: UsageDay.make(format.format(new Date(now.getTime() - (days - 1) * 86_400_000))), - untilDay: UsageDay.make(format.format(now)), + sinceDay: UsageDay.make(start.toISOString().slice(0, 10)), + untilDay: UsageDay.make(untilDay), timeZone, }; } diff --git a/apps/web/src/usage/usageMerge.test.ts b/apps/web/src/usage/usageMerge.test.ts index ade7448de45..7e44631cf5d 100644 --- a/apps/web/src/usage/usageMerge.test.ts +++ b/apps/web/src/usage/usageMerge.test.ts @@ -39,6 +39,7 @@ function summary( hostId: string; homePath: string; volumeId?: string; + distinctSessions?: number; }[], contractVersion: number = USAGE_CONTRACT_VERSION, ): UsageSummary { @@ -60,6 +61,7 @@ function summary( scannedFiles: 1, skippedFiles: 0, malformedRecords: 0, + distinctSessions: source.distinctSessions ?? 1, message: null, })), pricing: { status: "fresh", source: "litellm", fetchedAt: null, knownModels: 10 }, @@ -105,6 +107,7 @@ describe("mergeUsage", () => { expect(merged.costUsd).toBe(10); expect(merged.records).toBe(5); + expect(merged.sessions).toBe(1); expect(merged.duplicateSources).toHaveLength(1); expect(merged.contributingEnvironments).toEqual(["env-a"]); }); @@ -221,6 +224,32 @@ describe("mergeUsage", () => { expect(merged.duplicateSources).toHaveLength(1); }); + it("totals sessions from per-directory distinct counts, not per-bucket sums", () => { + // One session that spans two days appears in two buckets. Summing bucket + // sessions would say 2; the source's distinct count says 1. + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [bucket({ day: "2026-08-06" as UsageDay }), bucket({ day: "2026-08-07" as UsageDay })], + [ + { + provider: "claude", + hostId: "mac", + homePath: "/a/.claude", + distinctSessions: 1, + }, + ], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.sessions).toBe(1); + }); + it("returns empty totals with no environments", () => { const merged = mergeUsage([], USAGE_CONTRACT_VERSION); expect(merged.costUsd).toBe(0); diff --git a/apps/web/src/usage/usageMerge.ts b/apps/web/src/usage/usageMerge.ts index 653bb65918e..fd73c0a31c0 100644 --- a/apps/web/src/usage/usageMerge.ts +++ b/apps/web/src/usage/usageMerge.ts @@ -122,20 +122,27 @@ function claimSources(environments: readonly EnvironmentUsage[]): { return { ownerByFingerprint, duplicates }; } -/** Buckets this environment is allowed to contribute, after fingerprint claims. */ -function ownedBuckets( +/** Sources this environment owns after fingerprint claims, plus their buckets. */ +function ownedContribution( environment: EnvironmentUsage, ownerByFingerprint: ReadonlyMap, -): readonly UsageBucket[] { +): { readonly buckets: readonly UsageBucket[]; readonly sessions: number } { const ownedProviders = new Set(); + let sessions = 0; for (const source of environment.summary.sources) { if (source.status === "missing") continue; const key = fingerprintKey(source.fingerprint); if (ownerByFingerprint.get(key) === environment.environmentId) { ownedProviders.add(source.fingerprint.provider); + // Distinct within a directory. Summing per-bucket session counts instead + // would count a session once per day and model it spans. + sessions += source.distinctSessions; } } - return environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)); + return { + buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)), + sessions, + }; } function bucketTokens(bucket: UsageBucket): number { @@ -228,8 +235,12 @@ export function mergeUsage( const contributingEnvironments: EnvironmentId[] = []; for (const environment of current) { - const buckets = ownedBuckets(environment, ownerByFingerprint); + const { buckets, sessions: environmentSessions } = ownedContribution( + environment, + ownerByFingerprint, + ); if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); + sessions += environmentSessions; for (const bucket of buckets) { const tokens = bucketTokens(bucket); @@ -242,7 +253,6 @@ export function mergeUsage( outputTokens += bucket.totals.outputTokens; reasoningTokens += bucket.totals.reasoningTokens; records += bucket.records; - sessions += bucket.sessions; unpricedRecords += bucket.unpricedRecords; if (bucket.costSource === "providerReported") providerReportedRecords += bucket.records; diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index f967e63c8d0..92644673609 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -21,7 +21,7 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; * client renders partial coverage when an environment reports an older version * rather than failing the whole page. */ -export const USAGE_CONTRACT_VERSION = 2 as const; +export const USAGE_CONTRACT_VERSION = 3 as const; export const UsageProviderKind = Schema.Literals(["claude", "codex"]); export type UsageProviderKind = typeof UsageProviderKind.Type; @@ -32,7 +32,11 @@ export type UsageProviderKind = typeof UsageProviderKind.Type; * Days are bucketed server-side so that a turn always lands on the day the user * experienced it, not the UTC day. */ -export const UsageDay = TrimmedNonEmptyString.pipe(Schema.brand("UsageDay")); +const USAGE_DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +export const UsageDay = TrimmedNonEmptyString.check(Schema.isPattern(USAGE_DAY_PATTERN)).pipe( + Schema.brand("UsageDay"), +); export type UsageDay = typeof UsageDay.Type; /** @@ -126,6 +130,12 @@ export const UsageSource = Schema.Struct({ skippedFiles: NonNegativeInt, /** Records that parsed but carried no recognisable usage payload. */ malformedRecords: NonNegativeInt, + /** + * Distinct transcript sessions seen under this directory. Buckets also carry + * per-bucket session counts, but a session spans days and models, so summing + * those overcounts; this is the figure clients should total. + */ + distinctSessions: NonNegativeInt, message: Schema.NullOr(TrimmedNonEmptyString), }); export type UsageSource = typeof UsageSource.Type; From af0f0ac78200222caa514464763665b64e21a45e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 01:59:36 -0700 Subject: [PATCH 09/12] fix(usage): carry error causes, harden cache decode, scope sessions to window UsageReadError gains an optional structured cause so the failure chain survives without stringified defect text leaking into the wire-serialized message. A corrupt scan-cache row now disqualifies its whole file entry, so the file cold re-parses instead of a partial warm hit silently dropping the corrupt rows' usage. Distinct session counts only include records that actually landed in the requested window. Co-Authored-By: Claude Fable 5 --- apps/server/src/usage/UsageService.ts | 12 ++++-- .../server/src/usage/usageAggregation.test.ts | 13 ++++++ apps/server/src/usage/usageAggregation.ts | Bin 5739 -> 6019 bytes apps/server/src/usage/usageScanCache.test.ts | 40 +++++++++--------- apps/server/src/usage/usageScanCache.ts | 20 ++++++--- packages/contracts/src/usage.ts | 2 + 6 files changed, 59 insertions(+), 28 deletions(-) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index bdcf55c076c..739aeb9078b 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -204,7 +204,10 @@ export const make = Effect.gen(function* () { (cause) => new UsageReadError({ reason: "scanFailed", - detail: `Server settings could not be read: ${String(cause)}`, + // Bounded description only; the chain travels in `cause` so defect + // text never leaks into the wire-serialized message. + detail: "Server settings could not be read.", + cause, }), ), ); @@ -347,8 +350,11 @@ export const make = Effect.gen(function* () { } scannedFiles += 1; for (const record of records) { - aggregator.add(record); - if (record.sessionId.length > 0) sessionIds.add(record.sessionId); + // Only sessions that contributed in-window count: the mtime slack + // admits boundary files whose records fall outside the range. + if (aggregator.add(record) && record.sessionId.length > 0) { + sessionIds.add(record.sessionId); + } } } diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 75f611e2cd6..9117e216f12 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -107,6 +107,19 @@ describe("UsageAggregator", () => { expect(result.buckets).toHaveLength(0); }); + it("reports whether a record contributed", () => { + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + }); + + expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(true); + expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(false); + expect(aggregator.add(record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") }))).toBe(false); + }); + it("separates providers and models into their own buckets", () => { const result = aggregate([ record(), diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 9c5a2036048aa7217d283076138ff680b59b7776..4f04a318c529c8498a200c3f21a5caed2330ee3e 100644 GIT binary patch delta 321 zcmXYtF;2rk5Jd|FNEjpv4)DuhIq?~2pyvW;vpdEst!J&-ajaA+=xBYBNGYkg1s6aR zT!D>AGa7yA|Nr!Vs<(RoQ0(fmyaG_-F$R|qnIL4NICx&+nRF0N7+cbo1YTjR4km;x zY-AO`9+X^#6k)SUlK#`IVK@@MA@n56(X)#qnru#3IG@zBRV3P_ t2u)AL-Q_yQK!&H6;{75shD6hM@pXC+pVz-3`O&z;R@c;7Q` JJ|noD6#!OG7D)gA diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index cb793934e37..9c1157f9dc9 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -76,6 +76,27 @@ describe("scan cache round trip", () => { const restored = decodeScanCache(JSON.parse(JSON.stringify(withJunk))); expect([...restored.keys()]).toEqual(["/good.jsonl"]); }); + + it("drops the whole entry when any row is corrupt, forcing a cold re-parse", () => { + // Keeping the surviving rows under the original (size, mtime) would read + // as a valid warm hit and the file would never be re-parsed. + const encoded = encodeScanCache( + cacheWith([["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:" })]]]), + ); + const rows = encoded.files["/a.jsonl"]!.r; + const poisoned = { + ...encoded, + files: { + "/a.jsonl": { + ...encoded.files["/a.jsonl"]!, + r: [rows[0]!, [...rows[1]!.slice(0, 3), "not-a-number", ...rows[1]!.slice(4)]], + }, + }, + }; + + const restored = decodeScanCache(JSON.parse(JSON.stringify(poisoned))); + expect(restored.has("/a.jsonl")).toBe(false); + }); }); describe("pruneScanCache", () => { @@ -156,25 +177,6 @@ describe("pruneScanCache with an unwalked root", () => { }); }); -describe("decodeScanCache numeric validation", () => { - it("rejects rows whose token fields are not numbers", () => { - const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); - const rows = encoded.files["/a.jsonl"]!.r; - const poisoned = { - ...encoded, - files: { - "/a.jsonl": { - ...encoded.files["/a.jsonl"]!, - r: [[...rows[0]!.slice(0, 3), "not-a-number", ...rows[0]!.slice(4)]], - }, - }, - }; - - const restored = decodeScanCache(JSON.parse(JSON.stringify(poisoned))); - expect(restored.get("/a.jsonl")?.records).toHaveLength(0); - }); -}); - describe("dedupeWithinFile", () => { it("keeps the first record per dedupe key", () => { const kept = dedupeWithinFile([ diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 13e45ae5977..39b41199051 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -132,8 +132,15 @@ export function decodeScanCache(document: unknown): ScanCache { const provider: UsageProviderKind = entry.p; const records: UsageRecord[] = []; + // Any corrupt row disqualifies the whole entry. Keeping the survivors + // under the original (size, mtime) would read as a valid warm hit and the + // file would never be re-parsed, silently losing the dropped rows' usage. + let corrupt = false; for (const row of entry.r) { - if (!isRecordArray(row) || row.length < 10) continue; + if (!isRecordArray(row) || row.length < 10) { + corrupt = true; + break; + } const [ timestampMs, modelIndex, @@ -148,18 +155,18 @@ export function decodeScanCache(document: unknown): ScanCache { ] = row as SerializedRecord; const model = typeof modelIndex === "number" ? models[modelIndex] : undefined; - if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) continue; - if (model === undefined) continue; - // Token fields must be real numbers: a corrupt row must cost a cold - // re-parse of its file, never flow into the aggregate as NaN or a string. if ( + typeof timestampMs !== "number" || + !Number.isFinite(timestampMs) || + model === undefined || !Number.isFinite(uncached) || !Number.isFinite(cached) || !Number.isFinite(cacheCreation) || !Number.isFinite(output) || !Number.isFinite(reasoning) ) { - continue; + corrupt = true; + break; } records.push({ @@ -179,6 +186,7 @@ export function decodeScanCache(document: unknown): ScanCache { }); } + if (corrupt) continue; cache.set(path, { size: entry.s, mtimeMs: entry.m, provider, records }); } diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 92644673609..1aa639fe4a0 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -184,7 +184,9 @@ export type UsageSummary = typeof UsageSummary.Type; export class UsageReadError extends Schema.TaggedErrorClass()("UsageReadError", { reason: Schema.Literals(["scanFailed", "invalidWindow"]), + /** Stable, bounded description. The underlying failure travels in `cause`. */ detail: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), }) { override get message(): string { return `Usage read failed (${this.reason}): ${this.detail}`; From 94344aa2d23553513de33bb9c77b3c04e047b335 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 02:11:39 -0700 Subject: [PATCH 10/12] fix(usage): provider-aware cache hits, string-checked intern tables, squashed cause Co-Authored-By: Claude Fable 5 --- apps/server/src/usage/UsageService.ts | 19 +++++++++++++++---- apps/server/src/usage/usageScanCache.test.ts | 9 +++++++++ apps/server/src/usage/usageScanCache.ts | 5 +++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 739aeb9078b..2ad2a729ecb 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -21,6 +21,7 @@ import { type UsageSummaryInput, UsageReadError, } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -204,10 +205,11 @@ export const make = Effect.gen(function* () { (cause) => new UsageReadError({ reason: "scanFailed", - // Bounded description only; the chain travels in `cause` so defect - // text never leaks into the wire-serialized message. + // Bounded description; the squashed failure travels as the cause. + // Squashed, not the Cause tree: a full tree in a Defect field is + // the unbounded wire payload the bounded detail exists to avoid. detail: "Server settings could not be read.", - cause, + cause: Cause.squash(cause), }), ), ); @@ -263,7 +265,16 @@ export const make = Effect.gen(function* () { ): Effect.Effect => Effect.gen(function* () { const cached = fileCache.get(filePath); - if (cached && cached.size === size && cached.mtimeMs === mtimeMs) return cached.records; + // Provider is part of the identity: if both providers were ever pointed + // at one directory, a hit parsed by the other parser must not be reused. + if ( + cached && + cached.size === size && + cached.mtimeMs === mtimeMs && + cached.provider === provider + ) { + return cached.records; + } const parsed = yield* Effect.promise(() => readTranscriptRecords(filePath, provider)); // A read failure is not an empty transcript: caching it under this diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 9c1157f9dc9..64673e96c09 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -77,6 +77,15 @@ describe("scan cache round trip", () => { expect([...restored.keys()]).toEqual(["/good.jsonl"]); }); + it("rejects the whole cache when an intern table holds a non-string", () => { + // models: [1] would pass the undefined guard, put a number in a record's + // model, and crash normalizeModelName at aggregate time. + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); + const poisoned = { ...encoded, models: [1] }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).size).toBe(0); + }); + it("drops the whole entry when any row is corrupt, forcing a cold re-parse", () => { // Keeping the surviving rows under the original (size, mtime) would read // as a valid warm hit and the file would never be re-parsed. diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 39b41199051..0dafa7a6daf 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -120,6 +120,11 @@ export function decodeScanCache(document: unknown): ScanCache { if (!isRecordArray(root.models) || !isRecordArray(root.sessions)) return cache; if (typeof root.files !== "object" || root.files === null) return cache; + // The intern tables must be all strings: a numeric entry would pass the + // undefined guard below, land in a record's model, and crash the aggregate + // at normalizeModelName. A corrupt table rejects the whole cache. + if (!root.models.every((value) => typeof value === "string")) return cache; + if (!root.sessions.every((value) => typeof value === "string")) return cache; const models = root.models as readonly string[]; const sessions = root.sessions as readonly string[]; From ccad7ce1f4507f879540632bf88cc16acdaf66ca Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 03:50:42 -0700 Subject: [PATCH 11/12] copy(usage): footnote the headline cost Co-Authored-By: Claude Fable 5 --- apps/web/src/components/usage/UsagePage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 325ee96e3ba..a75b0dc48a0 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -116,12 +116,12 @@ export function UsagePage() { {metric === "cost" - ? formatUsd(merged.costUsd) + ? `${formatUsd(merged.costUsd)}*` : formatTokens(merged.totalTokens)} {metric === "cost" - ? "What these tokens would cost at API rates. Not what you were billed." + ? "* if billed at full API rate" : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`}
From 721f8122730babc5783705005021bf32147364bc Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 03:55:00 -0700 Subject: [PATCH 12/12] feat(web): promote cache savings to the metric row Co-Authored-By: Claude Fable 5 --- apps/web/src/components/usage/UsagePage.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index a75b0dc48a0..2f3ab4b574c 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -212,9 +212,13 @@ export function UsagePage() { detail={`includes ${formatTokens(merged.reasoningTokens)} reasoning`} /> 0 + ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw token cost` + : "vs full input rates" + } />