diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index ea7746800..94a63d3ba 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -86,6 +86,7 @@ import { type ProjectionSnapshotQueryShape, } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { ThreadSearch, type ThreadSearchShape } from "./orchestration/Services/ThreadSearch.ts"; +import { UsageService } from "./usage/UsageService.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; import { @@ -672,6 +673,9 @@ const buildAppUnderTest = (options?: { search: () => Effect.succeed({ matches: [], truncated: false }), ...options?.layers?.threadSearch, }), + // Usage scans real provider transcript directories; the empty summary + // keeps the RPC surface resolvable without touching the host's files. + UsageService.layerTest, ), ), Layer.provide( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e964ef173..591021149 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -93,6 +93,7 @@ import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { ThreadSearchLive } from "./orchestration/Layers/ThreadSearch.ts"; +import { UsageServiceLive } from "./usage/UsageService.ts"; import { clearPersistedServerRuntimeState, makePersistedServerRuntimeState, @@ -356,6 +357,10 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( const RuntimeServicesLive = Layer.mergeAll( ServerRuntimeStartupLive.pipe(Layer.provideMerge(RuntimeDependenciesLive)), ThreadSearchLive.pipe(Layer.provide(PersistenceLayerLive)), + // Usage reads provider transcripts straight off disk, so it needs only + // settings (for the resolved provider homes) plus the platform services and + // HTTP client provided further out. + UsageServiceLive.pipe(Layer.provide(ServerSettingsLive)), ); export const makeRoutesLayer = Layer.mergeAll( diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts new file mode 100644 index 000000000..db5da67ec --- /dev/null +++ b/apps/server/src/usage/UsageService.test.ts @@ -0,0 +1,343 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { DEFAULT_SERVER_SETTINGS, UsageDay, type UsageSummary } from "@threadlines/contracts"; +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "../config.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { UsageService, UsageServiceLive } from "./UsageService.ts"; + +const CLAUDE_MODEL = "claude-fable-5"; +const CODEX_MODEL = "gpt-5.6-sol"; + +/** Minimal LiteLLM document covering just the two models the fixtures use. */ +const RATE_DOCUMENT = { + [CLAUDE_MODEL]: { + input_cost_per_token: 1e-5, + output_cost_per_token: 5e-5, + cache_read_input_token_cost: 1e-6, + cache_creation_input_token_cost: 1.25e-5, + }, + [CODEX_MODEL]: { + input_cost_per_token: 2e-6, + output_cost_per_token: 1e-5, + cache_read_input_token_cost: 2e-7, + }, +}; + +const ratesHttpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(RATE_DOCUMENT), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ), + ), +); + +function claudeAssistantLine(input: { + readonly messageId: string; + readonly requestId: string; + readonly timestamp: string; + readonly sessionId: string; + readonly outputTokens: number; +}): string { + return JSON.stringify({ + type: "assistant", + timestamp: input.timestamp, + sessionId: input.sessionId, + requestId: input.requestId, + message: { + id: input.messageId, + role: "assistant", + model: CLAUDE_MODEL, + usage: { + input_tokens: 10, + cache_creation_input_tokens: 100, + cache_read_input_tokens: 1000, + output_tokens: input.outputTokens, + }, + }, + }); +} + +function codexRollout(input: { + readonly sessionId: string; + readonly forkedFromId?: string; + /** `[timestamp, inputTokens, outputTokens]` per usage event. */ + readonly events: readonly (readonly [string, number, number])[]; + readonly openedAt: string; +}): string { + const lines = [ + JSON.stringify({ + type: "session_meta", + timestamp: input.openedAt, + payload: { + id: input.sessionId, + forked_from_id: input.forkedFromId ?? null, + source: "vscode", + }, + }), + JSON.stringify({ + type: "turn_context", + timestamp: input.openedAt, + payload: { model: CODEX_MODEL }, + }), + ]; + for (const [timestamp, inputTokens, outputTokens] of input.events) { + lines.push( + JSON.stringify({ + type: "event_msg", + timestamp, + payload: { + type: "token_count", + info: { + last_token_usage: { + input_tokens: inputTokens, + cached_input_tokens: 0, + cache_write_input_tokens: 0, + output_tokens: outputTokens, + reasoning_output_tokens: 0, + }, + }, + }, + }), + ); + } + return `${lines.join("\n")}\n`; +} + +/** + * Lays out a throwaway provider home pair and the server state dir the scan + * cache lives in. Real files on disk: the whole point of the service is what it + * finds by walking them. + */ +const makeFixture = Effect.fn("makeFixture")(function* () { + const root = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "threadlines-usage-")), + ); + const claudeHome = NodePath.join(root, "claude-home"); + const claudeProjects = NodePath.join(claudeHome, ".claude", "projects", "-tmp-project"); + const codexHome = NodePath.join(root, "codex-home"); + const codexSessions = NodePath.join(codexHome, "sessions", "2026", "08", "07"); + + yield* Effect.promise(async () => { + await NodeFSP.mkdir(claudeProjects, { recursive: true }); + await NodeFSP.mkdir(codexSessions, { recursive: true }); + + // Two content blocks of one message repeat the same usage, and the same + // message is replayed into a second transcript: one record must survive. + const repeated = [ + claudeAssistantLine({ + messageId: "msg_1", + requestId: "req_1", + timestamp: "2026-08-07T12:00:00.000Z", + sessionId: "claude-session", + outputTokens: 40, + }), + claudeAssistantLine({ + messageId: "msg_1", + requestId: "req_1", + timestamp: "2026-08-07T12:00:00.000Z", + sessionId: "claude-session", + outputTokens: 40, + }), + ]; + await NodeFSP.writeFile( + NodePath.join(claudeProjects, "session-a.jsonl"), + `${repeated.join("\n")}\n`, + ); + await NodeFSP.writeFile(NodePath.join(claudeProjects, "session-b.jsonl"), `${repeated[0]!}\n`); + + await NodeFSP.writeFile( + NodePath.join(codexSessions, "rollout-parent.jsonl"), + codexRollout({ + sessionId: "codex-parent", + openedAt: "2026-08-07T12:00:00.000Z", + events: [["2026-08-07T12:00:10.000Z", 500, 20]], + }), + ); + // The fork replays the parent's turn re-stamped to the fork instant, then + // runs one turn of its own. Only the second may be counted. + await NodeFSP.writeFile( + NodePath.join(codexSessions, "rollout-fork.jsonl"), + codexRollout({ + sessionId: "codex-fork", + forkedFromId: "codex-parent", + openedAt: "2026-08-07T13:00:00.000Z", + events: [ + ["2026-08-07T13:00:00.001Z", 500, 20], + ["2026-08-07T13:00:30.000Z", 700, 35], + ], + }), + ); + }); + + const derivedPaths = yield* deriveServerPaths(NodePath.join(root, "state"), undefined).pipe( + Effect.provide(NodeServices.layer), + ); + const config: ServerConfigShape = { + appVersion: "0.0.0-test", + logLevel: "Info", + traceMinLevel: "Info", + traceTimingEnabled: false, + traceBatchWindowMs: 200, + traceMaxBytes: 1024, + traceMaxFiles: 1, + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + otlpExportIntervalMs: 10_000, + otlpServiceName: "threadlines-server", + mode: "web", + port: 0, + host: "127.0.0.1", + cwd: root, + baseDir: NodePath.join(root, "state"), + ...derivedPaths, + staticDir: undefined, + devUrl: undefined, + noBrowser: true, + startupPresentation: "browser", + desktopBootstrapToken: undefined, + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }; + yield* Effect.promise(() => NodeFSP.mkdir(config.stateDir, { recursive: true })); + + const settings = { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + claudeAgent: { ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, homePath: claudeHome }, + codex: { ...DEFAULT_SERVER_SETTINGS.providers.codex, homePath: codexHome }, + }, + }; + + const layer = UsageServiceLive.pipe( + Layer.provide( + Layer.mock(ServerSettingsService)({ + start: Effect.void, + ready: Effect.void, + getSettings: Effect.succeed(settings), + updateSettings: () => Effect.succeed(settings), + streamChanges: Stream.empty, + }), + ), + Layer.provide(Layer.succeed(ServerConfig, config)), + Layer.provide(ratesHttpClientLayer), + Layer.provide(NodeServices.layer), + ); + + return { root, claudeProjects, config, layer } as const; +}); + +const window = { + sinceDay: UsageDay.make("2026-08-01"), + untilDay: UsageDay.make("2026-08-31"), + timeZone: "UTC", +}; + +function bucketFor(summary: UsageSummary, provider: "claude" | "codex") { + return summary.buckets.find((bucket) => bucket.provider === provider); +} + +describe("UsageService.readSummary", () => { + // it.live throughout: the service walks real directories and reads the real + // clock, both of which stall under it.effect's TestClock. + it.live("prices a window, dropping repeated Claude usage and copied fork turns", () => + Effect.gen(function* () { + const fixture = yield* makeFixture(); + const summary = yield* Effect.provide( + Effect.flatMap(UsageService, (usage) => usage.readSummary(window)), + fixture.layer, + ); + + const claude = bucketFor(summary, "claude"); + // One message, repeated twice in one file and once more in a second. + expect(claude?.records).toBe(1); + expect(claude?.totals.outputTokens).toBe(40); + // 10*1e-5 + 1000*1e-6 + 100*1.25e-5 + 40*5e-5 + expect(claude?.costUsd).toBeCloseTo(0.00435, 9); + expect(claude?.costSource).toBe("modelPriced"); + + const codex = bucketFor(summary, "codex"); + // Parent's single turn plus the fork's own turn; the fork's replayed copy + // of the parent turn must not be counted a second time. + expect(codex?.records).toBe(2); + expect(codex?.totals.outputTokens).toBe(55); + expect(codex?.sessions).toBe(2); + + expect(summary.pricing.status).toBe("fresh"); + expect(summary.sources.map((source) => source.status)).toEqual(["ok", "ok"]); + expect(summary.sources.every((source) => source.lastScannedAt.length > 0)).toBe(true); + yield* Effect.promise(() => NodeFSP.rm(fixture.root, { recursive: true, force: true })); + }), + ); + + it.live("picks up a transcript that changed and leaves the rest memoised", () => + Effect.gen(function* () { + const fixture = yield* makeFixture(); + const read = Effect.provide( + Effect.flatMap(UsageService, (usage) => usage.readSummary(window)), + fixture.layer, + ); + + const before = yield* read; + expect(bucketFor(before, "claude")?.totals.outputTokens).toBe(40); + + yield* Effect.promise(() => + NodeFSP.appendFile( + NodePath.join(fixture.claudeProjects, "session-a.jsonl"), + `${claudeAssistantLine({ + messageId: "msg_2", + requestId: "req_2", + timestamp: "2026-08-08T12:00:00.000Z", + sessionId: "claude-session", + outputTokens: 7, + })}\n`, + ), + ); + + const after = yield* read; + const claudeBuckets = after.buckets.filter((bucket) => bucket.provider === "claude"); + expect(claudeBuckets.map((bucket) => bucket.day)).toEqual(["2026-08-07", "2026-08-08"]); + expect(claudeBuckets[1]?.totals.outputTokens).toBe(7); + + yield* Effect.promise(() => NodeFSP.rm(fixture.root, { recursive: true, force: true })); + }), + ); + + it.live("rejects a window longer than the cache retains", () => + Effect.gen(function* () { + const fixture = yield* makeFixture(); + const failure = yield* Effect.provide( + Effect.flatMap(UsageService, (usage) => + usage.readSummary({ + sinceDay: UsageDay.make("2025-08-01"), + untilDay: UsageDay.make("2026-08-31"), + timeZone: "UTC", + }), + ), + fixture.layer, + ).pipe(Effect.flip); + + expect(failure.reason).toBe("invalidWindow"); + yield* Effect.promise(() => NodeFSP.rm(fixture.root, { recursive: true, force: true })); + }), + ); +}); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts new file mode 100644 index 000000000..929325267 --- /dev/null +++ b/apps/server/src/usage/UsageService.ts @@ -0,0 +1,461 @@ +/** + * UsageService - scans provider transcripts and returns priced daily usage. + * + * The scan reads the provider CLIs' own session files rather than Threadlines' + * orchestration projections, so usage covers turns driven from the CLI, an + * editor extension, or any other tool sharing the same provider home. This is + * the approach `ccusage` takes. + * + * Transcripts are append-only, so parsed records are memoised per file by + * `(size, mtime)`; warm scans only reparse files that changed. + * + * Cost figures are API list-price equivalents, never billed spend. Subscription + * plans bill on their own terms and the transcripts carry no invoice. + * + * @module UsageService + */ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeOS from "node:os"; + +import { + USAGE_CONTRACT_VERSION, + USAGE_MAX_WINDOW_DAYS, + type UsageProviderKind, + type UsageSource, + type UsageSummary, + type UsageSummaryInput, + UsageReadError, +} from "@threadlines/contracts"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import { writeFileStringAtomically } from "../atomicWrite.ts"; +import { ServerConfig } from "../config.ts"; +import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; +import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { UsageAggregator } from "./usageAggregation.ts"; +import { parseRateTable, type RateTable } from "./usagePricing.ts"; +import { + decodeScanCache, + dedupeWithinFile, + encodeScanCache, + pruneScanCache, + type ScanCache, +} from "./usageScanCache.ts"; +import { + listTranscriptFiles, + readDirectoryVolumeId, + readTranscriptRecords, +} from "./usageTranscriptReader.ts"; +import type { UsageRecord } from "./usageTranscripts.ts"; + +const LITELLM_RATES_URL = + "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"; + +/** Rates move rarely; a day-old table keeps the page working offline. */ +const RATES_TTL_MS = 24 * 60 * 60 * 1000; + +const RATES_FETCH_TIMEOUT_MS = 10_000; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Files are filtered by mtime before opening. The slack covers a session whose + * last write lands just before local midnight on the window's first day. + */ +const MTIME_SLACK_MS = 36 * 60 * 60 * 1000; + +/** Longest window the UI offers, plus slack. Older entries are pruned. */ +const CACHE_RETENTION_DAYS = USAGE_MAX_WINDOW_DAYS; + +interface RatesCacheFile { + readonly fetchedAtMs: number; + readonly document: unknown; +} + +/** + * Both on-disk caches hold documents that are narrowed by hand downstream + * (`decodeScanCache`, `parseRateTable`), so a schema here would only restate + * `unknown`. A parse failure means one cold scan, never a failed read. + */ +const parseJsonFile = (raw: string): Effect.Effect => + Effect.try(() => JSON.parse(raw) as unknown); + +function readRatesCacheFile(document: unknown): RatesCacheFile | null { + if (typeof document !== "object" || document === null) return null; + const record = document as Partial; + if (typeof record.fetchedAtMs !== "number" || !Number.isFinite(record.fetchedAtMs)) return null; + return { fetchedAtMs: record.fetchedAtMs, document: record.document }; +} + +export interface UsageServiceShape { + readonly readSummary: (input: UsageSummaryInput) => Effect.Effect; +} + +export class UsageService extends Context.Service()( + "threadlines/usage/UsageService", +) { + /** Empty summary, for suites that only need the RPC surface to resolve. */ + static readonly layerTest = Layer.succeed( + UsageService, + UsageService.of({ + readSummary: (input) => + Effect.succeed({ + contractVersion: USAGE_CONTRACT_VERSION, + readAt: "1970-01-01T00:00:00.000Z", + timeZone: input.timeZone, + sinceDay: input.sinceDay, + untilDay: input.untilDay, + buckets: [], + sources: [], + pricing: { + status: "unavailable", + source: LITELLM_RATES_URL, + fetchedAt: null, + knownModels: 0, + }, + scanDurationMs: 0, + }), + }), + ); +} + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig; + const settingsService = yield* ServerSettingsService; + const httpClient = yield* HttpClient.HttpClient; + + const fileCache: ScanCache = new Map(); + let cacheDirty = false; + + /** + * Both caches are rebuildable, so a failed write costs one cold scan and must + * never surface. Written atomically so a crash mid-write cannot leave a + * truncated document that the next start would reject wholesale. + */ + const writeCacheFile = (filePath: string, contents: string): Effect.Effect => + writeFileStringAtomically({ filePath, contents }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.as(true), + Effect.catchCause(() => Effect.succeed(false)), + ); + + const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); + const scanCachePath = path.join(config.stateDir, "usage-scan-cache.json"); + let rates: RateTable = new Map(); + let ratesFetchedAtMs: number | null = null; + let ratesStatus: UsageSummary["pricing"]["status"] = "unavailable"; + + /** + * Loads the LiteLLM rate table, preferring a fresh copy and falling back to + * the on-disk snapshot. With neither, every model reports as unpriced rather + * than the page failing. + */ + const ensureRates = Effect.fn("UsageService.ensureRates")(function* () { + const now = yield* Clock.currentTimeMillis; + if (ratesFetchedAtMs !== null && now - ratesFetchedAtMs < RATES_TTL_MS) return; + + if (ratesFetchedAtMs === null) { + const fromDisk = yield* fileSystem.readFileString(ratesCachePath).pipe( + Effect.flatMap(parseJsonFile), + Effect.map(readRatesCacheFile), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (fromDisk !== null) { + const parsed = parseRateTable(fromDisk.document); + if (parsed.size > 0) { + rates = parsed; + ratesFetchedAtMs = fromDisk.fetchedAtMs; + ratesStatus = "cached"; + if (now - fromDisk.fetchedAtMs < RATES_TTL_MS) return; + } + } + } + + const fetched = yield* httpClient.get(LITELLM_RATES_URL).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.timeout(RATES_FETCH_TIMEOUT_MS), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (fetched === null) { + // The refresh failed; whatever we are serving is now past its TTL and + // must not keep claiming to be fresh. + if (rates.size > 0) ratesStatus = "cached"; + return; + } + + const parsed = parseRateTable(fetched); + if (parsed.size === 0) return; + + rates = parsed; + ratesFetchedAtMs = now; + ratesStatus = "fresh"; + + yield* writeCacheFile( + ratesCachePath, + JSON.stringify({ fetchedAtMs: now, document: fetched } satisfies RatesCacheFile), + ); + }); + + /** + * `resolveClaudeHomePath` returns the process home the CLI runs under, which + * is the home itself when overridden. A default install nests transcripts + * under `~/.claude/projects`, so probe that before falling back. + */ + const resolveClaudeTranscriptDir = (homePath: string) => + Effect.gen(function* () { + const nested = path.join(homePath, ".claude", "projects"); + const nestedExists = yield* fileSystem + .exists(nested) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + return nestedExists ? nested : path.join(homePath, "projects"); + }); + + /** Resolves the transcript directory for each provider. */ + const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* () { + // A settings failure must surface as an error: swallowing it here would + // present "zero usage from every provider" as a valid answer. + const settings = yield* settingsService.getSettings.pipe( + Effect.catchCause( + (cause) => + new UsageReadError({ + reason: "scanFailed", + // Bounded description; the squashed failure travels as the cause. + // Squashed, not the Cause tree: a full tree in a Defect field is + // the unbounded wire payload the bounded detail exists to avoid. + detail: "Server settings could not be read.", + cause: Cause.squash(cause), + }), + ), + ); + + const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent); + const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome); + // The shared home, not the shadow overlay: the overlay only isolates + // `auth.json`, and `sessions` is a symlink back to the shared directory. + const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex); + + return [ + { provider: "claude" as const, dir: claudeDir }, + { provider: "codex" as const, dir: path.join(codexLayout.sharedHomePath, "sessions") }, + ]; + }); + + /** + * Loads the persisted scan cache exactly once per process. + * + * `Effect.cached` makes concurrent first readers await the same load rather + * than each seeing a "loaded" flag set before the read finished and cold + * scanning against an empty cache. + */ + const ensureScanCacheLoaded = yield* Effect.cached( + Effect.gen(function* () { + const document = yield* fileSystem.readFileString(scanCachePath).pipe( + Effect.flatMap(parseJsonFile), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (document === null) return; + for (const [cachedPath, entry] of decodeScanCache(document)) fileCache.set(cachedPath, entry); + }), + ); + + const persistScanCache = Effect.fn("UsageService.persistScanCache")(function* () { + if (!cacheDirty) return; + // Cleared only after the write lands, so a failed persist is retried on the + // next scan instead of leaving disk permanently stale. + const written = yield* writeCacheFile( + scanCachePath, + JSON.stringify(encodeScanCache(fileCache)), + ); + if (written) cacheDirty = false; + }); + + /** Parses one transcript, reusing the cached result when it is unchanged. */ + const readFileRecords = ( + filePath: string, + size: number, + mtimeMs: number, + provider: UsageProviderKind, + ): Effect.Effect => + Effect.gen(function* () { + const cached = fileCache.get(filePath); + // Provider is part of the identity: if both providers were ever pointed + // at one directory, a hit parsed by the other parser must not be reused. + if ( + cached && + cached.size === size && + cached.mtimeMs === mtimeMs && + cached.provider === provider + ) { + return cached.records; + } + + const parsed = yield* Effect.promise(() => readTranscriptRecords(filePath, provider)); + // A read failure is not an empty transcript: caching it under this + // (size, mtime) would silently drop the file's usage until it changes. + if (parsed === null) return []; + // Stored already de-duplicated within the file, which is the bulk of all + // duplicates. The aggregator still runs the cross-file dedupe pass. + const records = dedupeWithinFile(parsed); + + fileCache.set(filePath, { size, mtimeMs, provider, records }); + cacheDirty = true; + return records; + }); + + const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { + if (input.sinceDay > input.untilDay) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: `sinceDay '${input.sinceDay}' is after untilDay '${input.untilDay}'`, + }); + } + + const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`); + const windowEnd = DateTime.make(`${input.untilDay}T00:00:00Z`); + if (Option.isNone(windowStart) || Option.isNone(windowEnd)) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: `'${input.sinceDay}'..'${input.untilDay}' is not a valid date range`, + }); + } + + const sinceMs = DateTime.toEpochMillis(windowStart.value); + const spanDays = Math.round((DateTime.toEpochMillis(windowEnd.value) - sinceMs) / DAY_MS) + 1; + // The scan cache only retains this far back, so a longer window would cold + // scan every call. Reject it rather than silently taking minutes. + if (spanDays > USAGE_MAX_WINDOW_DAYS) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: `window of ${spanDays} days exceeds the ${USAGE_MAX_WINDOW_DAYS}-day maximum`, + }); + } + + const startedAtMs = yield* Clock.currentTimeMillis; + yield* ensureRates(); + yield* ensureScanCacheLoaded; + + const hostId = NodeOS.hostname(); + // The home resolvers ask for `Path` themselves; satisfy them from the + // instance we already hold so `readSummary` stays context-free. + const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path)); + const windowStartMs = sinceMs - MTIME_SLACK_MS; + + const aggregator = new UsageAggregator({ + timeZone: input.timeZone, + sinceDay: input.sinceDay, + untilDay: input.untilDay, + rates, + }); + + const sources: UsageSource[] = []; + const livePaths = new Set(); + const walkedRoots: string[] = []; + + for (const { provider, dir } of dirs) { + const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); + const exists = yield* fileSystem + .exists(dir) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + const scannedAt = yield* DateTime.now; + + if (!exists) { + sources.push({ + fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, + status: "missing", + lastScannedAt: DateTime.formatIso(scannedAt), + scannedFiles: 0, + skippedFiles: 0, + distinctSessions: 0, + message: "No transcript directory on this environment.", + }); + continue; + } + + walkedRoots.push(dir); + const files = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs)); + let scannedFiles = 0; + let skippedFiles = 0; + // Distinct per directory. Buckets carry per-cell session counts, but a + // session spans days and models, so clients total this figure instead. + const sessionIds = new Set(); + + for (const file of files) { + livePaths.add(file.path); + const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider); + if (records.length === 0) { + skippedFiles += 1; + continue; + } + scannedFiles += 1; + for (const record of records) { + // Only sessions that contributed in-window count: the mtime slack + // admits boundary files whose records fall outside the range. + if (aggregator.add(record) && record.sessionId.length > 0) { + sessionIds.add(record.sessionId); + } + } + } + + sources.push({ + fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, + status: "ok", + lastScannedAt: DateTime.formatIso(scannedAt), + scannedFiles, + skippedFiles, + distinctSessions: sessionIds.size, + message: null, + }); + } + + const pruned = pruneScanCache(fileCache, { + livePaths, + walkedRoots, + windowStartMs, + retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * DAY_MS, + }); + if (pruned > 0) cacheDirty = true; + yield* persistScanCache(); + + const aggregated = aggregator.finish(); + const readAt = yield* DateTime.now; + const finishedAtMs = yield* Clock.currentTimeMillis; + + return { + contractVersion: USAGE_CONTRACT_VERSION, + readAt: DateTime.formatIso(readAt), + timeZone: input.timeZone, + sinceDay: input.sinceDay, + untilDay: input.untilDay, + buckets: aggregated.buckets, + sources, + pricing: { + status: ratesStatus, + source: LITELLM_RATES_URL, + fetchedAt: + ratesFetchedAtMs === null + ? null + : DateTime.formatIso(DateTime.makeUnsafe(ratesFetchedAtMs)), + knownModels: rates.size, + }, + scanDurationMs: Math.max(0, finishedAtMs - startedAtMs), + } satisfies UsageSummary; + }); + + return { readSummary } as const; +}); + +export const UsageServiceLive = Layer.effect(UsageService, make); diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts new file mode 100644 index 000000000..b9a4a70b9 --- /dev/null +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { UsageAggregator } from "./usageAggregation.ts"; +import type { RateTable } from "./usagePricing.ts"; +import type { UsageRecord } from "./usageTranscripts.ts"; + +const rates: RateTable = new Map([ + [ + "claude-fable-5", + { + inputCostPerToken: 1e-5, + outputCostPerToken: 5e-5, + cacheReadCostPerToken: 1e-6, + cacheCreationCostPerToken: 1.25e-5, + }, + ], +]); + +function record(overrides: Partial = {}): UsageRecord { + return { + provider: "claude", + // 2026-08-07T04:05Z is still Aug 6 in Los Angeles. + timestampMs: Date.parse("2026-08-07T04:05:13.944Z"), + model: "claude-fable-5", + sessionId: "session-a", + totals: { + uncachedInputTokens: 100, + cachedInputTokens: 1000, + cacheCreationTokens: 10, + outputTokens: 50, + reasoningTokens: 0, + }, + reportedCostUsd: null, + dedupeKey: null, + ...overrides, + }; +} + +function aggregate(records: readonly UsageRecord[], timeZone = "UTC") { + const aggregator = new UsageAggregator({ + timeZone, + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + }); + for (const item of records) aggregator.add(item); + return aggregator.finish(); +} + +describe("UsageAggregator", () => { + it("keeps only the first record for a repeated dedupe key", () => { + const result = aggregate([ + record({ dedupeKey: "msg_1:req_1" }), + record({ dedupeKey: "msg_1:req_1" }), + record({ dedupeKey: "msg_1:req_1" }), + ]); + + expect(result.duplicatesDropped).toBe(2); + expect(result.buckets[0]?.records).toBe(1); + expect(result.buckets[0]?.totals.outputTokens).toBe(50); + }); + + it("still sums records that carry no dedupe key", () => { + const result = aggregate([record(), record()]); + + expect(result.duplicatesDropped).toBe(0); + expect(result.buckets[0]?.totals.outputTokens).toBe(100); + }); + + it("buckets by the day in the requested time zone", () => { + const utc = aggregate([record()], "UTC"); + const losAngeles = aggregate([record()], "America/Los_Angeles"); + + expect(utc.buckets[0]?.day).toBe("2026-08-07"); + expect(losAngeles.buckets[0]?.day).toBe("2026-08-06"); + }); + + it("prices against the rate table", () => { + const result = aggregate([record()]); + + // 100*1e-5 + 1000*1e-6 + 10*1.25e-5 + 50*5e-5 + expect(result.buckets[0]?.costUsd).toBeCloseTo(0.004625, 9); + expect(result.buckets[0]?.costSource).toBe("modelPriced"); + // Cached input at full input rates minus what it actually cost. + expect(result.buckets[0]?.cacheSavingsUsd).toBeCloseTo(0.009, 9); + }); + + it("counts tokens but not cost for a model with no rate", () => { + const result = aggregate([record({ model: "kimi-k3" })]); + + expect(result.buckets[0]?.costUsd).toBe(0); + expect(result.buckets[0]?.costSource).toBe("unpriced"); + expect(result.buckets[0]?.unpricedRecords).toBe(1); + expect(result.buckets[0]?.totals.outputTokens).toBe(50); + }); + + it("prefers a reported cost over the rate table", () => { + const result = aggregate([record({ reportedCostUsd: 1.25 })]); + + expect(result.buckets[0]?.costUsd).toBe(1.25); + expect(result.buckets[0]?.costSource).toBe("providerReported"); + }); + + it("drops records outside the window and reports whether one contributed", () => { + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + }); + + expect(aggregator.add(record({ dedupeKey: "msg_1:req_1" }))).toBe(true); + expect(aggregator.add(record({ dedupeKey: "msg_1:req_1" }))).toBe(false); + // The mtime prefilter admits boundary files whose records fall outside. + expect(aggregator.add(record({ timestampMs: Date.parse("2026-07-31T23:59:59Z") }))).toBe(false); + expect(aggregator.add(record({ timestampMs: Date.parse("2026-09-01T00:00:00Z") }))).toBe(false); + expect(aggregator.add(record({ timestampMs: Date.parse("2026-08-01T00:00:00Z") }))).toBe(true); + + expect(aggregator.finish().outOfWindow).toBe(2); + }); + + it("separates providers and models into their own buckets", () => { + const result = aggregate([ + record(), + record({ provider: "codex", model: "gpt-5.6-sol" }), + record({ model: "claude-opus-5" }), + ]); + + expect(result.buckets).toHaveLength(3); + }); +}); diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts new file mode 100644 index 000000000..02a7a75f5 --- /dev/null +++ b/apps/server/src/usage/usageAggregation.ts @@ -0,0 +1,185 @@ +// @effect-diagnostics globalDate:off +/** + * Folds parsed transcript records into `(day, provider, model)` buckets. + * + * `Intl.DateTimeFormat` is the only reliable way to resolve a wall-clock day in + * an arbitrary IANA zone, and it takes a `Date`. That is why the raw `Date` + * construction is allowed here; nothing in this module reads the clock. + * + * Pure, so the bucketing and de-duplication rules are testable without touching + * the filesystem or the network. + * + * @module usageAggregation + */ +import type { UsageBucket, UsageDay, UsageTokenTotals } from "@threadlines/contracts"; + +import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; +import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts"; + +/** + * Formats an instant as a `YYYY-MM-DD` day in `timeZone`. + * + * `en-CA` yields ISO-ordered parts, which is why it is used here rather than + * assembling the day from `Date` getters (those are host-local only). + */ +export function makeDayFormatter(timeZone: string): (timestampMs: number) => string { + let format: Intl.DateTimeFormat; + try { + format = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + } catch { + // An unknown zone should degrade to UTC rather than fail the whole scan. + format = new Intl.DateTimeFormat("en-CA", { + timeZone: "UTC", + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + } + return (timestampMs) => format.format(new Date(timestampMs)); +} + +interface MutableBucket { + totals: UsageTokenTotals; + costUsd: number; + cacheSavingsUsd: number; + records: number; + unpricedRecords: number; + providerReportedRecords: number; + sessions: Set; +} + +export interface AggregateOptions { + readonly timeZone: string; + readonly sinceDay: string; + readonly untilDay: string; + readonly rates: RateTable; +} + +export interface AggregateResult { + readonly buckets: readonly UsageBucket[]; + /** Records dropped because an earlier record carried the same dedupe key. */ + readonly duplicatesDropped: number; + /** Records whose day fell outside the requested window. */ + readonly outOfWindow: number; +} + +/** + * Accumulates records across many files. + * + * De-duplication is global across the whole scan, not per file: Claude Code + * copies a message's records forward when a session is resumed or relocated, so + * the same `dedupeKey` legitimately appears in several transcripts. + */ +export class UsageAggregator { + readonly #buckets = new Map(); + readonly #seen = new Set(); + readonly #toDay: (timestampMs: number) => string; + readonly #options: AggregateOptions; + #duplicatesDropped = 0; + #outOfWindow = 0; + + constructor(options: AggregateOptions) { + this.#options = options; + this.#toDay = makeDayFormatter(options.timeZone); + } + + /** + * Folds one record in. Returns whether it actually contributed, so callers + * can derive per-window facts (distinct sessions, for one) from the records + * that landed rather than everything the mtime prefilter happened to admit. + */ + add(record: UsageRecord): boolean { + if (record.dedupeKey !== null) { + if (this.#seen.has(record.dedupeKey)) { + this.#duplicatesDropped += 1; + return false; + } + this.#seen.add(record.dedupeKey); + } + + const day = this.#toDay(record.timestampMs); + if (day < this.#options.sinceDay || day > this.#options.untilDay) { + this.#outOfWindow += 1; + return false; + } + + const key = `${day} ${record.provider} ${record.model}`; + let bucket = this.#buckets.get(key); + if (bucket === undefined) { + bucket = { + totals: EMPTY_TOTALS, + costUsd: 0, + cacheSavingsUsd: 0, + records: 0, + unpricedRecords: 0, + providerReportedRecords: 0, + sessions: new Set(), + }; + this.#buckets.set(key, bucket); + } + + const priced = priceUsage( + this.#options.rates, + record.model, + record.totals, + record.reportedCostUsd, + ); + + bucket.totals = addTotals(bucket.totals, record.totals); + bucket.costUsd += priced.costUsd; + bucket.cacheSavingsUsd += cacheSavingsUsd(this.#options.rates, record.model, record.totals); + bucket.records += 1; + if (priced.costSource === "unpriced") bucket.unpricedRecords += 1; + if (priced.costSource === "providerReported") bucket.providerReportedRecords += 1; + if (record.sessionId.length > 0) bucket.sessions.add(record.sessionId); + return true; + } + + finish(): AggregateResult { + const buckets: UsageBucket[] = []; + for (const [key, bucket] of this.#buckets) { + const [day = "", provider = "", model = ""] = key.split(" "); + buckets.push({ + day: day as UsageDay, + provider: provider as UsageBucket["provider"], + model, + totals: bucket.totals, + costUsd: bucket.costUsd, + cacheSavingsUsd: bucket.cacheSavingsUsd, + costSource: resolveCostSource(bucket), + records: bucket.records, + unpricedRecords: bucket.unpricedRecords, + sessions: bucket.sessions.size, + }); + } + // Stable ordering keeps payloads diffable and snapshot tests meaningful. + buckets.sort( + (a, b) => + a.day.localeCompare(b.day) || + a.provider.localeCompare(b.provider) || + a.model.localeCompare(b.model), + ); + + return { + buckets, + duplicatesDropped: this.#duplicatesDropped, + outOfWindow: this.#outOfWindow, + }; + } +} + +/** + * A bucket mixes records from one model, but their cost provenance can differ + * when only some records carried a reported cost. The weakest provenance in the + * bucket wins so the UI never overstates confidence. + */ +function resolveCostSource(bucket: MutableBucket): UsageBucket["costSource"] { + if (bucket.unpricedRecords === bucket.records) return "unpriced"; + if (bucket.providerReportedRecords === bucket.records) return "providerReported"; + return "modelPriced"; +} diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts new file mode 100644 index 000000000..de891e0b7 --- /dev/null +++ b/apps/server/src/usage/usagePricing.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { lookupRate, parseRateTable } from "./usagePricing.ts"; + +describe("parseRateTable", () => { + it("prefers the un-prefixed entry over routing-prefixed duplicates", () => { + // LiteLLM publishes the same model many times over with a routing prefix, + // sometimes at different rates. They all normalise to one key, so the + // canonical entry has to win regardless of enumeration order. + const table = parseRateTable({ + "azure/gpt-5.6-sol": { input_cost_per_token: 9e-6, output_cost_per_token: 9e-5 }, + "gpt-5.6-sol": { input_cost_per_token: 5e-6, output_cost_per_token: 3e-5 }, + "openrouter/gpt-5.6-sol": { input_cost_per_token: 7e-6, output_cost_per_token: 7e-5 }, + }); + + expect(lookupRate(table, "gpt-5.6-sol")?.inputCostPerToken).toBe(5e-6); + }); + + it("drops entries missing either half of the base rate", () => { + // A half-priced model under-reports silently, which is worse than saying + // the model is unpriced. + const table = parseRateTable({ + "half-priced": { input_cost_per_token: 1e-6 }, + priced: { input_cost_per_token: 1e-6, output_cost_per_token: 2e-6 }, + }); + + expect(lookupRate(table, "half-priced")).toBeNull(); + // Cache rates fall back to the plain input rate rather than to free. + expect(lookupRate(table, "priced")?.cacheReadCostPerToken).toBe(1e-6); + }); + + it("never prices an ambiguous family name", () => { + // "opus" spans generations; guessing one would be a fabricated number. + const table = parseRateTable({ + opus: { input_cost_per_token: 1e-6, output_cost_per_token: 2e-6 }, + }); + + expect(lookupRate(table, "opus")).toBeNull(); + }); +}); diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts new file mode 100644 index 000000000..a8b041db7 --- /dev/null +++ b/apps/server/src/usage/usagePricing.ts @@ -0,0 +1,158 @@ +/** + * Model rate lookup and cost arithmetic. + * + * Rates come from LiteLLM's `model_prices_and_context_window.json`, the same + * table `ccusage` prices against. Everything here is pure: fetching and caching + * the table lives in `UsageService`. + * + * Every figure produced here is an API list-price equivalent, not billed spend. + * + * @module usagePricing + */ +import type { UsageCostSource, UsageTokenTotals } from "@threadlines/contracts"; + +/** + * The subset of a LiteLLM entry we price against. All values are USD per token. + * + * LiteLLM also publishes tiered variants (`*_above_200k_tokens`, `*_flex`, + * `*_priority`, `*_batches`) and separate 1h cache-write rates. We deliberately + * price at the base tier: the transcripts don't record which tier served a + * request, so anything else would be a guess dressed up as precision. + */ +export interface ModelRate { + readonly inputCostPerToken: number; + readonly outputCostPerToken: number; + readonly cacheReadCostPerToken: number; + readonly cacheCreationCostPerToken: number; +} + +export type RateTable = ReadonlyMap; + +/** Raw shape of one LiteLLM entry, narrowed to the fields we read. */ +interface LiteLlmEntry { + readonly input_cost_per_token?: unknown; + readonly output_cost_per_token?: unknown; + readonly cache_read_input_token_cost?: unknown; + readonly cache_creation_input_token_cost?: unknown; +} + +function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +/** + * Projects the LiteLLM document into a rate table. + * + * Entries without both an input and an output rate are dropped: a half-priced + * model would silently under-report cost, which is worse than reporting the + * model as unpriced. + */ +export function parseRateTable(document: unknown): RateTable { + const table = new Map(); + if (typeof document !== "object" || document === null) return table; + + for (const [name, raw] of Object.entries(document as Record)) { + if (typeof raw !== "object" || raw === null) continue; + const entry = raw as LiteLlmEntry; + const input = finiteNumber(entry.input_cost_per_token); + const output = finiteNumber(entry.output_cost_per_token); + if (input === null || output === null) continue; + + // LiteLLM publishes the same model many times over with a routing prefix + // (`azure/gpt-5.6-sol`, `openrouter/...`), sometimes at different rates. + // They all normalise to one key, so the un-prefixed entry wins outright + // rather than whichever happened to be enumerated last. + const normalized = normalizeModelName(name); + const prefixed = name.includes("/"); + if (prefixed && table.has(normalized)) continue; + + table.set(normalized, { + inputCostPerToken: input, + outputCostPerToken: output, + // Anthropic bills cache reads at a discount and cache writes at a + // premium. When a model omits them, cached input is priced as plain input + // rather than as free. + cacheReadCostPerToken: finiteNumber(entry.cache_read_input_token_cost) ?? input, + cacheCreationCostPerToken: finiteNumber(entry.cache_creation_input_token_cost) ?? input, + }); + } + return table; +} + +/** + * Canonicalises a model name for lookup. + * + * Strips a `provider/` prefix (LiteLLM publishes both `claude-opus-4-5` and + * `anthropic/claude-opus-4-5`) and lowercases, since transcripts are + * inconsistent about casing. + */ +export function normalizeModelName(model: string): string { + const trimmed = model.trim().toLowerCase(); + const slash = trimmed.lastIndexOf("/"); + return slash === -1 ? trimmed : trimmed.slice(slash + 1); +} + +/** + * Models we never price, regardless of the table. + * + * `` marks locally generated messages that were never billed. Bare + * family names ("opus", "sonnet") are genuinely ambiguous across generations, so + * we report them as unpriced instead of guessing a generation. + */ +const UNPRICEABLE_MODELS = new Set([ + "", + "synthetic", + "opus", + "sonnet", + "haiku", + "fable", +]); + +export function lookupRate(table: RateTable, model: string): ModelRate | null { + const normalized = normalizeModelName(model); + if (normalized.length === 0 || UNPRICEABLE_MODELS.has(normalized)) return null; + return table.get(normalized) ?? null; +} + +export interface PricedUsage { + readonly costUsd: number; + readonly costSource: UsageCostSource; +} + +/** + * Prices one record's tokens. + * + * `reasoningTokens` is intentionally not charged separately: it is already + * counted inside `outputTokens`. + */ +export function priceUsage( + table: RateTable, + model: string, + totals: UsageTokenTotals, + reportedCostUsd: number | null, +): PricedUsage { + if (reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) { + return { costUsd: reportedCostUsd, costSource: "providerReported" }; + } + + const rate = lookupRate(table, model); + if (rate === null) return { costUsd: 0, costSource: "unpriced" }; + + const costUsd = + totals.uncachedInputTokens * rate.inputCostPerToken + + totals.cachedInputTokens * rate.cacheReadCostPerToken + + totals.cacheCreationTokens * rate.cacheCreationCostPerToken + + totals.outputTokens * rate.outputCostPerToken; + + return { costUsd, costSource: "modelPriced" }; +} + +/** + * What the cached input would have cost at full input rates, minus what it + * actually cost. Drives the "cache savings" figure. + */ +export function cacheSavingsUsd(table: RateTable, model: string, totals: UsageTokenTotals): number { + const rate = lookupRate(table, model); + if (rate === null) return 0; + return totals.cachedInputTokens * (rate.inputCostPerToken - rate.cacheReadCostPerToken); +} diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts new file mode 100644 index 000000000..3c5913eee --- /dev/null +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + decodeScanCache, + dedupeWithinFile, + encodeScanCache, + pruneScanCache, + type ScanCache, +} from "./usageScanCache.ts"; +import type { UsageRecord } from "./usageTranscripts.ts"; + +function record(overrides: Partial = {}): UsageRecord { + return { + provider: "claude", + timestampMs: 1_786_000_000_000, + model: "claude-fable-5", + sessionId: "session-a", + totals: { + uncachedInputTokens: 2, + cachedInputTokens: 1000, + cacheCreationTokens: 10, + outputTokens: 50, + reasoningTokens: 0, + }, + reportedCostUsd: null, + dedupeKey: "msg_1:req_1", + ...overrides, + }; +} + +function cacheWith(entries: readonly [string, number, readonly UsageRecord[]][]): ScanCache { + const cache: ScanCache = new Map(); + for (const [path, mtimeMs, records] of entries) { + cache.set(path, { size: records.length * 10, mtimeMs, provider: "claude", records }); + } + return cache; +} + +describe("scan cache round trip", () => { + it("restores records unchanged", () => { + const original = cacheWith([ + ["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:req_2", model: "claude-opus-5" })]], + ["/b.jsonl", 200, [record({ sessionId: "session-b", reportedCostUsd: 1.5 })]], + ]); + + const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original)))); + + expect(restored.get("/a.jsonl")).toEqual(original.get("/a.jsonl")); + expect(restored.get("/b.jsonl")).toEqual(original.get("/b.jsonl")); + }); + + it("interns repeated model and session strings", () => { + const encoded = encodeScanCache( + cacheWith([["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:req_2" }), record()]]]), + ); + + expect(encoded.models).toEqual(["claude-fable-5"]); + expect(encoded.sessions).toEqual(["session-a"]); + }); + + it("treats a corrupt or foreign document as an empty cache", () => { + // A bad cache should cost one cold scan, never a broken page. + expect(decodeScanCache(null).size).toBe(0); + expect(decodeScanCache("nonsense").size).toBe(0); + expect(decodeScanCache({ version: 999, models: [], sessions: [], files: {} }).size).toBe(0); + }); + + it("rejects the whole cache when an intern table holds a non-string", () => { + // models: [1] would pass the undefined guard, put a number in a record's + // model, and crash normalizeModelName at aggregate time. + const poisoned = { + ...encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])), + models: [1], + }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).size).toBe(0); + }); + + it("drops the whole entry when any row is corrupt, forcing a cold re-parse", () => { + // Keeping the surviving rows under the original (size, mtime) would read as + // a valid warm hit and the file would never be re-parsed. + const encoded = encodeScanCache( + cacheWith([ + ["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:req_2" })]], + ["/good.jsonl", 100, [record()]], + ]), + ); + const rows = encoded.files["/a.jsonl"]!.r; + const poisoned = { + ...encoded, + files: { + ...encoded.files, + "/a.jsonl": { + ...encoded.files["/a.jsonl"]!, + r: [rows[0]!, [...rows[1]!.slice(0, 3), "not-a-number", ...rows[1]!.slice(4)]], + }, + }, + }; + + const restored = decodeScanCache(JSON.parse(JSON.stringify(poisoned))); + expect([...restored.keys()]).toEqual(["/good.jsonl"]); + }); +}); + +describe("pruneScanCache", () => { + const retentionCutoffMs = 1000; + + it("drops entries older than retention", () => { + const cache = cacheWith([["/old.jsonl", 500, [record()]]]); + + const removed = pruneScanCache(cache, { + livePaths: new Set(), + walkedRoots: ["/"], + windowStartMs: 400, + retentionCutoffMs, + }); + + expect(removed).toBe(1); + expect(cache.size).toBe(0); + }); + + it("drops in-window entries whose file has disappeared but keeps the ones seen", () => { + const cache = cacheWith([ + ["/gone.jsonl", 5000, [record()]], + ["/live.jsonl", 5000, [record()]], + ]); + + pruneScanCache(cache, { + livePaths: new Set(["/live.jsonl"]), + walkedRoots: ["/"], + windowStartMs: 4000, + retentionCutoffMs, + }); + + expect([...cache.keys()]).toEqual(["/live.jsonl"]); + }); + + it("keeps entries outside the walked window that are still within retention", () => { + // Viewing 7 days must not evict the 90-day entries, which that walk never + // looked for and so cannot prove are gone. + const cache = cacheWith([["/older-but-valid.jsonl", 2000, [record()]]]); + + const removed = pruneScanCache(cache, { + livePaths: new Set(), + walkedRoots: ["/"], + windowStartMs: 4000, + retentionCutoffMs, + }); + + expect(removed).toBe(0); + }); + + it("keeps in-window entries for a provider whose directory was not walked", () => { + // A missing provider root or failed settings read leaves livePaths without + // that provider's files. Its warm entries must survive the pass. + const cache = cacheWith([["/codex/sessions/a.jsonl", 5000, [record()]]]); + + const removed = pruneScanCache(cache, { + livePaths: new Set(), + walkedRoots: ["/claude/projects"], + windowStartMs: 4000, + retentionCutoffMs, + }); + + expect(removed).toBe(0); + }); +}); + +describe("dedupeWithinFile", () => { + it("keeps the first record per dedupe key", () => { + const kept = dedupeWithinFile([ + record({ totals: { ...record().totals, outputTokens: 1 } }), + record({ totals: { ...record().totals, outputTokens: 999 } }), + record({ dedupeKey: "msg_2:req_2" }), + ]); + + expect(kept).toHaveLength(2); + expect(kept[0]?.totals.outputTokens).toBe(1); + }); + + it("keeps every record that has no dedupe key", () => { + // Codex records are unique per rollout and carry no key. + expect( + dedupeWithinFile([record({ dedupeKey: null }), record({ dedupeKey: null })]), + ).toHaveLength(2); + }); +}); diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts new file mode 100644 index 000000000..29de3fa14 --- /dev/null +++ b/apps/server/src/usage/usageScanCache.ts @@ -0,0 +1,254 @@ +/** + * Durable per-file scan cache. + * + * Transcripts are append-only and a file that has not changed can never yield + * different usage, so parsed records are keyed by `(size, mtime)` and reused. + * Without this every server restart re-parses the whole window. + * + * Caching *per file* rather than per day is deliberate. It is timezone + * independent, so changing the reporting zone does not invalidate anything, and + * it keeps cross-file de-duplication exact: cached entries are de-duplicated + * within their own file only, and the aggregator still applies the global + * dedupe pass over the small surviving key set. + * + * @module usageScanCache + */ +import type { UsageProviderKind } from "@threadlines/contracts"; + +import type { UsageRecord } from "./usageTranscripts.ts"; + +/** + * Bump whenever a parser change alters what a given file parses to. Stale + * entries would otherwise keep serving the old interpretation forever, since a + * file that has not changed is never re-read. + */ +export const USAGE_SCAN_CACHE_VERSION = 1 as const; + +export interface CachedFile { + readonly size: number; + readonly mtimeMs: number; + readonly provider: UsageProviderKind; + readonly records: readonly UsageRecord[]; +} + +export type ScanCache = Map; + +/** + * Row layout for the serialised form. Positional and interned rather than + * object-per-record: on a long window that is the difference between a cache + * file measured in tens of megabytes and one a fraction of that size. + */ +type SerializedRecord = readonly [ + timestampMs: number, + modelIndex: number, + sessionIndex: number, + uncachedInputTokens: number, + cachedInputTokens: number, + cacheCreationTokens: number, + outputTokens: number, + reasoningTokens: number, + dedupeKey: string | null, + reportedCostUsd: number | null, +]; + +interface SerializedFile { + readonly s: number; + readonly m: number; + readonly p: UsageProviderKind; + readonly r: readonly SerializedRecord[]; +} + +export interface SerializedCache { + readonly version: number; + readonly models: readonly string[]; + readonly sessions: readonly string[]; + readonly files: Readonly>; +} + +/** Serialises the cache, interning the repeated model and session strings. */ +export function encodeScanCache(cache: ScanCache): SerializedCache { + const models: string[] = []; + const sessions: string[] = []; + const modelIndex = new Map(); + const sessionIndex = new Map(); + + const intern = (table: string[], index: Map, value: string): number => { + const existing = index.get(value); + if (existing !== undefined) return existing; + const next = table.length; + table.push(value); + index.set(value, next); + return next; + }; + + const files: Record = {}; + for (const [path, entry] of cache) { + files[path] = { + s: entry.size, + m: entry.mtimeMs, + p: entry.provider, + r: entry.records.map((record) => [ + record.timestampMs, + intern(models, modelIndex, record.model), + intern(sessions, sessionIndex, record.sessionId), + record.totals.uncachedInputTokens, + record.totals.cachedInputTokens, + record.totals.cacheCreationTokens, + record.totals.outputTokens, + record.totals.reasoningTokens, + record.dedupeKey, + record.reportedCostUsd, + ]), + }; + } + + return { version: USAGE_SCAN_CACHE_VERSION, models, sessions, files }; +} + +function isArray(value: unknown): value is readonly unknown[] { + return Array.isArray(value); +} + +/** + * Rebuilds the cache from a parsed document. + * + * Anything malformed yields an empty cache rather than an error: a corrupt + * cache should cost one cold scan, never a broken page. + */ +export function decodeScanCache(document: unknown): ScanCache { + const cache: ScanCache = new Map(); + if (typeof document !== "object" || document === null) return cache; + + const root = document as Partial; + if (root.version !== USAGE_SCAN_CACHE_VERSION) return cache; + if (!isArray(root.models) || !isArray(root.sessions)) return cache; + if (typeof root.files !== "object" || root.files === null) return cache; + + // The intern tables must be all strings: a numeric entry would pass the + // undefined guard below, land in a record's model, and crash the aggregate at + // normalizeModelName. A corrupt table rejects the whole cache. + if (!root.models.every((value) => typeof value === "string")) return cache; + if (!root.sessions.every((value) => typeof value === "string")) return cache; + const models = root.models as readonly string[]; + const sessions = root.sessions as readonly string[]; + + for (const [path, raw] of Object.entries(root.files)) { + if (typeof raw !== "object" || raw === null) continue; + const entry = raw as Partial; + if (typeof entry.s !== "number" || typeof entry.m !== "number") continue; + if (entry.p !== "claude" && entry.p !== "codex") continue; + if (!isArray(entry.r)) continue; + + const provider: UsageProviderKind = entry.p; + const records: UsageRecord[] = []; + // Any corrupt row disqualifies the whole entry. Keeping the survivors under + // the original (size, mtime) would read as a valid warm hit and the file + // would never be re-parsed, silently losing the dropped rows' usage. + let corrupt = false; + for (const row of entry.r) { + if (!isArray(row) || row.length < 10) { + corrupt = true; + break; + } + const [ + timestampMs, + modelIndex, + sessionIndex, + uncached, + cached, + cacheCreation, + output, + reasoning, + dedupeKey, + reportedCostUsd, + ] = row as SerializedRecord; + + const model = typeof modelIndex === "number" ? models[modelIndex] : undefined; + if ( + typeof timestampMs !== "number" || + !Number.isFinite(timestampMs) || + model === undefined || + !Number.isFinite(uncached) || + !Number.isFinite(cached) || + !Number.isFinite(cacheCreation) || + !Number.isFinite(output) || + !Number.isFinite(reasoning) + ) { + corrupt = true; + break; + } + + records.push({ + provider, + timestampMs, + model, + sessionId: (typeof sessionIndex === "number" ? sessions[sessionIndex] : undefined) ?? "", + totals: { + uncachedInputTokens: uncached, + cachedInputTokens: cached, + cacheCreationTokens: cacheCreation, + outputTokens: output, + reasoningTokens: reasoning, + }, + reportedCostUsd: typeof reportedCostUsd === "number" ? reportedCostUsd : null, + dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null, + }); + } + + if (corrupt) continue; + cache.set(path, { size: entry.s, mtimeMs: entry.m, provider, records }); + } + + return cache; +} + +export interface PruneOptions { + /** Files the walk just saw. Only meaningful inside the walked window. */ + readonly livePaths: ReadonlySet; + /** + * Roots the walk actually completed. Absence from `livePaths` only proves a + * file is gone when its root was walked: a provider whose directory failed to + * resolve this pass must not have its warm entries purged. + */ + readonly walkedRoots: readonly string[]; + /** Start of the walked window; entries older than this were not looked for. */ + readonly windowStartMs: number; + /** Entries older than this are dropped regardless. */ + readonly retentionCutoffMs: number; +} + +/** + * Drops aged-out entries, and entries for files that have disappeared. + * + * The walk only covers the requested window, so absence from `livePaths` only + * proves deletion for entries *inside* that window. Pruning everything the walk + * missed would evict the 90-day entries every time someone looked at 7 days. + */ +export function pruneScanCache(cache: ScanCache, options: PruneOptions): number { + let removed = 0; + for (const [path, entry] of cache) { + const agedOut = entry.mtimeMs < options.retentionCutoffMs; + const underWalkedRoot = options.walkedRoots.some((root) => path.startsWith(root)); + const deleted = + underWalkedRoot && entry.mtimeMs >= options.windowStartMs && !options.livePaths.has(path); + if (agedOut || deleted) { + cache.delete(path); + removed += 1; + } + } + return removed; +} + +/** Within-file de-duplication, applied before an entry is cached. */ +export function dedupeWithinFile(records: readonly UsageRecord[]): readonly UsageRecord[] { + const seen = new Set(); + const kept: UsageRecord[] = []; + for (const record of records) { + if (record.dedupeKey !== null) { + if (seen.has(record.dedupeKey)) continue; + seen.add(record.dedupeKey); + } + kept.push(record); + } + return kept; +} diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts new file mode 100644 index 000000000..e4ba441ad --- /dev/null +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -0,0 +1,142 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Raw filesystem access for transcript scanning. + * + * Isolated here so the rest of the usage code stays on Effect's `FileSystem`. + * The direct `node:fs` streaming is deliberate: a cold 90-day window is on the + * order of a gigabyte across thousands of files, and `readline` over a read + * stream is roughly an order of magnitude cheaper than materialising each file. + * The equivalent Effect stream pipeline is idiomatic but not fast enough to sit + * behind a page load. + * + * @module usageTranscriptReader + */ +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeReadline from "node:readline"; + +import type { UsageProviderKind } from "@threadlines/contracts"; + +import { + initialCodexScanState, + mightCarryUsage, + parseClaudeLine, + parseCodexLine, + type UsageRecord, +} from "./usageTranscripts.ts"; + +export interface TranscriptFile { + readonly path: string; + readonly size: number; + readonly mtimeMs: number; +} + +/** + * Lists `.jsonl` transcripts under `root` last modified at or after `sinceMs`. + * + * Errors on individual entries are swallowed: session files rotate and get + * removed while the walk is in flight, and a partial listing is far better than + * failing the page. + */ +export async function listTranscriptFiles( + root: string, + sinceMs: number, +): Promise { + const found: TranscriptFile[] = []; + + const walk = async (dir: string): Promise => { + let entries; + try { + entries = await NodeFSP.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const child = NodePath.join(dir, entry.name); + if (entry.isDirectory()) { + await walk(child); + continue; + } + if (!entry.name.endsWith(".jsonl")) continue; + try { + const stats = await NodeFSP.stat(child); + if (stats.mtimeMs >= sinceMs) { + found.push({ path: child, size: stats.size, mtimeMs: stats.mtimeMs }); + } + } catch { + // Vanished between readdir and stat. + } + } + }; + + await walk(root); + return found; +} + +/** + * Filesystem identity of a directory, as `device:inode`. + * + * Used to tell "two servers reading the same transcript directory" apart from + * "two machines whose hostname and home path happen to match". Returns an empty + * string when the directory cannot be stat'd. + */ +export async function readDirectoryVolumeId(path: string): Promise { + try { + const stats = await NodeFSP.stat(path); + return `${stats.dev}:${stats.ino}`; + } catch { + return ""; + } +} + +/** + * Streams one transcript and returns the usage records it contains, or `null` + * when the file could not be read. + * + * The distinction matters to the caller's cache: a genuinely empty transcript + * is a stable fact worth memoising, while a transient read failure memoised + * under the same `(size, mtime)` key would silently drop that file's usage + * until the file next changes. + * + * Codex carries the active model on `turn_context` lines and its fork marker on + * `session_meta`, neither of which holds usage of its own, so those still have + * to pass through the reducer to keep attribution and fork suppression correct. + */ +export async function readTranscriptRecords( + filePath: string, + provider: UsageProviderKind, +): Promise { + const records: UsageRecord[] = []; + const codexState = initialCodexScanState(); + + try { + const lines = NodeReadline.createInterface({ + input: NodeFS.createReadStream(filePath, { encoding: "utf8" }), + crlfDelay: Infinity, + }); + + for await (const line of lines) { + if (provider === "codex") { + if ( + !mightCarryUsage(line, provider) && + !line.includes('"turn_context"') && + !line.includes('"session_meta"') + ) { + continue; + } + const record = parseCodexLine(line, codexState); + if (record !== null) records.push(record); + continue; + } + + if (!mightCarryUsage(line, provider)) continue; + const record = parseClaudeLine(line); + if (record !== null) records.push(record); + } + } catch { + return null; + } + + return records; +} diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts new file mode 100644 index 000000000..48bc60477 --- /dev/null +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + initialCodexScanState, + parseClaudeLine, + parseCodexLine, + totalTokens, +} from "./usageTranscripts.ts"; + +/** Shaped after a real Claude Code assistant record. */ +function claudeLine(overrides: { + messageId: string; + contentType: string; + requestId?: string; + model?: string; + outputTokens?: number; +}): string { + return JSON.stringify({ + type: "assistant", + timestamp: "2026-08-10T03:50:03.336Z", + sessionId: "bf5b7ee7-6a3b-41b2-b47c-ba3ef4375b46", + requestId: overrides.requestId ?? "req_011CdtPMPBEdPXGLpcmo3hze", + cwd: "/Users/will/badcode", + message: { + id: overrides.messageId, + role: "assistant", + model: overrides.model ?? "claude-fable-5", + content: [{ type: overrides.contentType }], + usage: { + input_tokens: 2, + cache_creation_input_tokens: 15501, + cache_read_input_tokens: 17070, + output_tokens: overrides.outputTokens ?? 499, + service_tier: "standard", + cache_creation: { ephemeral_1h_input_tokens: 15501, ephemeral_5m_input_tokens: 0 }, + }, + }, + }); +} + +describe("parseClaudeLine", () => { + it("extracts token totals and a dedupe key", () => { + const record = parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text" })); + + expect(record?.provider).toBe("claude"); + expect(record?.model).toBe("claude-fable-5"); + expect(record?.sessionId).toBe("bf5b7ee7-6a3b-41b2-b47c-ba3ef4375b46"); + expect(record?.totals).toEqual({ + uncachedInputTokens: 2, + cachedInputTokens: 17070, + cacheCreationTokens: 15501, + outputTokens: 499, + reasoningTokens: 0, + }); + expect(record?.dedupeKey).toBe("msg_1:req_011CdtPMPBEdPXGLpcmo3hze"); + }); + + it("gives every content block of one message the same dedupe key", () => { + // The CLI writes one record per content block, each repeating the parent + // message's full usage. Summing them would overcount several times over. + const text = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "text" })); + const toolUse = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "tool_use" })); + + expect(text?.dedupeKey).toBe(toolUse?.dedupeKey); + expect(text?.totals).toEqual(toolUse?.totals); + }); + + it("reads a reported cost only when the CLI wrote one", () => { + const withoutCost = parseClaudeLine(claudeLine({ messageId: "msg_3", contentType: "text" })); + const withCost = parseClaudeLine( + JSON.stringify({ + ...(JSON.parse(claudeLine({ messageId: "msg_3", contentType: "text" })) as object), + costUSD: 0.42, + }), + ); + + expect(withoutCost?.reportedCostUsd).toBeNull(); + expect(withCost?.reportedCostUsd).toBe(0.42); + }); + + it("ignores records that are not assistant messages", () => { + expect(parseClaudeLine(JSON.stringify({ type: "user", message: {} }))).toBeNull(); + expect(parseClaudeLine("not json")).toBeNull(); + }); +}); + +describe("parseCodexLine", () => { + const sessionMeta = JSON.stringify({ + type: "session_meta", + timestamp: "2026-08-10T04:41:36.122Z", + payload: { + session_id: "019fe9f9-df22-7720-8727-262a9a10e5e9", + id: "019fe9f9-df22-7720-8727-262a9a10e5e9", + // Ordinary rollouts carry `source` as a plain string and a null fork id. + source: "vscode", + forked_from_id: null, + }, + }); + // `turn_context` payloads carry `model` at the top level and no payload type. + const turnContext = JSON.stringify({ + type: "turn_context", + timestamp: "2026-08-10T04:41:38.694Z", + payload: { model: "gpt-5.6-sol", cwd: "/Users/will/badcode" }, + }); + const tokenCount = (inputTokens: number, cached: number, output: number, reasoning: number) => + JSON.stringify({ + type: "event_msg", + timestamp: "2026-08-10T04:41:46.840Z", + payload: { + type: "token_count", + info: { + total_token_usage: { input_tokens: inputTokens, output_tokens: output }, + last_token_usage: { + input_tokens: inputTokens, + cached_input_tokens: cached, + cache_write_input_tokens: 0, + output_tokens: output, + reasoning_output_tokens: reasoning, + }, + }, + }, + }); + + it("attributes usage to the model from the preceding turn context", () => { + const state = initialCodexScanState(); + parseCodexLine(sessionMeta, state); + parseCodexLine(turnContext, state); + const record = parseCodexLine(tokenCount(20642, 11008, 420, 169), state); + + expect(record?.provider).toBe("codex"); + expect(record?.model).toBe("gpt-5.6-sol"); + expect(record?.sessionId).toBe("019fe9f9-df22-7720-8727-262a9a10e5e9"); + // Codex reports input_tokens inclusive of the cached portion. + expect(record?.totals.uncachedInputTokens).toBe(20642 - 11008); + expect(record?.totals.cachedInputTokens).toBe(11008); + expect(record?.totals.reasoningTokens).toBe(169); + }); + + it("skips a repeated token_count so deltas are not double counted", () => { + const state = initialCodexScanState(); + parseCodexLine(turnContext, state); + const first = parseCodexLine(tokenCount(100, 0, 10, 0), state); + const repeat = parseCodexLine(tokenCount(100, 0, 10, 0), state); + + expect(first).not.toBeNull(); + expect(repeat).toBeNull(); + }); + + it("does not let a pre-model event poison the duplicate signature", () => { + // A token_count before its turn_context is dropped; the identical event + // re-emitted once the model is known must still be counted. + const state = initialCodexScanState(); + expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull(); + parseCodexLine(turnContext, state); + expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).not.toBeNull(); + }); + + // A forked or subagent rollout opens with the parent's history copied in and + // every line re-stamped to the fork instant, then the ancestors' session + // metas. Threadlines forks Codex threads natively, so counting those copies + // again would multiply the prefix turns by the number of forks taken. + describe("forked rollouts", () => { + const meta = (overrides: { + id: string; + timestamp: string; + forkedFromId?: string; + spawnParentId?: string; + }) => + JSON.stringify({ + type: "session_meta", + timestamp: overrides.timestamp, + payload: { + id: overrides.id, + forked_from_id: overrides.forkedFromId ?? null, + ...(overrides.spawnParentId === undefined + ? { source: "vscode" } + : { + source: { + subagent: { thread_spawn: { parent_thread_id: overrides.spawnParentId } }, + }, + }), + }, + }); + const stamped = (timestamp: string, line: string) => { + const parsed = JSON.parse(line) as { timestamp: string }; + parsed.timestamp = timestamp; + return JSON.stringify(parsed); + }; + + it("keeps the child session id over copied ancestor metas", () => { + const state = initialCodexScanState(); + parseCodexLine(meta({ id: "child", timestamp: "2026-07-21T05:11:54.120Z" }), state); + parseCodexLine(meta({ id: "parent", timestamp: "2026-07-21T05:11:54.121Z" }), state); + parseCodexLine(turnContext, state); + const record = parseCodexLine(tokenCount(100, 0, 10, 0), state); + + expect(record?.sessionId).toBe("child"); + }); + + it("drops the re-stamped copied burst and keeps the first real event", () => { + // Timings taken from a real Threadlines Codex fork: the copied prefix + // lands within a millisecond of the fork, the child's own turn 2.7s later. + const state = initialCodexScanState(); + const forkInstant = "2026-07-21T05:11:54.120Z"; + parseCodexLine(meta({ id: "child", timestamp: forkInstant, forkedFromId: "parent" }), state); + parseCodexLine(meta({ id: "parent", timestamp: "2026-07-21T05:11:54.121Z" }), state); + parseCodexLine(stamped("2026-07-21T05:11:54.121Z", turnContext), state); + + expect( + parseCodexLine(stamped("2026-07-21T05:11:54.121Z", tokenCount(17821, 1408, 20, 0)), state), + ).toBeNull(); + expect( + parseCodexLine(stamped("2026-07-21T05:11:54.140Z", tokenCount(17830, 1408, 25, 0)), state), + ).toBeNull(); + + const real = parseCodexLine( + stamped("2026-07-21T05:11:56.826Z", tokenCount(17859, 1408, 11, 0)), + state, + ); + expect(real?.totals.outputTokens).toBe(11); + + // Suppression never restarts, even for closely spaced later events. + expect( + parseCodexLine(stamped("2026-07-21T05:11:56.926Z", tokenCount(17900, 1408, 40, 0)), state), + ).not.toBeNull(); + }); + + it("recognizes subagent spawns without forked_from_id", () => { + const state = initialCodexScanState(); + const spawnInstant = "2026-08-01T20:36:12.311Z"; + parseCodexLine( + meta({ id: "child", timestamp: spawnInstant, spawnParentId: "parent" }), + state, + ); + parseCodexLine(stamped(spawnInstant, turnContext), state); + expect( + parseCodexLine(stamped("2026-08-01T20:36:12.312Z", tokenCount(100, 0, 10, 0)), state), + ).toBeNull(); + }); + + it("does not suppress anything in a rollout that is not a fork", () => { + const state = initialCodexScanState(); + parseCodexLine(meta({ id: "root", timestamp: "2026-08-01T20:36:12.311Z" }), state); + parseCodexLine(stamped("2026-08-01T20:36:12.411Z", turnContext), state); + const record = parseCodexLine( + stamped("2026-08-01T20:36:12.511Z", tokenCount(100, 0, 10, 0)), + state, + ); + + expect(record).not.toBeNull(); + }); + }); +}); + +describe("totalTokens", () => { + it("does not add reasoning on top of output", () => { + expect( + totalTokens({ + uncachedInputTokens: 10, + cachedInputTokens: 20, + cacheCreationTokens: 30, + outputTokens: 40, + reasoningTokens: 25, + }), + ).toBe(100); + }); +}); diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts new file mode 100644 index 000000000..d4aea728a --- /dev/null +++ b/apps/server/src/usage/usageTranscripts.ts @@ -0,0 +1,313 @@ +/** + * Pure parsers for the provider CLIs' on-disk session transcripts. + * + * Both parsers are line-at-a-time reducers so callers can stream large files + * without materialising them. Neither touches the filesystem. + * + * @module usageTranscripts + */ +import type { UsageProviderKind, UsageTokenTotals } from "@threadlines/contracts"; + +export interface UsageRecord { + readonly provider: UsageProviderKind; + readonly timestampMs: number; + readonly model: string; + readonly sessionId: string; + readonly totals: UsageTokenTotals; + readonly reportedCostUsd: number | null; + /** + * Key for cross-file de-duplication, or `null` when the record is inherently + * unique and needs no dedup. + */ + readonly dedupeKey: string | null; +} + +export const EMPTY_TOTALS: UsageTokenTotals = { + uncachedInputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, +}; + +function int(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; +} + +function parseTimestampMs(value: unknown): number | null { + if (typeof value !== "string") return null; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; +} + +export function addTotals(a: UsageTokenTotals, b: UsageTokenTotals): UsageTokenTotals { + return { + uncachedInputTokens: a.uncachedInputTokens + b.uncachedInputTokens, + cachedInputTokens: a.cachedInputTokens + b.cachedInputTokens, + cacheCreationTokens: a.cacheCreationTokens + b.cacheCreationTokens, + outputTokens: a.outputTokens + b.outputTokens, + reasoningTokens: a.reasoningTokens + b.reasoningTokens, + }; +} + +export function totalTokens(totals: UsageTokenTotals): number { + // reasoningTokens is a subset of outputTokens and must not be added again. + return ( + totals.uncachedInputTokens + + totals.cachedInputTokens + + totals.cacheCreationTokens + + totals.outputTokens + ); +} + +/** + * Cheap substring gate applied before `JSON.parse`. + * + * Transcripts are mostly tool output; only a minority of lines carry usage, and + * the lines that do not are frequently the largest. Skipping them without + * parsing is worth roughly an order of magnitude on a 30-day window. + */ +export function mightCarryUsage(line: string, provider: UsageProviderKind): boolean { + return provider === "claude" ? line.includes('"usage"') : line.includes('"token_count"'); +} + +/* -------------------------------------------------------------------------- */ +/* Claude Code */ +/* -------------------------------------------------------------------------- */ + +/** + * Parses one line of a Claude Code transcript. + * + * The CLI writes one record per assistant *content block*, and every one of + * those records repeats the same complete `usage` object for the parent + * message. Summing them overcounts badly, so the caller must drop repeats by + * `dedupeKey` and keep the first. + */ +export function parseClaudeLine(line: string): UsageRecord | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + + const record = parsed as Record; + if (record["type"] !== "assistant") return null; + + const message = record["message"]; + if (typeof message !== "object" || message === null) return null; + const messageRecord = message as Record; + + const usage = messageRecord["usage"]; + if (typeof usage !== "object" || usage === null) return null; + const usageRecord = usage as Record; + + const timestampMs = parseTimestampMs(record["timestamp"]); + if (timestampMs === null) return null; + + const model = typeof messageRecord["model"] === "string" ? messageRecord["model"] : ""; + if (model.length === 0) return null; + + const messageId = typeof messageRecord["id"] === "string" ? messageRecord["id"] : null; + const requestId = typeof record["requestId"] === "string" ? record["requestId"] : null; + // Matches ccusage: prefer the message/request pair, fall back to whichever + // half exists. Records with neither cannot be de-duplicated. + const dedupeKey = + messageId === null && requestId === null ? null : `${messageId ?? ""}:${requestId ?? ""}`; + + // Older CLI builds wrote a `costUSD` per record; current ones do not. Read it + // when present rather than assuming either way. + const cost = record["costUSD"]; + + return { + provider: "claude", + timestampMs, + model, + sessionId: typeof record["sessionId"] === "string" ? record["sessionId"] : "", + totals: { + uncachedInputTokens: int(usageRecord["input_tokens"]), + cachedInputTokens: int(usageRecord["cache_read_input_tokens"]), + // The nested `cache_creation` object splits this into 5m and 1h ephemeral + // buckets, which bill at different multipliers. The rate table publishes + // one cache-write rate, so the split would not change the arithmetic; + // the flat total is what we price. + cacheCreationTokens: int(usageRecord["cache_creation_input_tokens"]), + outputTokens: int(usageRecord["output_tokens"]), + // Anthropic folds thinking tokens into output and does not break them out. + reasoningTokens: 0, + }, + reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null, + dedupeKey, + }; +} + +/* -------------------------------------------------------------------------- */ +/* Codex */ +/* -------------------------------------------------------------------------- */ + +/** + * Rolling state for a single Codex rollout file. + * + * Codex `token_count` events carry no model, so the model is carried forward + * from the most recent `turn_context`. Sessions that switch models mid-run + * attribute correctly from the switch onward. + */ +export interface CodexScanState { + model: string; + sessionId: string; + lastUsageSignature: string | null; + sawSessionMeta: boolean; + /** While true, leading usage events are re-stamped copies of parent history. */ + suppressingForkCopies: boolean; + forkCopyAnchorMs: number; +} + +export function initialCodexScanState(): CodexScanState { + return { + model: "", + sessionId: "", + lastUsageSignature: null, + sawSessionMeta: false, + suppressingForkCopies: false, + forkCopyAnchorMs: 0, + }; +} + +/** + * A forked or subagent rollout opens with the parent's full history copied in, + * every line re-stamped to the fork instant. Those copies are written in one + * synchronous burst (observed gaps 0-40ms in real Threadlines forks), while the + * child's first genuine usage event only lands after a real model turn + * (observed seconds). One second of separation splits the two cleanly; + * `ccusage` uses the same threshold. + * + * This matters more for Threadlines than for a plain CLI install: forking a + * Codex thread is a first-class feature here, so forked rollouts are common and + * naive summing would multiply their prefix turns by the fork count. + */ +const FORK_COPY_MAX_GAP_MS = 1000; + +/** Whether a `session_meta` payload marks the rollout as a fork or subagent. */ +function isForkedSessionMeta(payload: Record): boolean { + // Present but `null` on ordinary rollouts, so the type check is load-bearing. + if (typeof payload["forked_from_id"] === "string") return true; + const source = payload["source"]; + // `source` is a plain string ("vscode", "cli") on ordinary rollouts. + if (typeof source !== "object" || source === null) return false; + const subagent = (source as Record)["subagent"]; + if (typeof subagent !== "object" || subagent === null) return false; + const spawn = (subagent as Record)["thread_spawn"]; + if (typeof spawn !== "object" || spawn === null) return false; + return typeof (spawn as Record)["parent_thread_id"] === "string"; +} + +/** + * Feeds one line of a Codex rollout into `state`, returning a record when the + * line was a usage event. + * + * Deltas come from `last_token_usage`. Summing those across a session + * reconciles with the session's final `total_token_usage`, provided consecutive + * duplicate events are dropped, which this does. + */ +export function parseCodexLine(line: string, state: CodexScanState): UsageRecord | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + + const record = parsed as Record; + const payload = record["payload"]; + if (typeof payload !== "object" || payload === null) return null; + const payloadRecord = payload as Record; + const payloadType = payloadRecord["type"]; + + if (record["type"] === "session_meta") { + // Only the first meta describes this file's own session. A forked rollout + // repeats the ancestors' metas right after it; letting those through would + // reassign every subsequent record to an ancestor session. + if (state.sawSessionMeta) return null; + state.sawSessionMeta = true; + const id = payloadRecord["id"] ?? payloadRecord["session_id"]; + if (typeof id === "string") state.sessionId = id; + const metaTimestampMs = parseTimestampMs(record["timestamp"]); + if (metaTimestampMs !== null && isForkedSessionMeta(payloadRecord)) { + state.suppressingForkCopies = true; + state.forkCopyAnchorMs = metaTimestampMs; + } + return null; + } + + if (record["type"] === "turn_context") { + // `turn_context` payloads carry `model` at the top level and no `type` of + // their own, so the envelope type is the only reliable discriminator. + if (typeof payloadRecord["model"] === "string") state.model = payloadRecord["model"]; + return null; + } + + if (payloadType !== "token_count") return null; + + const info = payloadRecord["info"]; + if (typeof info !== "object" || info === null) return null; + const last = (info as Record)["last_token_usage"]; + if (typeof last !== "object" || last === null) return null; + const lastRecord = last as Record; + + // Only an event that is otherwise eligible may consume the duplicate + // signature. A token_count arriving before its turn_context (no model yet) + // must not poison it, or the re-emitted copy after the model is known would + // be skipped as a duplicate and those tokens never counted. + const timestampMs = parseTimestampMs(record["timestamp"]); + if (timestampMs === null) return null; + if (state.model.length === 0) return null; + + // Codex re-emits an unchanged token_count on some stream boundaries. Summing + // those would double count, so identical consecutive payloads are skipped. + const signature = JSON.stringify(lastRecord); + if (signature === state.lastUsageSignature) return null; + state.lastUsageSignature = signature; + + // In a forked rollout the copied parent history was already counted from the + // parent's own file. Drop the leading burst; the first usage event separated + // from its predecessor by a real turn's worth of time ends it for good. + if (state.suppressingForkCopies) { + if (timestampMs - state.forkCopyAnchorMs < FORK_COPY_MAX_GAP_MS) { + state.forkCopyAnchorMs = timestampMs; + return null; + } + state.suppressingForkCopies = false; + } + + const inputTokens = int(lastRecord["input_tokens"]); + const cachedInputTokens = int(lastRecord["cached_input_tokens"]); + const cacheCreationTokens = int(lastRecord["cache_write_input_tokens"]); + const outputTokens = int(lastRecord["output_tokens"]); + + const totals: UsageTokenTotals = { + // Codex reports `input_tokens` inclusive of the cached portion. + uncachedInputTokens: Math.max(0, inputTokens - cachedInputTokens - cacheCreationTokens), + cachedInputTokens, + cacheCreationTokens, + outputTokens, + // Reported inside output_tokens, surfaced separately for the token mix. + reasoningTokens: Math.min(outputTokens, int(lastRecord["reasoning_output_tokens"])), + }; + + if (totalTokens(totals) === 0) return null; + + return { + provider: "codex", + timestampMs, + model: state.model, + sessionId: state.sessionId, + totals, + // Codex does not report cost in the rollout. + reportedCostUsd: null, + // Events surviving the fork-copy suppression above are unique to this + // rollout, so they need no global dedup. + dedupeKey: null, + }; +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c83205fc3..154f3f264 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -68,6 +68,7 @@ import { coalesceLatestAggregateEvents } from "./orchestration/shellStreamCoales import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { ThreadSearch } from "./orchestration/Services/ThreadSearch.ts"; +import { UsageService } from "./usage/UsageService.ts"; import { observeRpcEffect, observeRpcStream, @@ -233,6 +234,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => Effect.gen(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const threadSearch = yield* ThreadSearch; + const usage = yield* UsageService; const orchestrationEngine = yield* OrchestrationEngineService; const checkpointDiffQuery = yield* CheckpointDiffQuery; const checkpointRevert = yield* CheckpointRevert; @@ -1416,6 +1418,10 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => "rpc.aggregate": "server", }, ), + [WS_METHODS.usageSummary]: (input) => + observeRpcEffect(WS_METHODS.usageSummary, usage.readSummary(input), { + "rpc.aggregate": "usage", + }), [WS_METHODS.sourceControlLookupRepository]: (input) => observeRpcEffect( WS_METHODS.sourceControlLookupRepository, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index ef4d1a56b..0ec109393 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -24,6 +24,7 @@ import { CornerLeftUpIcon, FolderIcon, FolderPlusIcon, + GaugeIcon, HomeIcon, LinkIcon, MessageSquareIcon, @@ -1615,6 +1616,17 @@ function OpenCommandPaletteDialog() { }); } + actionItems.push({ + kind: "action", + value: "action:usage", + searchTerms: ["usage", "tokens", "cost", "spend", "spending", "price", "billing"], + title: "Open usage", + icon: , + run: async () => { + await navigate({ to: "/usage" }); + }, + }); + actionItems.push({ kind: "action", value: "action:settings", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c6484077c..ecb6a4e00 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -117,6 +117,7 @@ import { SidebarHoverCardGroup } from "./sidebar/hoverCard"; import { ThreadHoverCardProvider } from "./sidebar/ThreadHoverCard"; import { resolveThreadActionProjectRef, startNewGeneralChatThread } from "../lib/chatThreadActions"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; +import { SidebarUsageMeter } from "./sidebar/SidebarUsageMeter"; import { SidebarVersionTag } from "./sidebar/SidebarVersionTag"; import { readEnvironmentApi } from "../environmentApi"; import { useSettings } from "~/hooks/useSettings"; @@ -320,6 +321,9 @@ const SidebarChromeFooter = memo(function SidebarChromeFooter() { + + + setIsExpanded(event.currentTarget.open)} > - + { + event.preventDefault(); + setIsExpanded((expanded) => !expanded); + }} + > Advanced: headless chat token diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 1eabb8c63..22fad5891 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -17,12 +17,13 @@ import { type ServerProvider, type SourceControlDiscoveryResult, } from "@threadlines/contracts"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; import { page } from "vite-plus/test/browser"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { render } from "vitest-browser-react"; -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { RouterProvider, createMemoryHistory, @@ -40,6 +41,23 @@ import { DiagnosticsSettingsPanel } from "./DiagnosticsSettings"; import { GeneralSettingsPanel, ProviderSettingsPanel } from "./SettingsPanels"; import { SourceControlSettingsPanel } from "./SourceControlSettings"; +/** + * The app-wide providers these panels are always mounted under. Settings rows + * read cached data (usage, updates), so a bare render would crash on the + * missing query client rather than on anything the test is about. + */ +function TestAppProviders({ children }: { children: ReactNode }) { + const [queryClient] = useState( + () => new QueryClient({ defaultOptions: { queries: { retry: false } } }), + ); + + return ( + + {children} + + ); +} + function renderWithTestRouter(children: ReactNode) { const rootRoute = createRootRoute({ component: () => children, @@ -52,7 +70,6 @@ function renderWithTestRouter(children: ReactNode) { routeTree: rootRoute.addChildren([indexRoute]), history: createMemoryHistory({ initialEntries: ["/"] }), }); - return render(); } @@ -787,10 +804,10 @@ describe("GeneralSettingsPanel observability", () => { }); vi.stubGlobal("fetch", fetchMock); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await expect.element(page.getByText("Connect your phone or tablet")).toBeInTheDocument(); @@ -835,10 +852,10 @@ describe("GeneralSettingsPanel observability", () => { }); setServerConfigSnapshot(createBaseServerConfig()); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await page.getByRole("button", { name: "Create link", exact: true }).click(); @@ -875,10 +892,10 @@ describe("GeneralSettingsPanel observability", () => { }); setServerConfigSnapshot(createBaseServerConfig()); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await vi.waitFor(() => { @@ -915,10 +932,10 @@ describe("GeneralSettingsPanel observability", () => { }); setServerConfigSnapshot(createBaseServerConfig()); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await page.getByRole("button", { name: "Create link", exact: true }).click(); @@ -964,10 +981,10 @@ describe("GeneralSettingsPanel observability", () => { }); setServerConfigSnapshot(createBaseServerConfig()); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await page.getByRole("button", { name: "Create link", exact: true }).click(); @@ -1001,10 +1018,10 @@ describe("GeneralSettingsPanel observability", () => { }); setServerConfigSnapshot(createBaseServerConfig()); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await page.getByRole("button", { name: "Create link", exact: true }).click(); @@ -1075,10 +1092,10 @@ describe("GeneralSettingsPanel observability", () => { }); setServerConfigSnapshot(createBaseServerConfig()); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await expect @@ -1168,10 +1185,10 @@ describe("GeneralSettingsPanel observability", () => { }); setServerConfigSnapshot(createBaseServerConfig()); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await expect.element(page.getByText("http://192.168.86.39:3773/")).toBeInTheDocument(); @@ -1194,9 +1211,9 @@ describe("GeneralSettingsPanel observability", () => { setServerConfigSnapshot(createBaseServerConfig()); mounted = await renderWithTestRouter( - + - , + , ); await expect.element(page.getByText("About")).toBeInTheDocument(); @@ -1217,9 +1234,9 @@ describe("GeneralSettingsPanel observability", () => { setServerConfigSnapshot(createBaseServerConfig()); mounted = await renderWithTestRouter( - + - , + , ); await expect @@ -1252,9 +1269,9 @@ describe("GeneralSettingsPanel observability", () => { setServerConfigSnapshot(createBaseServerConfig()); mounted = await renderWithTestRouter( - + - , + , ); const analyticsSwitch = page.getByRole("switch", { @@ -1364,10 +1381,10 @@ describe("GeneralSettingsPanel observability", () => { setServerConfigSnapshot(createBaseServerConfig()); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await expect.element(page.getByText("Connected devices")).toBeInTheDocument(); @@ -1457,10 +1474,10 @@ describe("GeneralSettingsPanel observability", () => { setServerConfigSnapshot(createBaseServerConfig()); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await expect.element(page.getByText("Julius iPhone")).toBeInTheDocument(); @@ -1523,10 +1540,10 @@ describe("GeneralSettingsPanel observability", () => { setServerConfigSnapshot(createBaseServerConfig()); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await expect.element(page.getByText("Loading devices...")).toBeInTheDocument(); @@ -1552,10 +1569,10 @@ describe("GeneralSettingsPanel observability", () => { setServerConfigSnapshot(createBaseServerConfig()); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); const networkAccessToggle = page.getByLabelText("Allow phone and tablet access"); @@ -1606,10 +1623,10 @@ describe("GeneralSettingsPanel observability", () => { setServerConfigSnapshot(createBaseServerConfig()); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await page.getByRole("button", { name: "Add computer", exact: true }).click(); @@ -1699,10 +1716,10 @@ describe("GeneralSettingsPanel observability", () => { setServerConfigSnapshot(createBaseServerConfig()); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); const openLogsButton = page.getByLabelText("Open logs folder"); @@ -1714,10 +1731,10 @@ describe("GeneralSettingsPanel observability", () => { it("shows Claude configuration fields in provider settings", async () => { setServerConfigSnapshot(createBaseServerConfig()); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await page.getByLabelText("Toggle Claude details").click(); @@ -1754,10 +1771,10 @@ describe("GeneralSettingsPanel observability", () => { ], }); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await expect.element(page.getByText(/Credential configured ยท Claude Max/)).toBeInTheDocument(); @@ -1779,10 +1796,10 @@ describe("GeneralSettingsPanel observability", () => { ], }); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await page.getByLabelText("Toggle Claude details").click(); @@ -1839,10 +1856,10 @@ describe("GeneralSettingsPanel observability", () => { providers: [createCodexProviderWithResetCredits()], }); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await page.getByLabelText("Choose a reset credit for Codex usage").click(); @@ -1874,10 +1891,10 @@ describe("GeneralSettingsPanel observability", () => { providers: [createClaudeProvider()], }); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await page.getByLabelText("Toggle Claude details").click(); @@ -1916,10 +1933,10 @@ describe("GeneralSettingsPanel observability", () => { providers: [createClaudeProvider()], }); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await page.getByLabelText("Toggle Claude details").click(); @@ -1953,10 +1970,10 @@ describe("GeneralSettingsPanel observability", () => { providers: [createOutdatedProvider("codex")], }); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await page.getByRole("button", { name: "Update available โ€” view details" }).click(); @@ -1988,10 +2005,10 @@ describe("GeneralSettingsPanel observability", () => { providers: [createMissingClaudeProvider({ canInstall: true })], }); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); // The button replaces the manual recipe; the diagnosis sentence stays. @@ -2037,10 +2054,10 @@ describe("GeneralSettingsPanel observability", () => { ], }); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await expect.element(page.getByText("Installingโ€ฆ added 1 package")).toBeVisible(); @@ -2065,10 +2082,10 @@ describe("GeneralSettingsPanel observability", () => { providers: [createMissingClaudeProvider()], }); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); // No derived install command: the full guide sentence and its link stay. @@ -2099,10 +2116,10 @@ describe("GeneralSettingsPanel observability", () => { providers: [createVerifiedNativeOutdatedClaudeProvider()], }); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await page.getByRole("button", { name: "Update available โ€” view details" }).click(); @@ -2158,10 +2175,10 @@ describe("GeneralSettingsPanel observability", () => { providers: [provider], }); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await page.getByRole("button", { name: "Update available โ€” view details" }).click(); @@ -2192,10 +2209,10 @@ describe("GeneralSettingsPanel observability", () => { providers: [createOutdatedProvider("codex", longUpdateCommand)], }); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await page.getByRole("button", { name: "Update available โ€” view details" }).click(); @@ -2264,10 +2281,10 @@ describe("SourceControlSettingsPanel discovery states", () => { it("shows skeleton sections while the first source control scan is pending", async () => { setSourceControlDiscoveryStub(() => new Promise(() => {})); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await expect.element(page.getByText("Version Control")).toBeInTheDocument(); @@ -2284,10 +2301,10 @@ describe("SourceControlSettingsPanel discovery states", () => { sourceControlProviders: [], })); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await expect.element(page.getByText("Nothing detected yet")).toBeInTheDocument(); @@ -2318,10 +2335,10 @@ describe("SourceControlSettingsPanel discovery states", () => { sourceControlProviders: [], })); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled(); @@ -2364,10 +2381,10 @@ describe("SourceControlSettingsPanel discovery states", () => { ], })); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await expect @@ -2396,10 +2413,10 @@ describe("SourceControlSettingsPanel discovery states", () => { sourceControlProviders: [], })); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); const toggle = page.getByRole("button", { name: "Toggle Git details" }); @@ -2437,10 +2454,10 @@ describe("SourceControlSettingsPanel discovery states", () => { }; }); - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled(); @@ -2451,10 +2468,10 @@ describe("SourceControlSettingsPanel discovery states", () => { mounted = null; document.body.innerHTML = ""; - mounted = await render( - + mounted = await renderWithTestRouter( + - , + , ); await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled(); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index f3ba3f524..55ff539da 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,13 +1,15 @@ import { ArchiveIcon, ArchiveX, + ChevronRightIcon, LoaderIcon, PlusIcon, RefreshCwIcon, Trash2Icon, } from "lucide-react"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; +import { formatTokens, formatUsd } from "@threadlines/shared/usageFormat"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AUTO_ARCHIVE_INACTIVE_THREADS_DAY_OPTIONS, @@ -19,6 +21,7 @@ import { type ProviderInstanceConfig, type ProviderInstanceId, type ScopedThreadRef, + type UsageWindowDays, } from "@threadlines/contracts"; import { scopeThreadRef } from "@threadlines/client-runtime"; import { DEFAULT_UNIFIED_SETTINGS } from "@threadlines/contracts/settings"; @@ -37,6 +40,11 @@ import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; import { readEnvironmentApi } from "../../environmentApi"; import { setDesktopUpdateStateQueryData } from "../../lib/desktopUpdateReactQuery"; +import { + deriveUsageWindow, + usageSummaryQueryOptions, + useUsageEnvironmentTargets, +} from "../../lib/usageReactQuery"; import { resolveDefaultTextGenerationBackupModelSelectionState, resolveAppModelSelectionState, @@ -133,6 +141,8 @@ const MAINTAINED_PROVIDER_DRIVER_KINDS = DRIVER_OPTIONS.map((definition) => defi const INACTIVE_THREAD_ARCHIVE_COMMAND_DELAY_MS = 25; const ARCHIVED_THREAD_DELETE_COMMAND_DELAY_MS = 25; const DEFAULT_ARCHIVED_THREAD_DELETE_AGE_DAYS: ArchivedThreadDeleteAgeDays = 90; +/** A month reads as "recently" without being a single noisy week. */ +const USAGE_SETTINGS_WINDOW_DAYS: UsageWindowDays = 30; function waitForInactiveThreadArchiveCommandSlot(): Promise { return new Promise((resolve) => @@ -1060,6 +1070,55 @@ export function GeneralSettingsPanel({ surface = "full" }: { surface?: "full" | ); } +/** + * The bridge from provider setup to what those providers have actually cost: + * a clickable tile in the same card family as the provider entries below it, + * named for its window so the figures cannot be mistaken for all-time totals. + */ +function ProviderUsageLinkRow() { + const targets = useUsageEnvironmentTargets(); + // The same scan the usage page reads, narrowed here rather than re-fetched. + const usageQuery = useQuery(usageSummaryQueryOptions({ targets })); + const scan = usageQuery.data ?? null; + const merged = useMemo( + () => (scan ? deriveUsageWindow(scan, USAGE_SETTINGS_WINDOW_DAYS).merged : null), + [scan], + ); + + return ( + + + + + Usage ยท Last {USAGE_SETTINGS_WINDOW_DAYS} days + + {/* One typeface AND one color: mixing brightness on a line of small + mono type erodes the dim glyphs' anti-aliased bottom edge, so the + muted words read a pixel higher than the numbers beside them. The + caps label above carries the hierarchy instead. */} + {merged ? ( + + {formatTokens(merged.totalTokens)} tokens ยท {formatUsd(merged.costUsd)} API-equivalent + + ) : ( + Reading provider transcriptsโ€ฆ + )} + + {/* Bottom-aligned, not centered: the affordance sits level with the + figures line rather than floating between the two left rows. */} + + View usage + + + + + ); +} + export function ProviderSettingsPanel({ focusedInstanceId = null, }: { @@ -1387,6 +1446,7 @@ export function ProviderSettingsPanel({ Account, usage, and configuration apply to the paired computer. Favorites and model ordering are saved on this device.

+ {rows.map((row) => { const driverOption = getDriverOption(row.driver); const liveProvider = serverProviders.find( diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 059cc3fc5..a97af5bd3 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -11,6 +11,7 @@ import { SidebarMenuItem, SidebarSeparator, } from "../ui/sidebar"; +import { SidebarUsageMeter } from "../sidebar/SidebarUsageMeter"; import { SidebarVersionTag } from "../sidebar/SidebarVersionTag"; import { isHostedStaticApp } from "../../hostedPairing"; import { @@ -87,6 +88,11 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { + {/* The same meter the main sidebar keeps above Settings, so the + bottom-left corner means "today's usage" on both surfaces. */} + + + { + if (isMobile) { + setOpenMobile(false); + } + void navigate({ to: "/usage" }); + }, [isMobile, navigate, setOpenMobile]); + + return ( + + + Usage + {totalTokens === null ? null : ( + + {formatTokensCompact(totalTokens)} today + + )} + + ); +} diff --git a/apps/web/src/components/usage/UsageView.browser.tsx b/apps/web/src/components/usage/UsageView.browser.tsx new file mode 100644 index 000000000..2f8c80948 --- /dev/null +++ b/apps/web/src/components/usage/UsageView.browser.tsx @@ -0,0 +1,400 @@ +import "../../index.css"; + +import { + EnvironmentId, + USAGE_CONTRACT_VERSION, + type UsageBucket, + type UsageDay, + type UsageProviderKind, + type UsageSummary, + type UsageSummaryInput, +} from "@threadlines/contracts"; +import { enumerateDays } from "@threadlines/shared/usageFormat"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from "@tanstack/react-router"; +import type { ReactNode } from "react"; +import { page } from "vite-plus/test/browser"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { + __resetEnvironmentApiOverridesForTests, + __setEnvironmentApiOverrideForTests, +} from "../../environmentApi"; +import { + resetPrimaryEnvironmentDescriptorForTests, + writePrimaryEnvironmentDescriptor, +} from "../../environments/primary"; +import { + resetSavedEnvironmentRegistryStoreForTests, + useSavedEnvironmentRegistryStore, +} from "../../environments/runtime"; +import { SidebarProvider } from "../ui/sidebar"; +import { SidebarUsageMeter } from "../sidebar/SidebarUsageMeter"; +import { UsageView } from "./UsageView"; + +const REPORTING_ENVIRONMENT_ID = EnvironmentId.make("env-studio"); +const OFFLINE_ENVIRONMENT_ID = EnvironmentId.make("env-laptop"); + +function tokens(total: number): UsageBucket["totals"] { + return { + uncachedInputTokens: Math.round(total / 2), + cachedInputTokens: Math.round(total / 2), + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }; +} + +function bucket(input: { + readonly day: string; + readonly provider: UsageProviderKind; + readonly model: string; + readonly costUsd: number; + readonly totalTokens: number; + readonly cacheSavingsUsd?: number; +}): UsageBucket { + return { + day: input.day as UsageDay, + provider: input.provider, + model: input.model, + totals: tokens(input.totalTokens), + costUsd: input.costUsd, + cacheSavingsUsd: input.cacheSavingsUsd ?? 1.5, + costSource: "modelPriced", + records: 4, + unpricedRecords: 0, + sessions: 2, + }; +} + +function source( + provider: UsageProviderKind, + resolvedHomePath: string, +): UsageSummary["sources"][number] { + return { + fingerprint: { + hostId: "studio", + provider, + resolvedHomePath, + volumeId: `1:${provider}`, + }, + status: "ok", + lastScannedAt: new Date().toISOString(), + scannedFiles: 12, + skippedFiles: 0, + distinctSessions: 3, + message: null, + }; +} + +function summaryFor( + input: UsageSummaryInput, + buckets: (days: readonly string[]) => readonly UsageBucket[], +): UsageSummary { + return { + contractVersion: USAGE_CONTRACT_VERSION, + readAt: new Date().toISOString(), + timeZone: input.timeZone, + sinceDay: input.sinceDay, + untilDay: input.untilDay, + buckets: buckets(enumerateDays(input.sinceDay, input.untilDay)), + sources: [source("claude", "/Users/dev/.claude"), source("codex", "/Users/dev/.codex")], + pricing: { + status: "cached", + source: "litellm", + fetchedAt: new Date().toISOString(), + knownModels: 400, + }, + scanDurationMs: 42, + }; +} + +function registerEnvironments(summary: (input: UsageSummaryInput) => Promise): void { + writePrimaryEnvironmentDescriptor({ + environmentId: REPORTING_ENVIRONMENT_ID, + label: "Studio Mac", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.1.0", + capabilities: { repositoryIdentity: false }, + }); + // A saved backend that is not connected: the page must name it rather than + // quietly leaving it out of the totals. + useSavedEnvironmentRegistryStore.setState({ + byId: { + [OFFLINE_ENVIRONMENT_ID]: { + environmentId: OFFLINE_ENVIRONMENT_ID, + label: "Laptop", + wsBaseUrl: "ws://laptop.local", + httpBaseUrl: "http://laptop.local", + createdAt: new Date().toISOString(), + lastConnectedAt: null, + }, + }, + }); + __setEnvironmentApiOverrideForTests(REPORTING_ENVIRONMENT_ID, { + usage: { summary }, + } as never); +} + +function renderWithProviders(children: ReactNode) { + const rootRoute = createRootRoute({ component: () => children }); + const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: "/" }); + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return render( + + + , + ); +} + +beforeEach(() => { + resetPrimaryEnvironmentDescriptorForTests(); + resetSavedEnvironmentRegistryStoreForTests(); + __resetEnvironmentApiOverridesForTests(); +}); + +afterEach(() => { + resetPrimaryEnvironmentDescriptorForTests(); + resetSavedEnvironmentRegistryStoreForTests(); + __resetEnvironmentApiOverridesForTests(); +}); + +describe("UsageView", () => { + it("renders the hero, the stat band, the priced models, and the silent machine", async () => { + const summary = vi.fn(async (input: UsageSummaryInput) => + summaryFor(input, (days) => { + const day = days[days.length - 1] ?? input.untilDay; + return [ + bucket({ + day, + provider: "claude", + model: "claude-fable-5", + costUsd: 12.5, + totalTokens: 2_000_000, + }), + bucket({ + day, + provider: "codex", + model: "gpt-5.6-sol", + costUsd: 4.5, + totalTokens: 400_000, + }), + // Placeholder rows carry records but no tokens and no cost; the table + // has nothing to say about them. + bucket({ + day, + provider: "codex", + model: "", + costUsd: 0, + totalTokens: 0, + cacheSavingsUsd: 0, + }), + ]; + }), + ); + registerEnvironments(summary); + + renderWithProviders(); + + // The one display-size figure on the page, asterisked to its footnote. + await expect.element(page.getByTestId("usage-total-cost")).toHaveTextContent("$17.00*"); + await expect + .element(page.getByText("* if billed at full API rates. Subscription plans bill separately.")) + .toBeInTheDocument(); + + const providerRows = page.getByTestId("usage-provider-row").elements(); + expect(providerRows).toHaveLength(2); + expect(providerRows[0]?.textContent).toContain("Claude Code"); + expect(providerRows[0]?.textContent).toContain("$12.50"); + expect(providerRows[0]?.textContent).toContain("73.5% of cost ยท 2M tokens"); + + const stats = page.getByTestId("usage-stat").elements(); + expect(stats.map((stat) => stat.textContent)).toEqual([ + // Compact figures here: the stat band is one-off numbers, not a column. + "Processed tokens2.4M2.4M per active day", + "Cached input1.2M50.0% of observed input", + "Uncached input1.2M0 cache writes", + "Output0", + "Cache savings$3.000.2x the API-equivalent cost", + ]); + + const modelRows = page.getByTestId("usage-model-row").elements(); + expect(modelRows).toHaveLength(2); + // Sorted by cost, so the expensive model leads. + expect(modelRows[0]?.textContent).toContain("claude-fable-5"); + expect(modelRows[1]?.textContent).toContain("gpt-5.6-sol"); + + const machineRows = page.getByTestId("usage-machine-row").elements(); + expect(machineRows).toHaveLength(2); + const laptopRow = machineRows.find((row) => row.textContent?.includes("Laptop")); + expect(laptopRow?.textContent).toContain("Not reporting"); + const studioRow = machineRows.find((row) => row.textContent?.includes("Studio Mac")); + expect(studioRow?.textContent).toContain("/Users/dev/.claude"); + + // Hovering a day raises the tracking card: both providers, then the total. + await page.getByTestId("usage-chart-day").nth(29).hover(); + const card = page.getByTestId("usage-chart-card"); + await expect.element(card).toBeVisible(); + const cardText = card.element().textContent ?? ""; + expect(cardText).toContain("Claude Code"); + expect(cardText).toContain("$12.50"); + expect(cardText).toContain("Codex"); + expect(cardText).toContain("$4.50"); + expect(cardText).toContain("Total"); + expect(cardText).toContain("$17.00"); + }); + + it("switches the hero to tokens with the chart mode and the breakdown to days", async () => { + const summary = vi.fn(async (input: UsageSummaryInput) => + summaryFor(input, (days) => { + const day = days[days.length - 1] ?? input.untilDay; + return [ + bucket({ + day, + provider: "claude", + model: "claude-fable-5", + costUsd: 12.5, + totalTokens: 2_000_000, + }), + bucket({ + day, + provider: "codex", + model: "gpt-5.6-sol", + costUsd: 4.5, + totalTokens: 400_000, + }), + ]; + }), + ); + registerEnvironments(summary); + + renderWithProviders(); + await expect.element(page.getByTestId("usage-total-cost")).toHaveTextContent("$17.00*"); + + await page.getByTestId("usage-chart-mode-tokens").click(); + + // The whole hero follows the toggle: headline figure, label, provider rows. + // The cost asterisk and its footnote only make sense against dollars. + await expect.element(page.getByTestId("usage-total-cost")).toHaveTextContent("2.4M"); + await expect.element(page.getByText("Processed tokens").first()).toBeInTheDocument(); + expect( + page + .getByText("* if billed at full API rates. Subscription plans bill separately.") + .elements(), + ).toHaveLength(0); + const providerRows = page.getByTestId("usage-provider-row").elements(); + expect(providerRows[0]?.textContent).toContain("2M"); + expect(providerRows[0]?.textContent).toContain("83.3% of tokens ยท $12.50"); + + await page.getByTestId("usage-breakdown-days").click(); + + // Only the day with activity earns a row, and it carries both measures. + const dayRows = page.getByTestId("usage-day-row").elements(); + expect(dayRows).toHaveLength(1); + expect(dayRows[0]?.textContent).toContain("$17.00"); + expect(page.getByTestId("usage-model-row").elements()).toHaveLength(0); + + await page.getByTestId("usage-breakdown-models").click(); + expect(page.getByTestId("usage-model-row").elements()).toHaveLength(2); + }); + + it("opens on 30 days and switches windows without another scan", async () => { + const summary = vi.fn(async (input: UsageSummaryInput) => + summaryFor(input, (days) => + days.map((day) => + bucket({ + day, + provider: "claude", + model: "claude-fable-5", + costUsd: 1, + totalTokens: 1_000, + }), + ), + ), + ); + registerEnvironments(summary); + + renderWithProviders(); + + await vi.waitFor(() => { + expect(page.getByTestId("usage-chart-day").elements()).toHaveLength(30); + }); + // One scan, and it covers the longest window the selector offers. + expect(summary).toHaveBeenCalledTimes(1); + expect(windowLengthOf(summary.mock.calls[0]?.[0])).toBe(90); + + await page.getByTestId("usage-window-7").click(); + await vi.waitFor(() => { + expect(page.getByTestId("usage-chart-day").elements()).toHaveLength(7); + }); + + await page.getByTestId("usage-window-90").click(); + await vi.waitFor(() => { + expect(page.getByTestId("usage-chart-day").elements()).toHaveLength(90); + }); + + // Narrowing and widening are arithmetic on the scan already in hand. + expect(summary).toHaveBeenCalledTimes(1); + }); +}); + +describe("SidebarUsageMeter", () => { + it("names itself and carries today's compact total once it arrives", async () => { + const summary = vi.fn(async (input: UsageSummaryInput) => + summaryFor(input, (days) => + days.map((day) => + bucket({ + day, + provider: "claude", + model: "claude-fable-5", + costUsd: 3, + totalTokens: 2_400_000, + }), + ), + ), + ); + registerEnvironments(summary); + + renderWithProviders( + + + , + ); + + // Compact form: trailing zeros are table alignment, not chip copy. + await expect + .element(page.getByTestId("sidebar-usage-meter")) + .toHaveTextContent("Usage2.4M today"); + }); + + it("stays the plain label when no environment answers", async () => { + renderWithProviders( + + + , + ); + + await expect.element(page.getByTestId("sidebar-usage-meter")).toHaveTextContent("Usage"); + }); +}); + +function windowLengthOf(input: UsageSummaryInput | undefined): number { + if (!input) return 0; + const since = Date.parse(`${input.sinceDay}T00:00:00Z`); + const until = Date.parse(`${input.untilDay}T00:00:00Z`); + return Math.round((until - since) / 86_400_000) + 1; +} diff --git a/apps/web/src/components/usage/UsageView.tsx b/apps/web/src/components/usage/UsageView.tsx new file mode 100644 index 000000000..34c042d9a --- /dev/null +++ b/apps/web/src/components/usage/UsageView.tsx @@ -0,0 +1,645 @@ +import { + USAGE_WINDOW_DAY_OPTIONS, + type UsageProviderKind, + type UsageWindowDays, +} from "@threadlines/contracts"; +import { + formatCount, + formatTokens, + formatTokensCompact, + formatUsd, + formatPercent, +} from "@threadlines/shared/usageFormat"; +import { useQuery } from "@tanstack/react-query"; +import { RotateCwIcon } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { useRelativeTimeTick } from "../../hooks/useRelativeTimeTick"; +import { cn } from "../../lib/utils"; +import { + deriveUsageWindow, + usageSummaryQueryOptions, + useUsageEnvironmentTargets, +} from "../../lib/usageReactQuery"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; +import { ClaudeAI, OpenAI, type Icon } from "../Icons"; +import { + buildUsageAreaChart, + buildUsageDayRows, + buildUsageMachineRows, + buildUsageStats, + formatCostQualityFootnote, + formatUsageDateRange, + USAGE_BREAKDOWN_LABELS, + USAGE_BREAKDOWNS, + USAGE_CHART_MODE_LABELS, + USAGE_CHART_MODES, + USAGE_CHART_TITLES, + USAGE_HERO_LABELS, + USAGE_MACHINE_STATE_LABELS, + USAGE_PROVIDER_COLORS, + USAGE_PROVIDER_LABELS, + USAGE_PROVIDER_READING_ORDER, + USAGE_PROVIDER_SHORT_LABELS, + visibleUsageModels, + type UsageAreaChart, + type UsageBreakdown, + type UsageChartMode, + type UsageMachineRow, + type UsageStat, +} from "./usageView.logic"; + +const SECTION_LABEL_CLASS = + "font-mono text-[10px] uppercase tracking-wider text-muted-foreground/55 select-none"; +const NUMBER_CLASS = "font-mono tabular-nums"; +/** Scan freshness is a relative label, so it needs a slow clock of its own. */ +const FRESHNESS_TICK_MS = 30_000; +/** The window a first-time reader gets: a week is too short to see a pattern. */ +const DEFAULT_WINDOW_DAYS: UsageWindowDays = 30; + +/** The same glyphs the model picker draws, keyed by the usage contract's names. */ +const USAGE_PROVIDER_ICONS: Record = { + claude: ClaudeAI, + codex: OpenAI, +}; + +/** + * What the provider CLIs' own transcripts say this account has spent, merged + * across every computer Threadlines knows about. + * + * The figures are API list prices for the tokens, never billed spend, and the + * page says so under the headline: a subscription bills on its own terms and the + * transcripts carry no invoice. + * + * One scan covers the longest window on offer and the selector narrows it here, + * so switching between 7, 30 and 90 days is a recompute rather than a wait. + */ +export function UsageView() { + const [windowDays, setWindowDays] = useState(DEFAULT_WINDOW_DAYS); + const [chartMode, setChartMode] = useState("cost"); + const [breakdown, setBreakdown] = useState("models"); + // One slow clock for the whole page: scan freshness is the only honest signal + // about a warm scan, and it must not go stale on screen. + useRelativeTimeTick(FRESHNESS_TICK_MS); + const targets = useUsageEnvironmentTargets(); + const usageQuery = useQuery(usageSummaryQueryOptions({ targets })); + const scan = usageQuery.data ?? null; + + const view = useMemo( + () => (scan ? deriveUsageWindow(scan, windowDays) : null), + [scan, windowDays], + ); + const merged = view?.merged ?? null; + + const chart = useMemo( + () => + view + ? buildUsageAreaChart({ + daily: view.merged.daily, + sinceDay: view.window.sinceDay, + untilDay: view.window.untilDay, + mode: chartMode, + }) + : null, + [chartMode, view], + ); + // The machines answered for the whole scan, not for the selected window, so + // they are read from the scan and never move when the selector does. + const machines = useMemo( + () => + scan + ? buildUsageMachineRows({ + environments: scan.environments, + staleEnvironments: scan.merged.staleEnvironments, + }) + : [], + [scan], + ); + const models = useMemo(() => (merged ? visibleUsageModels(merged) : []), [merged]); + const dayRows = useMemo(() => (merged ? buildUsageDayRows(merged) : []), [merged]); + const stats = useMemo(() => (merged ? buildUsageStats(merged) : []), [merged]); + + return ( +
+ {/* Wraps as a whole row on narrow screens; the range never breaks internally. */} +
+

Usage

+ {view ? ( + + {formatUsageDateRange(view.window.sinceDay, view.window.untilDay)} + + ) : null} +
+
+ {USAGE_WINDOW_DAY_OPTIONS.map((option) => ( + + ))} +
+ +
+ + {targets.length === 0 ? ( +

+ No computers are connected, so there is nothing to report yet. +

+ ) : !merged || !view || !chart ? ( +

+ {usageQuery.isError ? "Usage could not be read." : "Reading provider transcriptsโ€ฆ"} +

+ ) : ( + <> +
+
+ {USAGE_HERO_LABELS[chartMode]} + + {chartMode === "cost" ? ( + <> + {formatUsd(merged.costUsd)} + + * + + + ) : ( + formatTokensCompact(merged.totalTokens) + )} + + {/* The asterisk and its footnote are the cost figure's caveat; a + token count needs no disclaimer. */} + {chartMode === "cost" ? ( +

+ * if billed at full API rates. Subscription plans bill separately. +

+ ) : null} +
+ {USAGE_PROVIDER_READING_ORDER.flatMap((provider) => { + const totals = merged.providers.find((entry) => entry.provider === provider); + return totals + ? [] + : []; + })} +
+
+ + +
+ +
+ {stats.map((stat) => ( + + ))} +
+ +
+
+

+ {breakdown === "models" ? "Models" : "Days"} ยท {formatCount(merged.sessions)}{" "} + sessions +

+
+
+ {USAGE_BREAKDOWNS.map((option, index) => ( + + {index > 0 ? | : null} + + + ))} +
+
+ {breakdown === "days" ? ( + dayRows.length === 0 ? ( +

+ No recorded activity in this window. +

+ ) : ( +
+ {dayRows.map((row) => ( +
+ + {row.label} + + + {formatTokens(row.totalTokens)} + + + {formatUsd(row.costUsd)} + + + {formatPercent(row.costShare, 0)} + +
+ ))} +
+ ) + ) : models.length === 0 ? ( +

+ No recorded activity in this window. +

+ ) : ( +
+ {models.map((model) => { + const ProviderIcon = USAGE_PROVIDER_ICONS[model.provider]; + return ( +
+ + + + {model.model} + + + + {formatTokens(model.totalTokens)} + + + {formatUsd(model.costUsd)} + + {/* Share is the first column to go on a phone: the model + name is the row's point, and share is derivable from + cost at a glance. */} + + {formatPercent(model.costShare, 0)} + +
+ ); + })} +
+ )} +
+ +
+

Machines

+
+ {machines.map((machine) => ( + + ))} +
+ {merged.duplicateSources.length > 0 ? ( +

+ Counted once: {merged.duplicateSources.join(", ")} is the same folder another + computer already reported. +

+ ) : null} +
+ +

+ {formatCostQualityFootnote(merged.costQuality)} +

+ + )} +
+ ); +} + +/** + * Provider name, its slice of the selected measure, and how wide that slice is. + * The row follows the page's cost|tokens mode: the leading figure and the bar + * show the selected measure, and the sub-line carries the other one. + */ +function UsageProviderRow({ + totals, + mode, +}: { + readonly totals: { + readonly provider: UsageProviderKind; + readonly costUsd: number; + readonly costShare: number; + readonly totalTokens: number; + readonly tokenShare: number; + }; + readonly mode: UsageChartMode; +}) { + const ProviderIcon = USAGE_PROVIDER_ICONS[totals.provider]; + const share = mode === "cost" ? totals.costShare : totals.tokenShare; + return ( +
+
+ + + {USAGE_PROVIDER_LABELS[totals.provider]} + + + {mode === "cost" ? formatUsd(totals.costUsd) : formatTokensCompact(totals.totalTokens)} + +
+
+
+
+ + {mode === "cost" + ? `${formatPercent(totals.costShare)} of cost ยท ${formatTokensCompact(totals.totalTokens)} tokens` + : `${formatPercent(totals.tokenShare)} of tokens ยท ${formatUsd(totals.costUsd)}`} + +
+ ); +} + +/** + * Overlapping areas, one per provider, drawn from the zero baseline. + * + * The SVG carries only the curves: gridlines, labels and the hover targets are + * HTML, so nothing has to survive the horizontal stretch that lets the plot fill + * whatever width the hero gives it. + */ +function UsageChart({ + chart, + mode, + onModeChange, +}: { + readonly chart: UsageAreaChart; + readonly mode: UsageChartMode; + readonly onModeChange: (mode: UsageChartMode) => void; +}) { + // The hovered day, held as an index so a window switch under the cursor + // cannot leave the card describing a day the chart no longer shows. + const [hoveredIndex, setHoveredIndex] = useState(null); + const hoveredColumn = hoveredIndex === null ? null : (chart.columns[hoveredIndex] ?? null); + const hoveredCenterPercent = + hoveredIndex === null || chart.columns.length === 0 + ? 0 + : ((hoveredIndex + 0.5) / chart.columns.length) * 100; + // The card sits on whichever side has room, flipping past the midline. + const hoveredOnLeftHalf = hoveredCenterPercent <= 50; + return ( +
+
+

{USAGE_CHART_TITLES[mode]}

+
+
+ {USAGE_CHART_MODES.map((option, index) => ( + + {index > 0 ? | : null} + + + ))} +
+
+ {USAGE_PROVIDER_READING_ORDER.map((provider) => { + const ProviderIcon = USAGE_PROVIDER_ICONS[provider]; + return ( + + {/* The glyphs' built-in brand fills already match the series + hues, so they replace the swatch dots without a legend key + being lost. */} + + {USAGE_PROVIDER_LABELS[provider]} + + ); + })} +
+
+ +
+ {chart.gridlines.map((gridline) => ( +
+ + {gridline.label} + +
+ ))} + + {chart.series.map((series) => ( + + + + + ))} + + {/* Transparent per-day columns drive the tracking line and the card. */} +
setHoveredIndex(null)}> + {chart.columns.map((column, index) => ( +
setHoveredIndex(index)} + /> + ))} +
+ {hoveredColumn ? ( + <> +
+
+

{hoveredColumn.label}

+
+ {hoveredColumn.entries.map((entry) => { + const ProviderIcon = USAGE_PROVIDER_ICONS[entry.provider]; + return ( + + + + {USAGE_PROVIDER_LABELS[entry.provider]} + + + {entry.valueLabel} + + + ); + })} +
+
+ Total + + {hoveredColumn.totalLabel} + +
+
+ + ) : null} + {chart.isEmpty ? ( +

+ No recorded activity in this window. +

+ ) : null} +
+
+ {chart.axisLabels.map((label) => ( + + {label} + + ))} +
+
+ ); +} + +function UsageStatCell({ stat }: { readonly stat: UsageStat }) { + return ( +
+ {stat.label} + + {stat.value} + + {stat.context ? ( + // Wraps on a phone, truncates on desktop where the band is one row and + // a two-line cell would stagger its neighbours. + {stat.context} + ) : null} +
+ ); +} + +function UsageMachineRowView({ machine }: { readonly machine: UsageMachineRow }) { + return ( +
+ + {machine.label} + + {USAGE_MACHINE_STATE_LABELS[machine.state]} + + + {machine.detail ? ( + {machine.detail} + ) : null} + {machine.sources.map((source) => ( + + + {USAGE_PROVIDER_SHORT_LABELS[source.fingerprint.provider]} + + + {source.fingerprint.resolvedHomePath} + + + {source.status === "missing" + ? "not found" + : `scanned ${formatRelativeTimeLabel(source.lastScannedAt)}`} + + + ))} +
+ ); +} diff --git a/apps/web/src/components/usage/usageView.logic.ts b/apps/web/src/components/usage/usageView.logic.ts new file mode 100644 index 000000000..c11d9a9b8 --- /dev/null +++ b/apps/web/src/components/usage/usageView.logic.ts @@ -0,0 +1,449 @@ +import type { UsageProviderKind, UsageSource } from "@threadlines/contracts"; +import { + enumerateDays, + formatDayShort, + formatPercent, + formatTokens, + formatTokensCompact, + formatUsd, +} from "@threadlines/shared/usageFormat"; +import type { CostQuality, DailyTotals, MergedUsage } from "@threadlines/shared/usageMerge"; + +import type { UsageEnvironmentReport } from "~/lib/usageReactQuery"; + +/** Product names, as the providers write them. */ +export const USAGE_PROVIDER_LABELS: Record = { + claude: "Claude Code", + codex: "Codex", +}; + +/** For the narrow columns where the product name would not fit. */ +export const USAGE_PROVIDER_SHORT_LABELS: Record = { + claude: "Claude", + codex: "Codex", +}; + +/** + * The only brand hues in the app, defined in `index.css` for both themes. + * + * Held as CSS variables rather than utility classes because the chart paints + * them into SVG fill/stroke attributes as well as into HTML. + */ +export const USAGE_PROVIDER_COLORS: Record = { + claude: "var(--provider-claude)", + codex: "var(--provider-codex)", +}; + +/** + * Paint order, not stacking order: the series overlap, and Claude is usually the + * larger of the two, so it draws last and keeps its outline unbroken. + */ +export const USAGE_PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; + +/** Reading order for legends and rows: the same order every time. */ +export const USAGE_PROVIDER_READING_ORDER: readonly UsageProviderKind[] = ["claude", "codex"]; + +export type UsageChartMode = "cost" | "tokens"; + +export const USAGE_CHART_MODES: readonly UsageChartMode[] = ["cost", "tokens"]; + +export const USAGE_CHART_MODE_LABELS: Record = { + cost: "Cost", + tokens: "Tokens", +}; + +export const USAGE_CHART_TITLES: Record = { + cost: "Daily cost", + tokens: "Daily tokens", +}; + +/** The hero follows the chart's cost|tokens mode; these are its labels. */ +export const USAGE_HERO_LABELS: Record = { + cost: "API-equivalent cost", + tokens: "Processed tokens", +}; + +export type UsageBreakdown = "models" | "days"; + +export const USAGE_BREAKDOWNS: readonly UsageBreakdown[] = ["models", "days"]; + +export const USAGE_BREAKDOWN_LABELS: Record = { + models: "Model", + days: "Day", +}; + +export interface UsageDayRow { + readonly day: string; + readonly label: string; + readonly totalTokens: number; + readonly costUsd: number; + readonly costShare: number; +} + +/** + * The days breakdown, newest first. Days without activity are dropped: unlike + * the chart, where a gap is information, a table of zero rows buries the days + * that had something to say. + */ +export function buildUsageDayRows(merged: MergedUsage): readonly UsageDayRow[] { + return merged.daily + .filter((entry) => entry.totalTokens > 0 || entry.costUsd > 0) + .map((entry) => ({ + day: entry.day, + label: formatDayShort(entry.day), + totalTokens: entry.totalTokens, + costUsd: entry.costUsd, + costShare: merged.costUsd === 0 ? 0 : entry.costUsd / merged.costUsd, + })) + .toReversed(); +} + +/** + * Chart user units. Height matches the rendered pixel height so vertical + * geometry is 1:1; width is nominal, since the SVG stretches horizontally to + * whatever the hero column gives it. + */ +const CHART_WIDTH = 1000; +const CHART_HEIGHT = 200; +const GRIDLINE_COUNT = 4; + +export interface UsageChartSeries { + readonly provider: UsageProviderKind; + readonly color: string; + /** Closed shape from the zero baseline, for the translucent fill. */ + readonly areaPath: string; + /** The same curve without the baseline, for the stroke on top. */ + readonly linePath: string; +} + +export interface UsageChartGridline { + readonly value: number; + readonly label: string; + /** Distance from the top of the plot, as a percentage. */ + readonly topPercent: number; +} + +export interface UsageChartColumnEntry { + readonly provider: UsageProviderKind; + /** Formatted in the chart's mode, so the card needs no arithmetic. */ + readonly valueLabel: string; +} + +/** One hoverable day: everything the tracking card says about it. */ +export interface UsageChartColumn { + readonly day: string; + readonly label: string; + readonly entries: readonly UsageChartColumnEntry[]; + readonly totalLabel: string; + readonly hasActivity: boolean; +} + +export interface UsageAreaChart { + readonly viewBoxWidth: number; + readonly viewBoxHeight: number; + readonly series: readonly UsageChartSeries[]; + readonly gridlines: readonly UsageChartGridline[]; + /** Window start, midpoint and end. Three ticks, whatever the window length. */ + readonly axisLabels: readonly string[]; + readonly columns: readonly UsageChartColumn[]; + readonly isEmpty: boolean; +} + +function chartValue( + totals: { readonly costUsd: number; readonly totalTokens: number } | undefined, + mode: UsageChartMode, +): number { + if (!totals) return 0; + return mode === "cost" ? totals.costUsd : totals.totalTokens; +} + +/** + * One smooth area per provider across every calendar day of the window, + * including the days with no activity โ€” gaps are information, and dropping them + * would compress the axis into a lie about when the work happened. + */ +export function buildUsageAreaChart(input: { + readonly daily: readonly DailyTotals[]; + readonly sinceDay: string; + readonly untilDay: string; + readonly mode: UsageChartMode; +}): UsageAreaChart { + const byDay = new Map(input.daily.map((entry) => [entry.day, entry])); + const days = enumerateDays(input.sinceDay, input.untilDay); + + const valuesByProvider = new Map( + USAGE_PROVIDER_ORDER.map((provider) => [ + provider, + days.map((day) => chartValue(byDay.get(day)?.byProvider.get(provider), input.mode)), + ]), + ); + + const peak = [...valuesByProvider.values()] + .flat() + .reduce((highest, value) => Math.max(highest, value), 0); + const axisMax = niceAxisMax(peak); + + const series: UsageChartSeries[] = []; + for (const provider of USAGE_PROVIDER_ORDER) { + const values = valuesByProvider.get(provider) ?? []; + if (values.every((value) => value === 0)) continue; + const points = monotonePoints(values, axisMax); + series.push({ + provider, + color: USAGE_PROVIDER_COLORS[provider], + areaPath: `${points} L ${CHART_WIDTH} ${CHART_HEIGHT} L 0 ${CHART_HEIGHT} Z`, + linePath: points, + }); + } + + return { + viewBoxWidth: CHART_WIDTH, + viewBoxHeight: CHART_HEIGHT, + series, + gridlines: axisMax === 0 ? [] : buildGridlines(axisMax, input.mode), + axisLabels: axisTickDays(days).map((day) => formatDayShort(day).toUpperCase()), + columns: days.map((day) => { + const entry = byDay.get(day); + const formatValue = input.mode === "cost" ? formatUsd : formatTokensCompact; + return { + day, + label: formatDayShort(day), + // Every charted provider gets a row, zeros included: the card answers + // "who was quiet that day" as much as "who was busy". Reading order, + // not paint order, so the card matches the legend and the hero. + entries: USAGE_PROVIDER_READING_ORDER.filter((provider) => + series.some((line) => line.provider === provider), + ).map((provider) => ({ + provider, + valueLabel: formatValue(chartValue(entry?.byProvider.get(provider), input.mode)), + })), + totalLabel: formatValue(chartValue(entry, input.mode)), + hasActivity: (entry?.totalTokens ?? 0) > 0, + }; + }), + isEmpty: series.length === 0, + }; +} + +/** Start, middle and end, de-duplicated for windows too short to have three. */ +function axisTickDays(days: readonly string[]): readonly string[] { + if (days.length === 0) return []; + const ticks = [days[0], days[Math.floor((days.length - 1) / 2)], days[days.length - 1]]; + return [...new Set(ticks.filter((day): day is string => day !== undefined))]; +} + +function buildGridlines(axisMax: number, mode: UsageChartMode): readonly UsageChartGridline[] { + return Array.from({ length: GRIDLINE_COUNT }, (_unused, index) => { + const fraction = (GRIDLINE_COUNT - index) / GRIDLINE_COUNT; + const value = axisMax * fraction; + return { + value, + label: mode === "cost" ? formatUsd(value) : formatTokens(value), + topPercent: (1 - fraction) * 100, + }; + }); +} + +const AXIS_STEPS = [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10] as const; + +/** + * The smallest round ceiling above the peak that also divides evenly into + * {@link GRIDLINE_COUNT} bands, so every gridline label is a number a reader can + * hold in their head. + */ +export function niceAxisMax(peak: number): number { + if (!(peak > 0)) return 0; + const band = peak / GRIDLINE_COUNT; + const magnitude = 10 ** Math.floor(Math.log10(band)); + const normalized = band / magnitude; + const step = AXIS_STEPS.find((candidate) => normalized <= candidate + 1e-9) ?? 10; + return step * magnitude * GRIDLINE_COUNT; +} + +/** + * A monotone cubic through the daily values, as an SVG path. + * + * Fritsch-Carlson tangents rather than a plain Catmull-Rom: a smooth curve that + * overshoots would draw days below zero and peaks the data never reached, which + * on a spend chart is not a stylistic quibble. + */ +function monotonePoints(values: readonly number[], axisMax: number): string { + if (values.length === 0) return "M 0 " + String(CHART_HEIGHT); + const stepX = values.length > 1 ? CHART_WIDTH / (values.length - 1) : 0; + const toY = (value: number) => + axisMax === 0 ? CHART_HEIGHT : CHART_HEIGHT - (value / axisMax) * CHART_HEIGHT; + const ys = values.map(toY); + if (ys.length === 1) { + // One day: a flat segment, so the fill still has a shape to close. + return `M 0 ${round(ys[0] ?? CHART_HEIGHT)} L ${CHART_WIDTH} ${round(ys[0] ?? CHART_HEIGHT)}`; + } + + const tangents = monotoneTangents(ys, stepX); + let path = `M 0 ${round(ys[0] ?? 0)}`; + for (let index = 0; index < ys.length - 1; index += 1) { + const x0 = index * stepX; + const x1 = (index + 1) * stepX; + const y0 = ys[index] ?? 0; + const y1 = ys[index + 1] ?? 0; + const m0 = tangents[index] ?? 0; + const m1 = tangents[index + 1] ?? 0; + const control = stepX / 3; + path += ` C ${round(x0 + control)} ${round(y0 + m0 * control)} ${round(x1 - control)} ${round(y1 - m1 * control)} ${round(x1)} ${round(y1)}`; + } + return path; +} + +function monotoneTangents(ys: readonly number[], stepX: number): readonly number[] { + const secants = ys.slice(0, -1).map((y, index) => ((ys[index + 1] ?? y) - y) / stepX); + return ys.map((_unused, index) => { + const before = secants[index - 1]; + const after = secants[index]; + if (before === undefined) return after ?? 0; + if (after === undefined) return before; + // A local extremum: a flat tangent is the only one that cannot overshoot. + if (before * after <= 0) return 0; + const average = (before + after) / 2; + const limit = 3 * Math.min(Math.abs(before), Math.abs(after)); + return Math.sign(average) * Math.min(Math.abs(average), limit); + }); +} + +function round(value: number): number { + return Math.round(value * 100) / 100; +} + +/** `2026-07-12` and `2026-08-10` to `Jul 12 to Aug 10`. */ +export function formatUsageDateRange(sinceDay: string, untilDay: string): string { + return `${formatDayShort(sinceDay)} to ${formatDayShort(untilDay)}`; +} + +export interface UsageStat { + readonly label: string; + readonly value: string; + /** One line of context, or nothing when the figure has none worth adding. */ + readonly context: string | null; +} + +/** + * The band under the hero: what the tokens were, not just how many. + * + * Every sub-line is a ratio the totals alone do not give, so a reader can tell a + * heavy day from a heavy month, and cache reads from cache writes. + */ +export function buildUsageStats(merged: MergedUsage): readonly UsageStat[] { + // A day with any tokens at all. Dividing by calendar days instead would make + // a week off look like a drop in intensity rather than a week off. + const activeDays = merged.daily.filter((entry) => entry.totalTokens > 0).length; + const observedInput = + merged.cachedInputTokens + merged.uncachedInputTokens + merged.cacheCreationTokens; + + return [ + { + label: "Processed tokens", + value: formatTokensCompact(merged.totalTokens), + context: + activeDays === 0 + ? null + : `${formatTokensCompact(merged.totalTokens / activeDays)} per active day`, + }, + { + label: "Cached input", + value: formatTokensCompact(merged.cachedInputTokens), + context: + observedInput === 0 + ? null + : `${formatPercent(merged.cachedInputTokens / observedInput)} of observed input`, + }, + { + label: "Uncached input", + value: formatTokensCompact(merged.uncachedInputTokens), + context: `${formatTokensCompact(merged.cacheCreationTokens)} cache writes`, + }, + { + label: "Output", + value: formatTokensCompact(merged.outputTokens), + context: + merged.reasoningTokens === 0 + ? null + : `includes ${formatTokensCompact(merged.reasoningTokens)} reasoning`, + }, + { + label: "Cache savings", + value: formatUsd(merged.costQuality.cacheSavingsUsd), + context: + merged.costUsd === 0 + ? null + : `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the API-equivalent cost`, + }, + ]; +} + +/** + * Models worth a row. + * + * A scan turns up placeholder model names (`` among them) carrying + * neither tokens nor cost. They are real records, but a row of zeros tells a + * reader nothing except that the table is noisy. + */ +export function visibleUsageModels(merged: MergedUsage): MergedUsage["models"] { + return merged.models.filter((model) => model.totalTokens > 0 || model.costUsd > 0); +} + +export type UsageMachineState = "reporting" | "stale" | "not-reporting"; + +export interface UsageMachineRow { + readonly environmentId: string; + readonly label: string; + readonly state: UsageMachineState; + readonly detail: string | null; + readonly sources: readonly UsageSource[]; +} + +/** + * A row per environment the app knows about, including the ones that answered + * nothing. Silently dropping an unreachable machine would make the totals look + * complete when they are not. + */ +export function buildUsageMachineRows(input: { + readonly environments: readonly UsageEnvironmentReport[]; + readonly staleEnvironments: readonly string[]; +}): readonly UsageMachineRow[] { + const stale = new Set(input.staleEnvironments); + + return [...input.environments] + .sort((left, right) => left.label.localeCompare(right.label)) + .map((environment) => { + if (!environment.summary) { + return { + environmentId: environment.environmentId, + label: environment.label, + state: "not-reporting" as const, + detail: environment.error, + sources: [], + }; + } + const isStale = stale.has(environment.environmentId); + return { + environmentId: environment.environmentId, + label: environment.label, + state: isStale ? ("stale" as const) : ("reporting" as const), + detail: isStale ? "Running an older usage format; excluded from the totals." : null, + sources: environment.summary.sources, + }; + }); +} + +export const USAGE_MACHINE_STATE_LABELS: Record = { + reporting: "Reporting", + stale: "Out of date", + "not-reporting": "Not reporting", +}; + +/** How trustworthy the cost column is, in one line. */ +export function formatCostQualityFootnote(costQuality: CostQuality): string { + return [ + `Priced from provider records ${formatPercent(costQuality.providerReportedShare, 0)}`, + `model rates ${formatPercent(costQuality.modelPricedShare, 0)}`, + `unpriced ${formatPercent(costQuality.unpricedShare, 0)}`, + ].join(" ยท "); +} diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index 49aedab3e..feae4ed51 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -45,6 +45,9 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { filesystem: { browse: rpcClient.filesystem.browse, }, + usage: { + summary: rpcClient.usage.summary, + }, sourceControl: { lookupRepository: rpcClient.sourceControl.lookupRepository, listRepositories: rpcClient.sourceControl.listRepositories, diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 3a69ae638..1cc0aa3e2 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -70,6 +70,8 @@ --color-muted: var(--muted); --color-secondary-foreground: var(--secondary-foreground); --color-secondary: var(--secondary); + --color-provider-claude: var(--provider-claude); + --color-provider-codex: var(--provider-codex); --color-primary-graph: var(--primary-graph); --color-primary-readable: var(--primary-readable); --color-primary-foreground: var(--primary-foreground); @@ -207,6 +209,13 @@ --primary: var(--app-accent-blue); --primary-readable: oklch(0.488 0.217 264); --primary-graph: oklch(0.488 0.217 264); + /* The two providers charted on the usage page, and the only brand hues in the + app. Anthropic's terracotta reads as itself in both modes; Codex has no + usable brand color, so it takes a near-foreground neutral, which is also how + its own mark is drawn. Darkened on light, lifted on dark, so a 1.5px chart + stroke stays legible against either canvas. */ + --provider-claude: #c96442; + --provider-codex: oklch(0.32 0 0); --primary-foreground: var(--color-white); --secondary: --alpha(var(--color-black) / 4%); --secondary-foreground: var(--foreground); @@ -265,6 +274,8 @@ --primary: var(--app-accent-blue); --primary-readable: oklch(0.65 0.19 264); --primary-graph: oklch(0.7 0.17 264); + --provider-claude: #e08b66; + --provider-codex: oklch(0.9 0 0); --primary-foreground: var(--color-white); --secondary: --alpha(var(--color-white) / 8%); --secondary-foreground: var(--foreground); diff --git a/apps/web/src/lib/usageReactQuery.ts b/apps/web/src/lib/usageReactQuery.ts new file mode 100644 index 000000000..98822d228 --- /dev/null +++ b/apps/web/src/lib/usageReactQuery.ts @@ -0,0 +1,251 @@ +import { + USAGE_CONTRACT_VERSION, + USAGE_MAX_WINDOW_DAYS, + UsageDay, + type EnvironmentId, + type UsageSummary, + type UsageSummaryInput, + type UsageWindowDays, +} from "@threadlines/contracts"; +import { + makeTodayUsageWindow, + makeUsageWindow, + windowStartDay, +} from "@threadlines/shared/usageFormat"; +import { + filterSummaryWindow, + mergeUsage, + type EnvironmentUsage, + type MergedUsage, +} from "@threadlines/shared/usageMerge"; +import { keepPreviousData, queryOptions } from "@tanstack/react-query"; +import { useMemo } from "react"; + +import { readEnvironmentApi } from "~/environmentApi"; +import { readPrimaryEnvironmentDescriptor, usePrimaryEnvironmentId } from "~/environments/primary"; +import { + useSavedEnvironmentRegistryStore, + useSavedEnvironmentRuntimeStore, +} from "~/environments/runtime"; +import { resolveEnvironmentOptionLabel } from "~/components/BranchToolbar.logic"; + +/** An environment the page will ask for usage, connected or not. */ +export interface UsageEnvironmentTarget { + readonly environmentId: EnvironmentId; + readonly label: string; +} + +/** + * What one environment contributed. + * + * A machine that could not be reached keeps its row with `summary: null` rather + * than disappearing: a total that quietly omits a computer is worse than one + * that says which computer is missing. + */ +export interface UsageEnvironmentReport { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly summary: UsageSummary | null; + readonly error: string | null; +} + +export interface UsageAcrossEnvironments { + readonly window: UsageSummaryInput; + readonly merged: MergedUsage; + readonly environments: readonly UsageEnvironmentReport[]; +} + +/** A few minutes: a transcript scan is disk work, and usage is not live data. */ +const USAGE_STALE_TIME_MS = 5 * 60_000; + +function normalizeTargets( + targets: ReadonlyArray, +): ReadonlyArray { + return [...targets].sort((left, right) => left.environmentId.localeCompare(right.environmentId)); +} + +async function readUsageAcrossEnvironments( + targets: ReadonlyArray, + window: UsageSummaryInput, +): Promise { + const reads = await Promise.allSettled( + targets.map(async (target) => { + const api = readEnvironmentApi(target.environmentId); + if (!api?.usage) { + throw new Error(`Environment ${target.environmentId} is unavailable.`); + } + return api.usage.summary(window); + }), + ); + + const environments: UsageEnvironmentReport[] = targets.map((target, index) => { + const read = reads[index]; + if (read?.status === "fulfilled") { + return { + environmentId: target.environmentId, + label: target.label, + summary: read.value, + error: null, + }; + } + return { + environmentId: target.environmentId, + label: target.label, + summary: null, + error: read?.status === "rejected" ? describeUsageReadFailure(read.reason) : "Unavailable", + }; + }); + + const contributions: EnvironmentUsage[] = environments.flatMap((environment) => + environment.summary + ? [ + { + environmentId: environment.environmentId, + label: environment.label, + summary: environment.summary, + }, + ] + : [], + ); + + return { + window, + merged: mergeUsage(contributions, USAGE_CONTRACT_VERSION), + environments, + }; +} + +function describeUsageReadFailure(reason: unknown): string { + if (reason instanceof Error && reason.message.trim().length > 0) { + return reason.message; + } + return "Unavailable"; +} + +/** + * The page's query. Every environment is asked in parallel and the failures are + * kept alongside the successes, so the merge can be honest about coverage. + * + * Always the longest window the page offers, whatever the selector says. A scan + * is disk work measured in seconds, and every shorter selection is a subset of + * this one, so the 7- and 30-day views are derived rather than fetched. + */ +export function usageSummaryQueryOptions(input: { + readonly targets: ReadonlyArray; +}) { + const targets = normalizeTargets(input.targets); + return queryOptions({ + queryKey: [ + "usage", + "summary", + USAGE_MAX_WINDOW_DAYS, + targets.map((target) => target.environmentId), + ] as const, + // The window is built inside the fetch rather than in the key so a session + // left open past midnight rolls onto the new day at the next refetch. + queryFn: () => readUsageAcrossEnvironments(targets, makeUsageWindow(USAGE_MAX_WINDOW_DAYS)), + enabled: targets.length > 0, + staleTime: USAGE_STALE_TIME_MS, + // A refresh keeps the page on screen: the numbers move, the layout does not. + placeholderData: keepPreviousData, + }); +} + +/** + * Narrows a scanned result to the selected window, client-side. + * + * Anchored on the scan's own last day rather than on "now", so the derived + * window always lines up with the days the scan actually covered. + */ +export function deriveUsageWindow( + scan: UsageAcrossEnvironments, + windowDays: UsageWindowDays, +): UsageAcrossEnvironments { + const untilDay = scan.window.untilDay; + const sinceDay = UsageDay.make(windowStartDay(untilDay, windowDays)); + if (sinceDay <= scan.window.sinceDay) return scan; + + const environments: UsageEnvironmentReport[] = scan.environments.map((environment) => + environment.summary + ? { ...environment, summary: filterSummaryWindow(environment.summary, sinceDay, untilDay) } + : environment, + ); + const contributions: EnvironmentUsage[] = environments.flatMap((environment) => + environment.summary + ? [ + { + environmentId: environment.environmentId, + label: environment.label, + summary: environment.summary, + }, + ] + : [], + ); + + return { + window: { ...scan.window, sinceDay }, + merged: mergeUsage(contributions, USAGE_CONTRACT_VERSION), + environments, + }; +} + +/** + * Today alone, for the sidebar meter. Same key family as the page so the two + * never collide, and a different window so neither refetches for the other. + */ +export function usageTodayQueryOptions(input: { + readonly targets: ReadonlyArray; +}) { + const targets = normalizeTargets(input.targets); + return queryOptions({ + queryKey: ["usage", "summary", "today", targets.map((target) => target.environmentId)] as const, + queryFn: () => readUsageAcrossEnvironments(targets, makeTodayUsageWindow()), + enabled: targets.length > 0, + staleTime: USAGE_STALE_TIME_MS, + }); +} + +/** + * Every environment the app knows about: this device plus each saved backend, + * whether or not it is currently connected. + */ +export function useUsageEnvironmentTargets(): ReadonlyArray { + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const primaryLabel = readPrimaryEnvironmentDescriptor()?.label ?? null; + const savedEnvironmentsById = useSavedEnvironmentRegistryStore((state) => state.byId); + const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((state) => state.byId); + + return useMemo(() => { + const targets: UsageEnvironmentTarget[] = []; + const seen = new Set(); + + if (primaryEnvironmentId) { + seen.add(primaryEnvironmentId); + targets.push({ + environmentId: primaryEnvironmentId, + label: resolveEnvironmentOptionLabel({ + isPrimary: true, + environmentId: primaryEnvironmentId, + runtimeLabel: primaryLabel, + }), + }); + } + + for (const record of Object.values(savedEnvironmentsById)) { + if (seen.has(record.environmentId)) continue; + seen.add(record.environmentId); + targets.push({ + environmentId: record.environmentId, + label: resolveEnvironmentOptionLabel({ + isPrimary: false, + environmentId: record.environmentId, + runtimeLabel: + savedEnvironmentRuntimeById[record.environmentId]?.descriptor?.label ?? null, + savedLabel: record.label, + }), + }); + } + + return normalizeTargets(targets); + }, [primaryEnvironmentId, primaryLabel, savedEnvironmentRuntimeById, savedEnvironmentsById]); +} diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 6991a5f06..2f7b7be7b 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -68,6 +68,9 @@ const rpcClientMock = { filesystem: { browse: vi.fn(), }, + usage: { + summary: vi.fn(), + }, sourceControl: { lookupRepository: vi.fn(), listRepositories: vi.fn(), diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index f4c57490c..b8c92dbf6 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as PairRouteImport } from './routes/pair' import { Route as SettingsRouteImport } from './routes/settings' import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as ChatChatsRouteImport } from './routes/_chat.chats' +import { Route as ChatUsageRouteImport } from './routes/_chat.usage' import { Route as SettingsIndexRouteImport } from './routes/settings.index' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' @@ -51,6 +52,11 @@ const ChatChatsRoute = ChatChatsRouteImport.update({ path: '/chats', getParentRoute: () => ChatRoute, } as any) +const ChatUsageRoute = ChatUsageRouteImport.update({ + id: '/usage', + path: '/usage', + getParentRoute: () => ChatRoute, +} as any) const SettingsIndexRoute = SettingsIndexRouteImport.update({ id: '/', path: '/', @@ -118,6 +124,7 @@ export interface FileRoutesByFullPath { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/chats': typeof ChatChatsRoute + '/usage': typeof ChatUsageRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -134,6 +141,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/pair': typeof PairRoute '/chats': typeof ChatChatsRoute + '/usage': typeof ChatUsageRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -154,6 +162,7 @@ export interface FileRoutesById { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/_chat/chats': typeof ChatChatsRoute + '/_chat/usage': typeof ChatUsageRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -175,6 +184,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/chats' + | '/usage' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -191,6 +201,7 @@ export interface FileRouteTypes { to: | '/pair' | '/chats' + | '/usage' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -210,6 +221,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/_chat/chats' + | '/_chat/usage' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -268,6 +280,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatChatsRouteImport parentRoute: typeof ChatRoute } + '/_chat/usage': { + id: '/_chat/usage' + path: '/usage' + fullPath: '/usage' + preLoaderRoute: typeof ChatUsageRouteImport + parentRoute: typeof ChatRoute + } '/settings/': { id: '/settings/' path: '/' @@ -357,6 +376,7 @@ declare module '@tanstack/react-router' { interface ChatRouteChildren { ChatChatsRoute: typeof ChatChatsRoute + ChatUsageRoute: typeof ChatUsageRoute ChatIndexRoute: typeof ChatIndexRoute ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute ChatDraftDraftIdRoute: typeof ChatDraftDraftIdRoute @@ -364,6 +384,7 @@ interface ChatRouteChildren { const ChatRouteChildren: ChatRouteChildren = { ChatChatsRoute: ChatChatsRoute, + ChatUsageRoute: ChatUsageRoute, ChatIndexRoute: ChatIndexRoute, ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute, ChatDraftDraftIdRoute: ChatDraftDraftIdRoute, diff --git a/apps/web/src/routes/_chat.usage.tsx b/apps/web/src/routes/_chat.usage.tsx new file mode 100644 index 000000000..e894bfc95 --- /dev/null +++ b/apps/web/src/routes/_chat.usage.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { UsageView } from "../components/usage/UsageView"; + +export const Route = createFileRoute("/_chat/usage")({ + component: UsageView, +}); diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index e44357e21..b6788c5c1 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -113,6 +113,9 @@ export interface WsRpcClient { readonly filesystem: { readonly browse: RpcUnaryMethod; }; + readonly usage: { + readonly summary: RpcUnaryMethod; + }; readonly sourceControl: { readonly lookupRepository: RpcUnaryMethod; readonly listRepositories: RpcUnaryMethod; @@ -438,6 +441,9 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { filesystem: { browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)), }, + usage: { + summary: (input) => transport.request((client) => client[WS_METHODS.usageSummary](input)), + }, sourceControl: { lookupRepository: (input) => transport.request((client) => client[WS_METHODS.sourceControlLookupRepository](input)), diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 86661a7ac..150ac186b 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -23,4 +23,5 @@ export * from "./orchestration.ts"; export * from "./editor.ts"; export * from "./project.ts"; export * from "./filesystem.ts"; +export * from "./usage.ts"; export * from "./rpc.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 99c33ecaa..3ed6eae82 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -52,6 +52,7 @@ import type { VcsStatusResult, } from "./git.ts"; import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; +import type { UsageSummary, UsageSummaryInput } from "./usage.ts"; import type { ProjectFaviconInput, ProjectFaviconResult, @@ -1178,6 +1179,14 @@ export interface EnvironmentApi { filesystem: { browse: (input: FilesystemBrowseInput) => Promise; }; + /** + * Token/cost reporting for this environment's provider transcripts. Optional + * so a client can still talk to a server predating the usage scan; callers + * treat its absence the same way they treat an unreachable environment. + */ + usage?: { + summary: (input: UsageSummaryInput) => Promise; + }; sourceControl: { lookupRepository: ( input: SourceControlRepositoryLookupInput, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 02960820c..dcbb4034b 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -14,6 +14,7 @@ import { FilesystemBrowseResult, FilesystemBrowseError, } from "./filesystem.ts"; +import { UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; import { GitActionProgressEvent, VcsCommitDetailsInput, @@ -345,6 +346,9 @@ export const WS_METHODS = { serverGetProviderInstructionFiles: "server.getProviderInstructionFiles", serverWriteProviderInstructionFile: "server.writeProviderInstructionFile", + // Usage reporting methods + usageSummary: "usage.summary", + // Source control methods sourceControlLookupRepository: "sourceControl.lookupRepository", sourceControlListRepositories: "sourceControl.listRepositories", @@ -688,6 +692,12 @@ export const WsSourceControlPublishRepositoryRpc = Rpc.make( }, ); +export const WsUsageSummaryRpc = Rpc.make(WS_METHODS.usageSummary, { + payload: UsageSummaryInput, + success: UsageSummary, + error: UsageReadError, +}); + export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, { payload: ProjectSearchEntriesInput, success: ProjectSearchEntriesResult, @@ -1144,6 +1154,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerReadProviderExtensionMcpResourceRpc, WsServerGetProviderInstructionFilesRpc, WsServerWriteProviderInstructionFileRpc, + WsUsageSummaryRpc, WsSourceControlLookupRepositoryRpc, WsSourceControlListRepositoriesRpc, WsSourceControlCloneRepositoryRpc, diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts new file mode 100644 index 000000000..efb3ab53f --- /dev/null +++ b/packages/contracts/src/usage.ts @@ -0,0 +1,215 @@ +/** + * Usage reporting contract. + * + * Each environment scans the provider CLIs' own on-disk session transcripts + * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`) rather + * than Threadlines' orchestration projections, so usage stays complete even for + * turns that were driven by the CLI, an editor extension, or another tool + * entirely. `ccusage` takes the same approach. + * + * Environments return pre-aggregated `(day, provider, model)` buckets. Raw + * transcript records never cross the wire. + * + * Cost semantics: every `costUsd` in this module is the *API list-price + * equivalent* of the tokens, not billed spend. Subscription plans bill on their + * own terms and the transcripts carry no invoice, so a figure here answers + * "what would this have cost on the API" and nothing else. UI copy must say so. + * + * @module usage + */ +import * as Schema from "effect/Schema"; + +import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +/** + * Bumped whenever the shape of {@link UsageSummary} changes incompatibly. A + * client merging several environments renders partial coverage when one reports + * an older version rather than failing the whole page. + */ +export const USAGE_CONTRACT_VERSION = 1 as const; + +export const UsageProviderKind = Schema.Literals(["claude", "codex"]); +export type UsageProviderKind = typeof UsageProviderKind.Type; + +/** + * A calendar day in the reporting time zone, formatted `YYYY-MM-DD`. + * + * Days are bucketed server-side so a turn always lands on the day the user + * experienced it, not the UTC day. + */ +const USAGE_DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +export const UsageDay = TrimmedNonEmptyString.check(Schema.isPattern(USAGE_DAY_PATTERN)).pipe( + Schema.brand("UsageDay"), +); +export type UsageDay = typeof UsageDay.Type; + +/** + * Window lengths the UI offers. The scan cache retains 90 days, so a longer + * window would cold-scan every time. + */ +export const USAGE_WINDOW_DAY_OPTIONS = [7, 30, 90] as const; + +export const UsageWindowDays = Schema.Literals(USAGE_WINDOW_DAY_OPTIONS); +export type UsageWindowDays = typeof UsageWindowDays.Type; + +/** Longest window the server will scan, in days. Matches the cache retention. */ +export const USAGE_MAX_WINDOW_DAYS = 90 as const; + +/** + * Why a bucket's cost is what it is. + * + * - `providerReported` โ€” the transcript carried an explicit cost figure. + * - `modelPriced` โ€” we matched the model against the LiteLLM rate table. + * - `unpriced` โ€” tokens are known, rates are not. Counted in token totals, + * excluded from cost. + */ +export const UsageCostSource = Schema.Literals(["providerReported", "modelPriced", "unpriced"]); +export type UsageCostSource = typeof UsageCostSource.Type; + +/** + * Token counts for a bucket. + * + * `cachedInputTokens` and `cacheCreationTokens` are disjoint from + * `uncachedInputTokens`; summing all three gives total input. `reasoningTokens` + * is a *subset* of `outputTokens` (Codex reports it that way, and Anthropic + * folds thinking into output), so it must never be added on top. + */ +export const UsageTokenTotals = Schema.Struct({ + uncachedInputTokens: NonNegativeInt, + cachedInputTokens: NonNegativeInt, + cacheCreationTokens: NonNegativeInt, + outputTokens: NonNegativeInt, + reasoningTokens: NonNegativeInt, +}); +export type UsageTokenTotals = typeof UsageTokenTotals.Type; + +/** + * One `(day, provider, model)` cell. + * + * `costUsd` is the API list-price equivalent of these tokens, not money spent. + * `unpricedRecords` counts records whose tokens are included in the token + * totals but which contributed nothing to `costUsd`. + */ +export const UsageBucket = Schema.Struct({ + day: UsageDay, + provider: UsageProviderKind, + model: TrimmedNonEmptyString, + totals: UsageTokenTotals, + costUsd: Schema.Number, + /** + * What the cached input would have cost at full input rates minus what it + * actually cost. Requires the rate table, so it is computed alongside cost + * rather than derived on the client. + */ + cacheSavingsUsd: Schema.Number, + costSource: UsageCostSource, + /** Distinct assistant responses, after de-duplication. */ + records: NonNegativeInt, + unpricedRecords: NonNegativeInt, + /** Distinct transcript sessions that contributed to this cell. */ + sessions: NonNegativeInt, +}); +export type UsageBucket = typeof UsageBucket.Type; + +/** + * Identifies the physical transcript directory a source read from. + * + * Two environments on one machine (worktree servers, for example) resolve the + * same provider home and would otherwise double count. The client drops + * duplicate fingerprints before merging. + */ +export const UsageSourceFingerprint = Schema.Struct({ + hostId: TrimmedNonEmptyString, + provider: UsageProviderKind, + resolvedHomePath: TrimmedNonEmptyString, + /** + * Filesystem identity of the transcript directory, as `device:inode`. + * + * Hostname and path alone are not enough: every Mac resolves + * `/Users//.claude`, so two machines that happen to share a hostname + * would look like one source and have one of them silently dropped. The + * device/inode pair is stable for two servers reading the same directory and + * effectively never collides across machines. Empty when it cannot be read. + */ + volumeId: Schema.String, +}); +export type UsageSourceFingerprint = typeof UsageSourceFingerprint.Type; + +export const UsageSourceStatus = Schema.Literals(["ok", "missing", "partial", "failed"]); +export type UsageSourceStatus = typeof UsageSourceStatus.Type; + +export const UsageSource = Schema.Struct({ + fingerprint: UsageSourceFingerprint, + status: UsageSourceStatus, + /** + * When this directory was last walked, as an ISO instant. Warm scans reuse + * memoised per-file results, so this is the only honest freshness signal the + * client has for a source. + */ + lastScannedAt: Schema.String, + scannedFiles: NonNegativeInt, + skippedFiles: NonNegativeInt, + /** + * Distinct transcript sessions seen under this directory. Buckets also carry + * per-bucket session counts, but a session spans days and models, so summing + * those overcounts; this is the figure clients should total. + */ + distinctSessions: NonNegativeInt, + message: Schema.NullOr(TrimmedNonEmptyString), +}); +export type UsageSource = typeof UsageSource.Type; + +export const UsagePricingStatus = Schema.Literals(["fresh", "cached", "unavailable"]); +export type UsagePricingStatus = typeof UsagePricingStatus.Type; + +/** + * Provenance for the rate table, so the UI can be honest about how good the + * cost figures are. + */ +export const UsagePricing = Schema.Struct({ + status: UsagePricingStatus, + source: TrimmedNonEmptyString, + fetchedAt: Schema.NullOr(Schema.String), + knownModels: NonNegativeInt, +}); +export type UsagePricing = typeof UsagePricing.Type; + +export const UsageSummaryInput = Schema.Struct({ + /** Inclusive first day of the window, in `timeZone`. */ + sinceDay: UsageDay, + /** Inclusive last day of the window, in `timeZone`. */ + untilDay: UsageDay, + /** + * IANA zone the client wants days bucketed in. A fixed offset would be wrong + * for any window that crosses a DST boundary, and the viewer's machine is + * often not the machine holding the transcripts. + */ + timeZone: TrimmedNonEmptyString, +}); +export type UsageSummaryInput = typeof UsageSummaryInput.Type; + +export const UsageSummary = Schema.Struct({ + contractVersion: Schema.Number, + readAt: Schema.String, + timeZone: TrimmedNonEmptyString, + sinceDay: UsageDay, + untilDay: UsageDay, + buckets: Schema.Array(UsageBucket), + sources: Schema.Array(UsageSource), + pricing: UsagePricing, + /** Wall-clock cost of the scan, surfaced in diagnostics. */ + scanDurationMs: NonNegativeInt, +}); +export type UsageSummary = typeof UsageSummary.Type; + +export class UsageReadError extends Schema.TaggedErrorClass()("UsageReadError", { + reason: Schema.Literals(["scanFailed", "invalidWindow"]), + /** Stable, bounded description. The underlying failure travels in `cause`. */ + detail: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), +}) { + override get message(): string { + return `Usage read failed (${this.reason}): ${this.detail}`; + } +} diff --git a/packages/shared/package.json b/packages/shared/package.json index b64a2b328..316bfeb7c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -174,6 +174,14 @@ "./uuid": { "types": "./src/uuid.ts", "import": "./src/uuid.ts" + }, + "./usageMerge": { + "types": "./src/usageMerge.ts", + "import": "./src/usageMerge.ts" + }, + "./usageFormat": { + "types": "./src/usageFormat.ts", + "import": "./src/usageFormat.ts" } }, "scripts": { diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts new file mode 100644 index 000000000..5323add58 --- /dev/null +++ b/packages/shared/src/usageFormat.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + enumerateDays, + formatDayShort, + formatTokens, + formatTokensCompact, + makeTodayUsageWindow, + makeUsageWindow, + windowStartDay, +} from "./usageFormat.ts"; + +describe("formatTokens", () => { + it("compacts to three significant figures with a unit suffix", () => { + expect(formatTokens(999)).toBe("999"); + expect(formatTokens(1_040)).toBe("1.04K"); + expect(formatTokens(804_000)).toBe("804K"); + expect(formatTokens(2_400_000)).toBe("2.40M"); + expect(formatTokens(2_000_000)).toBe("2M"); + expect(formatTokens(19_900_000_000)).toBe("19.9B"); + }); +}); + +describe("formatTokensCompact", () => { + it("drops trailing zeros but keeps significant fraction digits", () => { + expect(formatTokensCompact(2_400_000)).toBe("2.4M"); + expect(formatTokensCompact(2_000_000)).toBe("2M"); + expect(formatTokensCompact(1_040)).toBe("1.04K"); + expect(formatTokensCompact(999)).toBe("999"); + }); +}); + +describe("formatDayShort", () => { + it("renders a calendar day without shifting it into another zone", () => { + expect(formatDayShort("2026-08-07")).toBe("Aug 7"); + expect(formatDayShort("2026-01-01")).toBe("Jan 1"); + }); +}); + +describe("enumerateDays", () => { + it("includes both bounds and spans month ends", () => { + expect(enumerateDays("2026-01-30", "2026-02-02")).toEqual([ + "2026-01-30", + "2026-01-31", + "2026-02-01", + "2026-02-02", + ]); + }); + + it("returns nothing when the range is inverted", () => { + expect(enumerateDays("2026-02-02", "2026-01-30")).toEqual([]); + }); +}); + +describe("windowStartDay", () => { + it("counts the end day itself and walks back across a month boundary", () => { + expect(windowStartDay("2026-08-10", 30)).toBe("2026-07-12"); + expect(windowStartDay("2026-03-03", 7)).toBe("2026-02-25"); + expect(windowStartDay("2026-08-10", 1)).toBe("2026-08-10"); + }); +}); + +describe("makeUsageWindow", () => { + it("ends today and spans the requested number of days inclusively", () => { + const window = makeUsageWindow(7, new Date("2026-03-10T18:00:00Z")); + expect(enumerateDays(window.sinceDay, window.untilDay)).toHaveLength(7); + expect(window.timeZone.length).toBeGreaterThan(0); + }); + + it("walks back across a month boundary", () => { + const window = makeUsageWindow(7, new Date("2026-03-03T18:00:00Z")); + expect(enumerateDays(window.sinceDay, window.untilDay)).toHaveLength(7); + expect(window.sinceDay < window.untilDay).toBe(true); + }); +}); + +describe("makeTodayUsageWindow", () => { + it("covers a single day", () => { + const window = makeTodayUsageWindow(new Date("2026-03-10T18:00:00Z")); + expect(window.sinceDay).toBe(window.untilDay); + }); +}); diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts new file mode 100644 index 000000000..96dc25035 --- /dev/null +++ b/packages/shared/src/usageFormat.ts @@ -0,0 +1,155 @@ +// @effect-diagnostics globalDate:off -- Usage windows are calendar days in the viewer's zone, derived from wall-clock "now" via Intl. +/** + * Display formatting and window construction for the usage page. + * + * Costs formatted here are API list-price equivalents, not billed spend; the + * surrounding UI copy has to say so. + * + * @module usageFormat + */ +import { UsageDay, type UsageSummaryInput, type UsageWindowDays } from "@threadlines/contracts"; + +const CURRENCY = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +const INTEGER = new Intl.NumberFormat("en-US"); + +const DAY_MS = 86_400_000; + +export function formatUsd(value: number): string { + return CURRENCY.format(value); +} + +export function formatCount(value: number): string { + return INTEGER.format(Math.round(value)); +} + +/** + * Compacts a token count to three significant figures with a unit suffix, so + * columns of numbers line up at a glance (`19.9B`, `76.7M`, `804K`). + */ +export function formatTokens(value: number): string { + const abs = Math.abs(value); + if (abs >= 1e12) return `${trim(value / 1e12)}T`; + if (abs >= 1e9) return `${trim(value / 1e9)}B`; + if (abs >= 1e6) return `${trim(value / 1e6)}M`; + if (abs >= 1e3) return `${trim(value / 1e3)}K`; + return INTEGER.format(Math.round(value)); +} + +function trim(value: number): string { + const abs = Math.abs(value); + const digits = abs >= 100 ? 0 : abs >= 10 ? 1 : 2; + return value.toFixed(digits).replace(/\.0+$/, ""); +} + +/** + * {@link formatTokens} without trailing zeros ("2.4M", never "2.40M"), for + * one-off figures like the sidebar meter. Tables keep the padded form so + * fraction digits line up. + */ +export function formatTokensCompact(value: number): string { + return formatTokens(value) + .replace(/(\.\d*?)0+(?=[A-Z]?$)/, "$1") + .replace(/\.(?=[A-Z]?$)/, ""); +} + +export function formatPercent(share: number, digits = 1): string { + return `${(share * 100).toFixed(digits)}%`; +} + +const MONTHS = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +] as const; + +/** `2026-08-07` to `Aug 7`. */ +export function formatDayShort(day: string): string { + const [year, month, dayOfMonth] = day.split("-").map((part) => Number(part)); + if (year === undefined || month === undefined || dayOfMonth === undefined) return day; + return `${MONTHS[month - 1] ?? ""} ${dayOfMonth}`; +} + +/** Inclusive day list between two `YYYY-MM-DD` bounds. */ +export function enumerateDays(sinceDay: string, untilDay: string): readonly string[] { + const days: string[] = []; + const start = Date.parse(`${sinceDay}T00:00:00Z`); + const end = Date.parse(`${untilDay}T00:00:00Z`); + if (Number.isNaN(start) || Number.isNaN(end) || end < start) return days; + + for (let cursor = start; cursor <= end; cursor += DAY_MS) { + days.push(new Date(cursor).toISOString().slice(0, 10)); + } + return days; +} + +/** + * Inclusive first day of a window of `windowDays` ending on `untilDay`. + * + * The page fetches one long window and narrows it on the client, so the shorter + * selections have to be anchored to the day the scan actually ended on rather + * than to a fresh "now" that may already have rolled over. + */ +export function windowStartDay(untilDay: string, windowDays: number): string { + const [year = 0, month = 1, dayOfMonth = 1] = untilDay + .split("-") + .map((part) => Number.parseInt(part, 10)); + // Calendar arithmetic in UTC, where every day is the same length. + const start = new Date(Date.UTC(year, month - 1, dayOfMonth - (windowDays - 1))); + return start.toISOString().slice(0, 10); +} + +/** + * The window the page requests for a 7/30/90-day selection, expressed in the + * viewer's own time zone so days line up with what they actually experienced. + * + * The zone travels with the request rather than being inferred server-side: the + * machine holding the transcripts is often not the machine looking at them. + */ +export function makeUsageWindow(windowDays: UsageWindowDays, now = new Date()): UsageSummaryInput { + return buildUsageWindow(windowDays, now); +} + +/** + * Today alone, for the compact live figure in the sidebar footer. + * + * Separate from {@link makeUsageWindow} because one day is not one of the + * windows the page offers, and widening that function's type would invite + * arbitrary windows the scan cache is not sized for. + */ +export function makeTodayUsageWindow(now = new Date()): UsageSummaryInput { + return buildUsageWindow(1, now); +} + +function buildUsageWindow(windowDays: number, now: Date): UsageSummaryInput { + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + const format = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + const untilDay = format.format(now); + // Subtracting fixed milliseconds from `now` lands on the wrong calendar day + // around a DST transition. Only "today" needs the zone; the window start is + // pure calendar arithmetic on that day, done in UTC where days are uniform. + return { + sinceDay: UsageDay.make(windowStartDay(untilDay, windowDays)), + untilDay: UsageDay.make(untilDay), + timeZone, + }; +} diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts new file mode 100644 index 000000000..38bacaa79 --- /dev/null +++ b/packages/shared/src/usageMerge.test.ts @@ -0,0 +1,316 @@ +import { + USAGE_CONTRACT_VERSION, + type EnvironmentId, + type UsageBucket, + type UsageDay, + type UsageProviderKind, + type UsageSummary, +} from "@threadlines/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { filterSummaryWindow, mergeUsage, type EnvironmentUsage } from "./usageMerge.ts"; + +function bucket(overrides: Partial = {}): UsageBucket { + return { + day: "2026-08-07" as UsageDay, + provider: "claude", + model: "claude-fable-5", + totals: { + uncachedInputTokens: 100, + cachedInputTokens: 1000, + cacheCreationTokens: 10, + outputTokens: 50, + reasoningTokens: 0, + }, + costUsd: 10, + cacheSavingsUsd: 2, + costSource: "modelPriced", + records: 5, + unpricedRecords: 0, + sessions: 1, + ...overrides, + }; +} + +interface SourceSpec { + readonly provider: UsageProviderKind; + readonly hostId: string; + readonly homePath: string; + readonly volumeId?: string; + readonly distinctSessions?: number; + readonly lastScannedAt?: string; +} + +function summary( + buckets: readonly UsageBucket[], + sources: readonly SourceSpec[], + contractVersion: number = USAGE_CONTRACT_VERSION, +): UsageSummary { + return { + contractVersion, + readAt: "2026-08-07T00:00:00.000Z", + timeZone: "UTC", + sinceDay: "2026-08-01" as UsageDay, + untilDay: "2026-08-31" as UsageDay, + buckets, + sources: sources.map((source) => ({ + fingerprint: { + hostId: source.hostId, + provider: source.provider, + resolvedHomePath: source.homePath, + volumeId: source.volumeId ?? `vol-${source.hostId}`, + }, + status: "ok" as const, + lastScannedAt: source.lastScannedAt ?? "2026-08-07T00:00:00.000Z", + scannedFiles: 1, + skippedFiles: 0, + distinctSessions: source.distinctSessions ?? 1, + message: null, + })), + pricing: { status: "fresh", source: "litellm", fetchedAt: null, knownModels: 10 }, + scanDurationMs: 1, + }; +} + +function environment(id: string, usageSummary: UsageSummary): EnvironmentUsage { + return { environmentId: id as EnvironmentId, label: id, summary: usageSummary }; +} + +const claudeSource = (hostId: string): SourceSpec => ({ + provider: "claude", + hostId, + homePath: "/Users/will/.claude/projects", +}); + +describe("mergeUsage", () => { + it("sums environments that read different transcript directories", () => { + const merged = mergeUsage( + [ + environment("env-a", summary([bucket()], [claudeSource("mac")])), + environment( + "env-b", + summary([bucket({ costUsd: 4 })], [{ ...claudeSource("windows"), distinctSessions: 3 }]), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(14); + expect(merged.records).toBe(10); + expect(merged.sessions).toBe(4); + expect(merged.contributingEnvironments).toEqual(["env-a", "env-b"]); + expect(merged.duplicateSources).toEqual([]); + }); + + it("counts a directory once when two environments on one machine scan it", () => { + // Worktree servers resolve the same provider home; summing both would + // double every token on that machine. + const merged = mergeUsage( + [ + environment("env-a", summary([bucket()], [claudeSource("mac")])), + environment("env-b", summary([bucket()], [claudeSource("mac")])), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(10); + expect(merged.sessions).toBe(1); + expect(merged.contributingEnvironments).toEqual(["env-a"]); + expect(merged.duplicateSources).toEqual(["env-b: /Users/will/.claude/projects"]); + }); + + it("keeps two machines apart when hostname and home path collide", () => { + // Every Mac with the default computer name resolves the same path, so only + // the filesystem identity can tell them apart. + const merged = mergeUsage( + [ + environment( + "env-a", + summary([bucket()], [{ ...claudeSource("macbook-pro"), volumeId: "1:2" }]), + ), + environment( + "env-b", + summary([bucket()], [{ ...claudeSource("macbook-pro"), volumeId: "3:4" }]), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(20); + expect(merged.duplicateSources).toEqual([]); + }); + + it("drops only the duplicated provider, not the whole environment", () => { + const merged = mergeUsage( + [ + environment("env-a", summary([bucket()], [claudeSource("mac")])), + environment( + "env-b", + summary( + [bucket(), bucket({ provider: "codex", model: "gpt-5.6-sol", costUsd: 3 })], + [claudeSource("mac"), { provider: "codex", hostId: "mac", homePath: "/x/.codex" }], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(13); + expect(merged.providers.map((provider) => provider.provider)).toEqual(["claude", "codex"]); + }); + + it("excludes an environment on an older contract instead of failing", () => { + const merged = mergeUsage( + [ + environment("env-a", summary([bucket()], [claudeSource("mac")])), + environment( + "env-b", + summary([bucket()], [claudeSource("windows")], USAGE_CONTRACT_VERSION - 1), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(10); + expect(merged.staleEnvironments).toEqual(["env-b"]); + }); + + it("splits cost quality across the three provenances", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [ + bucket({ records: 2, costSource: "providerReported" }), + bucket({ + day: "2026-08-08" as UsageDay, + records: 6, + unpricedRecords: 3, + costSource: "modelPriced", + }), + ], + [claudeSource("mac")], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.records).toBe(8); + expect(merged.costQuality.providerReportedShare).toBeCloseTo(2 / 8, 9); + expect(merged.costQuality.unpricedShare).toBeCloseTo(3 / 8, 9); + expect(merged.costQuality.modelPricedShare).toBeCloseTo(3 / 8, 9); + expect(merged.costQuality.cacheSavingsUsd).toBe(4); + }); + + it("reports the oldest scan across contributing sources", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary([bucket()], [{ ...claudeSource("mac"), lastScannedAt: "2026-08-07T09:00:00Z" }]), + ), + environment( + "env-b", + summary( + [bucket()], + [{ ...claudeSource("windows"), lastScannedAt: "2026-08-07T06:00:00Z" }], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.oldestScanAt).toBe("2026-08-07T06:00:00Z"); + }); + + it("totals tokens without adding reasoning on top of output", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [ + bucket({ + totals: { + uncachedInputTokens: 1, + cachedInputTokens: 2, + cacheCreationTokens: 3, + outputTokens: 4, + reasoningTokens: 2, + }, + }), + ], + [claudeSource("mac")], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.totalTokens).toBe(10); + expect(merged.reasoningTokens).toBe(2); + }); +}); + +describe("filterSummaryWindow", () => { + it("keeps the days on both bounds and drops the ones outside", () => { + const scanned = summary( + [ + bucket({ day: "2026-08-04" as UsageDay }), + bucket({ day: "2026-08-05" as UsageDay }), + bucket({ day: "2026-08-07" as UsageDay }), + bucket({ day: "2026-08-08" as UsageDay }), + ], + [claudeSource("mac")], + ); + + const narrowed = filterSummaryWindow( + scanned, + "2026-08-05" as UsageDay, + "2026-08-07" as UsageDay, + ); + + expect(narrowed.buckets.map((entry) => entry.day)).toEqual(["2026-08-05", "2026-08-07"]); + expect(narrowed.sinceDay).toBe("2026-08-05"); + expect(narrowed.untilDay).toBe("2026-08-07"); + }); + + it("passes the scan's own sources and pricing through untouched", () => { + const scanned = summary([bucket()], [claudeSource("mac")]); + + const narrowed = filterSummaryWindow( + scanned, + "2026-08-07" as UsageDay, + "2026-08-07" as UsageDay, + ); + + expect(narrowed.sources).toBe(scanned.sources); + expect(narrowed.pricing).toBe(scanned.pricing); + expect(narrowed.readAt).toBe(scanned.readAt); + }); + + it("re-merges to the totals the narrower window alone would have produced", () => { + const scanned = summary( + [ + bucket({ day: "2026-08-01" as UsageDay, costUsd: 10 }), + bucket({ day: "2026-08-07" as UsageDay, costUsd: 4 }), + ], + [claudeSource("mac")], + ); + + const merged = mergeUsage( + [ + environment( + "env-a", + filterSummaryWindow(scanned, "2026-08-05" as UsageDay, "2026-08-07" as UsageDay), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(4); + expect(merged.daily.map((entry) => entry.day)).toEqual(["2026-08-07"]); + }); +}); diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts new file mode 100644 index 000000000..e3ffa99c5 --- /dev/null +++ b/packages/shared/src/usageMerge.ts @@ -0,0 +1,395 @@ +/** + * Merges per-environment usage summaries into the single view the page renders. + * + * Pure, so the de-duplication and derivation rules can be tested without a + * connected environment. + * + * Every `costUsd` here is an API list-price equivalent, not billed spend. + * + * @module usageMerge + */ +import type { + EnvironmentId, + UsageBucket, + UsageDay, + UsageProviderKind, + UsageSourceFingerprint, + UsageSummary, +} from "@threadlines/contracts"; + +/** + * Narrows a scanned summary to a shorter day range. + * + * The page scans once for the longest window it offers and derives the 7- and + * 30-day views from that result, so switching windows is arithmetic rather than + * another disk walk. Sources, pricing and freshness describe the scan itself and + * pass through untouched: they do not become less true for a shorter window. + */ +export function filterSummaryWindow( + summary: UsageSummary, + sinceDay: UsageDay, + untilDay: UsageDay, +): UsageSummary { + return { + ...summary, + sinceDay, + untilDay, + // `YYYY-MM-DD` days with fixed width compare correctly as strings. + buckets: summary.buckets.filter((bucket) => bucket.day >= sinceDay && bucket.day <= untilDay), + }; +} + +export interface EnvironmentUsage { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly summary: UsageSummary; +} + +export interface ProviderTotals { + readonly provider: UsageProviderKind; + readonly costUsd: number; + readonly totalTokens: number; + readonly records: number; + readonly costShare: number; + readonly tokenShare: number; +} + +export interface ModelTotals { + readonly model: string; + readonly provider: UsageProviderKind; + readonly costUsd: number; + readonly totalTokens: number; + readonly records: number; + readonly costShare: number; +} + +export interface DailyTotals { + readonly day: string; + readonly costUsd: number; + readonly totalTokens: number; + readonly byProvider: ReadonlyMap; +} + +export interface CostQuality { + readonly providerReportedShare: number; + readonly modelPricedShare: number; + readonly unpricedShare: number; + readonly cacheSavingsUsd: number; +} + +export interface MergedUsage { + readonly costUsd: number; + readonly uncachedInputTokens: number; + readonly cachedInputTokens: number; + readonly cacheCreationTokens: number; + readonly outputTokens: number; + readonly reasoningTokens: number; + readonly totalTokens: number; + readonly records: number; + readonly sessions: number; + readonly providers: readonly ProviderTotals[]; + readonly models: readonly ModelTotals[]; + readonly daily: readonly DailyTotals[]; + readonly costQuality: CostQuality; + /** Environments whose data was dropped as a duplicate of another's. */ + readonly duplicateSources: readonly string[]; + readonly contributingEnvironments: readonly EnvironmentId[]; + readonly staleEnvironments: readonly EnvironmentId[]; + /** Oldest `lastScannedAt` across contributing sources, or `null` when none. */ + readonly oldestScanAt: string | null; +} + +/** + * Two sources are the same physical transcript directory only when host, + * provider, path and filesystem identity all agree. + * + * `volumeId` is what stops two machines that happen to share a hostname and a + * home path, which is every Mac with the default computer name, from collapsing + * into one source and having one of them silently dropped. + */ +function fingerprintKey(fingerprint: UsageSourceFingerprint): string { + return [ + fingerprint.hostId, + fingerprint.provider, + fingerprint.resolvedHomePath, + fingerprint.volumeId, + ].join(" "); +} + +/** + * Decides which environment owns each physical transcript directory. + * + * Several environments on one machine (worktree servers, for instance) resolve + * the same provider home and would otherwise double count every token. The + * first environment in a stable order claims a fingerprint; the rest have that + * provider's buckets dropped. Environments are sorted by id so the winner does + * not change between renders. + */ +function claimSources(environments: readonly EnvironmentUsage[]): { + readonly ownerByFingerprint: ReadonlyMap; + readonly duplicates: readonly string[]; +} { + const ownerByFingerprint = new Map(); + const duplicates: string[] = []; + + const ordered = [...environments].sort((a, b) => a.environmentId.localeCompare(b.environmentId)); + + for (const environment of ordered) { + for (const source of environment.summary.sources) { + if (source.status === "missing") continue; + const key = fingerprintKey(source.fingerprint); + if (ownerByFingerprint.has(key)) { + duplicates.push(`${environment.label}: ${source.fingerprint.resolvedHomePath}`); + continue; + } + ownerByFingerprint.set(key, environment.environmentId); + } + } + + return { ownerByFingerprint, duplicates }; +} + +/** Sources this environment owns after fingerprint claims, plus their buckets. */ +function ownedContribution( + environment: EnvironmentUsage, + ownerByFingerprint: ReadonlyMap, +): { + readonly buckets: readonly UsageBucket[]; + readonly sessions: number; + readonly oldestScanAt: string | null; +} { + const ownedProviders = new Set(); + let sessions = 0; + let oldestScanAt: string | null = null; + for (const source of environment.summary.sources) { + if (source.status === "missing") continue; + const key = fingerprintKey(source.fingerprint); + if (ownerByFingerprint.get(key) !== environment.environmentId) continue; + ownedProviders.add(source.fingerprint.provider); + // Distinct within a directory. Summing per-bucket session counts instead + // would count a session once per day and model it spans. + sessions += source.distinctSessions; + // ISO instants with the same precision sort lexicographically. + if (oldestScanAt === null || source.lastScannedAt < oldestScanAt) { + oldestScanAt = source.lastScannedAt; + } + } + return { + buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)), + sessions, + oldestScanAt, + }; +} + +function bucketTokens(bucket: UsageBucket): number { + // reasoningTokens is a subset of outputTokens and must not be added again. + return ( + bucket.totals.uncachedInputTokens + + bucket.totals.cachedInputTokens + + bucket.totals.cacheCreationTokens + + bucket.totals.outputTokens + ); +} + +const EMPTY_MERGED: MergedUsage = { + costUsd: 0, + uncachedInputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + records: 0, + sessions: 0, + providers: [], + models: [], + daily: [], + costQuality: { + providerReportedShare: 0, + modelPricedShare: 0, + unpricedShare: 0, + cacheSavingsUsd: 0, + }, + duplicateSources: [], + contributingEnvironments: [], + staleEnvironments: [], + oldestScanAt: null, +}; + +/** + * Merges every connected environment's summary. + * + * `expectedContractVersion` guards against an environment running older server + * code: rather than blocking the page, its data is excluded and its id is + * reported so the UI can say coverage is partial. + */ +export function mergeUsage( + environments: readonly EnvironmentUsage[], + expectedContractVersion: number, +): MergedUsage { + if (environments.length === 0) return EMPTY_MERGED; + + const current: EnvironmentUsage[] = []; + const staleEnvironments: EnvironmentId[] = []; + for (const environment of environments) { + if (environment.summary.contractVersion === expectedContractVersion) { + current.push(environment); + } else { + staleEnvironments.push(environment.environmentId); + } + } + + const { ownerByFingerprint, duplicates } = claimSources(current); + + let costUsd = 0; + let uncachedInputTokens = 0; + let cachedInputTokens = 0; + let cacheCreationTokens = 0; + let outputTokens = 0; + let reasoningTokens = 0; + let records = 0; + let sessions = 0; + let cacheSavingsUsd = 0; + let providerReportedRecords = 0; + let unpricedRecords = 0; + let oldestScanAt: string | null = null; + + const providerAccumulator = new Map< + UsageProviderKind, + { costUsd: number; totalTokens: number; records: number } + >(); + const modelAccumulator = new Map< + string, + { provider: UsageProviderKind; costUsd: number; totalTokens: number; records: number } + >(); + const dailyAccumulator = new Map< + string, + { + costUsd: number; + totalTokens: number; + byProvider: Map; + } + >(); + const contributingEnvironments: EnvironmentId[] = []; + + for (const environment of current) { + const contribution = ownedContribution(environment, ownerByFingerprint); + if (contribution.buckets.length > 0) contributingEnvironments.push(environment.environmentId); + sessions += contribution.sessions; + if ( + contribution.oldestScanAt !== null && + (oldestScanAt === null || contribution.oldestScanAt < oldestScanAt) + ) { + oldestScanAt = contribution.oldestScanAt; + } + + for (const bucket of contribution.buckets) { + const tokens = bucketTokens(bucket); + + costUsd += bucket.costUsd; + cacheSavingsUsd += bucket.cacheSavingsUsd; + uncachedInputTokens += bucket.totals.uncachedInputTokens; + cachedInputTokens += bucket.totals.cachedInputTokens; + cacheCreationTokens += bucket.totals.cacheCreationTokens; + outputTokens += bucket.totals.outputTokens; + reasoningTokens += bucket.totals.reasoningTokens; + records += bucket.records; + unpricedRecords += bucket.unpricedRecords; + if (bucket.costSource === "providerReported") providerReportedRecords += bucket.records; + + const provider = providerAccumulator.get(bucket.provider) ?? { + costUsd: 0, + totalTokens: 0, + records: 0, + }; + provider.costUsd += bucket.costUsd; + provider.totalTokens += tokens; + provider.records += bucket.records; + providerAccumulator.set(bucket.provider, provider); + + const modelKey = `${bucket.provider} ${bucket.model}`; + const model = modelAccumulator.get(modelKey) ?? { + provider: bucket.provider, + costUsd: 0, + totalTokens: 0, + records: 0, + }; + model.costUsd += bucket.costUsd; + model.totalTokens += tokens; + model.records += bucket.records; + modelAccumulator.set(modelKey, model); + + const day = dailyAccumulator.get(bucket.day) ?? { + costUsd: 0, + totalTokens: 0, + byProvider: new Map(), + }; + day.costUsd += bucket.costUsd; + day.totalTokens += tokens; + const dayProvider = day.byProvider.get(bucket.provider) ?? { costUsd: 0, totalTokens: 0 }; + dayProvider.costUsd += bucket.costUsd; + dayProvider.totalTokens += tokens; + day.byProvider.set(bucket.provider, dayProvider); + dailyAccumulator.set(bucket.day, day); + } + } + + const totalTokens = uncachedInputTokens + cachedInputTokens + cacheCreationTokens + outputTokens; + + const providers: ProviderTotals[] = [...providerAccumulator.entries()] + .map(([provider, totals]) => ({ + provider, + costUsd: totals.costUsd, + totalTokens: totals.totalTokens, + records: totals.records, + costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, + tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens, + })) + .sort((a, b) => b.costUsd - a.costUsd); + + const models: ModelTotals[] = [...modelAccumulator.entries()] + .map(([key, totals]) => ({ + model: key.slice(key.indexOf(" ") + 1), + provider: totals.provider, + costUsd: totals.costUsd, + totalTokens: totals.totalTokens, + records: totals.records, + costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, + })) + .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens); + + const daily: DailyTotals[] = [...dailyAccumulator.entries()] + .map(([day, totals]) => ({ + day, + costUsd: totals.costUsd, + totalTokens: totals.totalTokens, + byProvider: totals.byProvider, + })) + .sort((a, b) => a.day.localeCompare(b.day)); + + return { + costUsd, + uncachedInputTokens, + cachedInputTokens, + cacheCreationTokens, + outputTokens, + reasoningTokens, + totalTokens, + records, + sessions, + providers, + models, + daily, + costQuality: { + providerReportedShare: records === 0 ? 0 : providerReportedRecords / records, + unpricedShare: records === 0 ? 0 : unpricedRecords / records, + modelPricedShare: + records === 0 ? 0 : (records - providerReportedRecords - unpricedRecords) / records, + cacheSavingsUsd, + }, + duplicateSources: duplicates, + contributingEnvironments, + staleEnvironments, + oldestScanAt, + }; +}