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