1/**
2 * UsageService - reads every driver's usage history and returns priced usage
3 * buckets.
4 *
5 * Each built-in driver with a `usage` reader contributes its sources, including
6 * work driven outside T3 Code. A `transcripts` reader names JSONL directories
7 * that this service streams itself; a `scan` reader reads its own sources.
8 *
9 * JSONL transcripts are append-only, so parsed records are memoised per file by
10 * `(size, mtime)`. A cold 30-day scan of ~1.4 GB lands around 2-3 seconds; warm
11 * scans only reparse files that changed, and a file that merely grew resumes
12 * from its cached parse position so only the appended bytes are read.
13 *
14 * A scan reader with a slow source (an account API) may answer from its own
15 * cache and mark itself `refreshing` while a background refresh runs;
16 * `awaitRefresh` waits for that refresh instead.
17 *
18 * @module UsageService
19 */
20import * as NodeOS from "node:os";
21
22import {
23 type ProviderInstanceConfig,
24 ProviderInstanceId,
25 USAGE_CONTRACT_VERSION,
26 type ServerSettings as ServerSettingsValue,
27 type UsageProviderKind,
28 type UsageSource,
29 type UsagePricing,
30 type UsageSummary,
31 type UsageSummaryInput,
32 UsageReadError,
33} from "@t3tools/contracts";
34import * as HostProcess from "@t3tools/shared/HostProcess";
35import * as Cause from "effect/Cause";
36import * as Clock from "effect/Clock";
37import * as Context from "effect/Context";
38import * as DateTime from "effect/DateTime";
39import * as Deferred from "effect/Deferred";
40import * as Effect from "effect/Effect";
41import * as Exit from "effect/Exit";
42import * as Fiber from "effect/Fiber";
43import * as FileSystem from "effect/FileSystem";
44import * as Layer from "effect/Layer";
45import * as Option from "effect/Option";
46import * as Path from "effect/Path";
47import * as Schema from "effect/Schema";
48import * as Semaphore from "effect/Semaphore";
49import { HttpClient, HttpClientResponse } from "effect/http";
50
51import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
52import * as ServerConfig from "../config.ts";
53import * as ServerSettings from "../serverSettings.ts";
54import { BUILT_IN_USAGE_DRIVERS, type BuiltInUsageReadersEnv } from "../provider/builtInDrivers.ts";
55import type { ProviderDriver } from "@t3tools/provider-core/server/driver";
56import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
57import type {
58 ProviderUsageInstance,
59 TranscriptUsageFormat,
60 UsageRecord,
61} from "@t3tools/provider-core/server/usage";
62import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts";
63import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts";
64import {
65 listTranscriptFiles,
66 readDirectoryVolumeId,
67 readTranscriptRecords,
68} from "./usageTranscriptReader.ts";
69import {
70 decodeScanCache,
71 dedupeWithinFile,
72 LEGACY_SCAN_CACHE_FILE_NAME,
73 makeScanCacheWriter,
74 pruneScanCache,
75 SCAN_CACHE_FILE_NAME,
76 type CachedFile,
77 type ScanCache,
78} from "./usageScanCache.ts";
79
80const LITELLM_RATES_URL =
81 "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
82
83/** Rates move rarely; a day-old table keeps the page working offline. */
84const RATES_TTL_MS = 24 * 60 * 60 * 1000;
85
86/** An explicit refresh ignores the TTL, but not a table fetched this recently. */
87const RATES_REFRESH_FLOOR_MS = 60 * 1000;
88
89/**
90 * Files are filtered by mtime before opening. The slack covers a session whose
91 * last write lands just before local midnight on the window's first day.
92 */
93const MTIME_SLACK_MS = 36 * 60 * 60 * 1000;
94const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000;
95
96/**
97 * The longest window the UI offers, 90 days, plus its `MTIME_SLACK_MS`, rounded
98 * up. Older entries are pruned.
99 */
100const CACHE_RETENTION_DAYS = 92;
101
102/** Transcripts parsed at once. More gains little once the disk stays busy. */
103const TRANSCRIPT_READ_CONCURRENCY = 4;
104
105/** The transcript readers, in driver order. */
106const transcriptReaders = BUILT_IN_USAGE_DRIVERS.flatMap((driver) =>
107 driver.usage?.kind === "transcripts" ? [{ driver, reader: driver.usage }] : [],
108);
109
110/** The scan readers, in driver order. */
111const scanReaders = BUILT_IN_USAGE_DRIVERS.flatMap((driver) =>
112 driver.usage?.kind === "scan" ? [{ driver, reader: driver.usage }] : [],
113);
114
115/** Transcript formats by provider, for decoding the persisted scan cache. */
116const transcriptFormats = new Map(
117 transcriptReaders.map(({ reader }) => [reader.provider, reader.format] as const),
118);
119
120/** One transcript directory to scan. */
121interface TranscriptSource {
122 readonly provider: UsageProviderKind;
123 readonly format: TranscriptUsageFormat<unknown>;
124 readonly dir: string;
125 readonly volumeId: string;
126 readonly fileName?: string;
127}
128
129/** On-disk shape of the rate snapshot. */
130const RatesCacheFile = Schema.Struct({
131 fetchedAtMs: Schema.Number,
132 document: Schema.Unknown,
133});
134const decodeRatesCache = Schema.decodeUnknownEffect(
135 Schema.fromJsonString(RatesCacheFile as unknown as Schema.Codec<typeof RatesCacheFile.Type>),
136);
137const encodeRatesCache = Schema.encodeEffect(
138 Schema.fromJsonString(RatesCacheFile as unknown as Schema.Codec<typeof RatesCacheFile.Type>),
139);
140
141/** The scan cache is narrowed by hand in `usageScanCache`, so JSON is enough here. */
142const ScanCacheJson = Schema.fromJsonString(Schema.Unknown as unknown as Schema.Codec<unknown>);
143const decodeScanCacheFile = Schema.decodeUnknownEffect(ScanCacheJson);
144const encodeUsageRecordKey = Schema.encodeSync(ScanCacheJson);
145const CachedSource = Schema.Struct({ dir: Schema.String, volumeId: Schema.String });
146
147/** Whether `a` read a later state of its file than `b`. Transcripts only grow. */
148function isLaterRead(a: CachedFile, b: CachedFile): boolean {
149 return a.mtimeMs > b.mtimeMs || (a.mtimeMs === b.mtimeMs && a.size > b.size);
150}
151
152/** Providers whose format lets one session's records appear in several files. */
153const sharedSessionProviders: ReadonlySet<UsageProviderKind> = new Set(
154 transcriptReaders.flatMap(({ reader }) =>
155 reader.format.sharedSessionsAcrossFiles ? [reader.provider] : [],
156 ),
157);
158
159/**
160 * Sessions of `sharedSessionProviders` with records in more than one file,
161 * such as a rollout that moved after it was read. Only these need cross-file
162 * dedupe keys: within one file the occurrence count already keeps every key
163 * unique, so keying the rest would only build and hash a string for each of
164 * their records.
165 */
166function sharedSessions(
167 files: readonly { readonly records: readonly UsageRecord[] }[],
168): ReadonlySet<string> {
169 const firstFile = new Map<string, number>();
170 const shared = new Set<string>();
171 for (const [index, file] of files.entries()) {
172 let previous = "";
173 for (const { provider, sessionId } of file.records) {
174 if (!sharedSessionProviders.has(provider) || sessionId === previous || sessionId.length === 0)
175 continue;
176 previous = sessionId;
177 const first = firstFile.get(sessionId);
178 if (first === undefined) firstFile.set(sessionId, index);
179 else if (first !== index) shared.add(sessionId);
180 }
181 }
182 return shared;
183}
184const decodeCachedSources = Schema.decodeUnknownOption(
185 Schema.Struct({ sources: Schema.Record(Schema.String, CachedSource) }),
186);
187
188export class UsageService extends Context.Service<
189 UsageService,
190 {
191 readonly readSummary: (input: UsageSummaryInput) => Effect.Effect<UsageSummary, UsageReadError>;
192 /** Refetches the rate table ahead of its TTL. See `ensureRates`. */
193 readonly refreshRates: Effect.Effect<UsagePricing>;
194 }
195>()("t3/usage/UsageService") {}
196
197const EMPTY_PRICING: UsagePricing = {
198 status: "unavailable",
199 source: LITELLM_RATES_URL,
200 fetchedAt: null,
201 knownModels: 0,
202};
203
204/** Empty summary, for suites that only need the RPC surface to resolve. */
205const layerTest = Layer.succeed(
206 UsageService,
207 UsageService.of({
208 readSummary: (input) =>
209 Effect.succeed({
210 contractVersion: USAGE_CONTRACT_VERSION,
211 readAt: "1970-01-01T00:00:00.000Z",
212 timeZone: input.timeZone,
213 sinceDay: input.sinceDay,
214 untilDay: input.untilDay,
215 buckets: [],
216 sources: [],
217 pricing: EMPTY_PRICING,
218 scanDurationMs: 0,
219 }),
220 refreshRates: Effect.succeed(EMPTY_PRICING),
221 }),
222);
223
224export const make = Effect.gen(function* () {
225 const fileSystem = yield* FileSystem.FileSystem;
226 const path = yield* Path.Path;
227 const config = yield* ServerConfig.ServerConfig;
228 const settingsService = yield* ServerSettings.ServerSettingsService;
229 const httpClient = yield* HttpClient.HttpClient;
230 const hostEnvironment = yield* HostProcess.Environment;
231 // The readers yield their own services; scans run them against this context.
232 const readerContext = yield* Effect.context<BuiltInUsageReadersEnv>();
233
234 const fileCache: ScanCache = new Map();
235 const sourceCache = new Map<string, typeof CachedSource.Type>();
236 let cacheDirty = false;
237 const isWithinDirectory = (filePath: string, dir: string) => {
238 const relative = path.relative(dir, filePath);
239 return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative);
240 };
241
242 const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json");
243 const scanCachePath = path.join(config.stateDir, SCAN_CACHE_FILE_NAME);
244 const legacyScanCachePath = path.join(config.stateDir, LEGACY_SCAN_CACHE_FILE_NAME);
245 const writeCacheFile = (filePath: string, contents: string) =>
246 writeFileStringAtomically({ filePath, contents }).pipe(
247 Effect.provideService(FileSystem.FileSystem, fileSystem),
248 Effect.provideService(Path.Path, path),
249 );
250 let rates: RateTable = new Map();
251 let ratesFetchedAtMs: number | null = null;
252 let ratesStatus: UsagePricing["status"] = "unavailable";
253 // One fetch at a time. A burst of refreshes from several clients waits on
254 // the first fetch and then sees a table young enough to skip its own.
255 const ratesLock = yield* Semaphore.make(1);
256
257 const pricing = (): UsagePricing => ({
258 status: ratesStatus,
259 source: LITELLM_RATES_URL,
260 fetchedAt:
261 ratesFetchedAtMs === null ? null : DateTime.formatIso(DateTime.makeUnsafe(ratesFetchedAtMs)),
262 knownModels: rates.size,
263 });
264
265 /**
266 * Loads the LiteLLM rate table, preferring a fresh copy and falling back to
267 * the on-disk snapshot. With neither, every model reports as unpriced rather
268 * than the page failing. `force` refetches inside the TTL so a model that
269 * LiteLLM added since the last fetch gets priced now.
270 */
271 const loadRates = Effect.fn("UsageService.loadRates")(function* (force: boolean) {
272 const now = yield* Clock.currentTimeMillis;
273 const maxAgeMs = force ? RATES_REFRESH_FLOOR_MS : RATES_TTL_MS;
274 if (ratesFetchedAtMs !== null && now - ratesFetchedAtMs < maxAgeMs) return;
275
276 if (ratesFetchedAtMs === null) {
277 const fromDisk = yield* fileSystem.readFileString(ratesCachePath).pipe(
278 Effect.flatMap((raw) => decodeRatesCache(raw)),
279 Effect.catchCause(() => Effect.succeed(null)),
280 );
281 if (fromDisk !== null) {
282 const parsed = parseRateTable(fromDisk.document);
283 if (parsed.size > 0) {
284 rates = parsed;
285 ratesFetchedAtMs = fromDisk.fetchedAtMs;
286 ratesStatus = "cached";
287 if (now - fromDisk.fetchedAtMs < maxAgeMs) return;
288 }
289 }
290 }
291
292 const fetched = yield* httpClient.get(LITELLM_RATES_URL).pipe(
293 Effect.flatMap(HttpClientResponse.filterStatusOk),
294 Effect.flatMap((response) => response.json),
295 Effect.timeout(10_000),
296 Effect.catchCause(() => Effect.succeed(null)),
297 );
298 if (fetched === null) {
299 // The refresh failed; whatever we are serving is now past its TTL and
300 // must not keep claiming to be fresh.
301 if (rates.size > 0) ratesStatus = "cached";
302 return;
303 }
304
305 const parsed = parseRateTable(fetched);
306 if (parsed.size === 0) return;
307
308 rates = parsed;
309 ratesFetchedAtMs = now;
310 ratesStatus = "fresh";
311
312 yield* encodeRatesCache({ fetchedAtMs: now, document: fetched }).pipe(
313 Effect.flatMap((contents) => writeCacheFile(ratesCachePath, contents)),
314 Effect.ignoreCause,
315 );
316 });
317
318 const ensureRates = (force: boolean) => ratesLock.withPermit(loadRates(force));
319
320 const refreshRates = ensureRates(true).pipe(
321 Effect.map(pricing),
322 Effect.withSpan("UsageService.refreshRates"),
323 );
324
325 // A settings failure must not silently discard custom rates or transcript homes.
326 const readSettings = settingsService.getSettings.pipe(
327 Effect.catchCause(
328 (cause) =>
329 new UsageReadError({
330 reason: "scanFailed",
331 detail: "Server settings could not be read.",
332 cause: Cause.squash(cause),
333 }),
334 ),
335 );
336
337 /**
338 * Every instance of `driver` with its decoded config, or none when it does
339 * not decode. Disabled accounts still have history. An unconfigured default
340 * slot runs with default config, just as it does in the provider registry.
341 */
342 const usageInstances = Effect.fn("UsageService.usageInstances")(function* <Config>(
343 driver: ProviderDriver<Config, unknown, unknown>,
344 settings: ServerSettingsValue,
345 ) {
346 const entries: Array<
347 readonly [ProviderInstanceId, Pick<ProviderInstanceConfig, "config" | "environment">, boolean]
348 > = Object.entries(settings.providerInstances)
349 .filter(([, instance]) => instance.driver === driver.driverKind)
350 .map(([id, instance]) => [ProviderInstanceId.make(id), instance, true] as const);
351 if (!Object.hasOwn(settings.providerInstances, driver.driverKind)) {
352 entries.push([ProviderInstanceId.make(driver.driverKind), {}, false]);
353 }
354 const decodeConfig = Schema.decodeUnknownOption(driver.configSchema);
355 return yield* Effect.forEach(
356 entries,
357 Effect.fnUntraced(function* ([instanceId, instance, configured]) {
358 const instanceConfig: ProviderUsageInstance<Config> = {
359 instanceId,
360 config: Option.getOrUndefined(decodeConfig(instance.config ?? {})),
361 environment: yield* mergeProviderInstanceEnvironment(
362 instance.environment,
363 hostEnvironment,
364 ),
365 configured,
366 };
367 return instanceConfig;
368 }),
369 );
370 });
371
372 /** Resolves every transcript directory the usage readers point at. */
373 const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* (
374 settings: ServerSettingsValue,
375 retentionCutoffMs: number,
376 ) {
377 const dirs: Array<TranscriptSource> = [];
378 const seen = new Set<string>();
379 for (const { driver, reader } of transcriptReaders) {
380 const { provider, format } = reader;
381 const directories = yield* Effect.forEach(
382 yield* usageInstances(driver, settings),
383 (instance) => reader.directories(instance),
384 );
385 for (const { dir: directory, fileName } of directories.flat()) {
386 const sourceKey = provider + "\0" + directory;
387 const previous = sourceCache.get(sourceKey);
388 // Keep canonical paths and source fingerprints stable after root cleanup,
389 // including aliases and clients merging pre-cleanup environment summaries.
390 const dir = yield* fileSystem
391 .realPath(directory)
392 .pipe(Effect.orElseSucceed(() => previous?.dir ?? directory));
393 const currentVolumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir));
394 const hasRetainedHistory = fileCache
395 .entries()
396 .some(
397 ([filePath, entry]) =>
398 entry.provider === provider &&
399 entry.mtimeMs >= retentionCutoffMs &&
400 entry.records.length + entry.tailRecords.length > 0 &&
401 isWithinDirectory(filePath, dir),
402 );
403 // A recreated directory still reports the retained history under its old identity.
404 const volumeId =
405 previous?.dir === dir && (hasRetainedHistory || !currentVolumeId)
406 ? previous.volumeId || currentVolumeId
407 : currentVolumeId;
408 if (previous?.dir !== dir || previous.volumeId !== volumeId) {
409 sourceCache.set(sourceKey, { dir, volumeId });
410 cacheDirty = true;
411 }
412 const key = `${provider}\0${dir}`;
413 if (seen.has(key)) continue;
414 seen.add(key);
415 dirs.push({
416 provider,
417 format,
418 dir,
419 volumeId,
420 ...(fileName === undefined ? {} : { fileName }),
421 });
422 }
423 }
424 return dirs;
425 });
426
427 /**
428 * Loads the persisted scan cache exactly once per process.
429 *
430 * `Effect.cached` makes concurrent first readers await the same load rather
431 * than each seeing a "loaded" flag set before the read finished and cold
432 * scanning against an empty cache.
433 */
434 const ensureScanCacheLoaded = yield* Effect.cached(
435 Effect.gen(function* () {
436 const readDocument = (filePath: string) =>
437 fileSystem.readFileString(filePath).pipe(
438 Effect.flatMap((raw) => decodeScanCacheFile(raw)),
439 Effect.catchCause(() => Effect.succeed(null)),
440 );
441 let document = yield* readDocument(scanCachePath);
442 if (document === null) {
443 document = yield* readDocument(legacyScanCachePath);
444 // Write the migrated cache to its own file on the next scan.
445 cacheDirty = document !== null;
446 }
447 if (document === null) return;
448 for (const [path, entry] of decodeScanCache(document, transcriptFormats)) {
449 fileCache.set(path, entry);
450 }
451 const sources = decodeCachedSources(document);
452 if (Option.isSome(sources)) {
453 for (const [key, source] of Object.entries(sources.value.sources))
454 sourceCache.set(key, source);
455 }
456 }),
457 );
458
459 const writeScanCache = makeScanCacheWriter();
460 // Scans with different windows can finish together; serializing the writes
461 // keeps an older snapshot from landing after a newer one.
462 const persistLock = yield* Semaphore.make(1);
463
464 // The dirty flag is cleared before encoding, so a change while the write is
465 // in flight marks it dirty again. A failed write restores the flag, so the
466 // next persist retries instead of leaving disk stale. A cache we cannot
467 // write is a slower next start, not a failed read.
468 const persistCaches = Effect.gen(function* () {
469 if (cacheDirty) {
470 cacheDirty = false;
471 yield* Effect.sync(() =>
472 writeScanCache(fileCache, { sources: Object.fromEntries(sourceCache) }),
473 ).pipe(
474 Effect.flatMap((contents) => writeCacheFile(scanCachePath, contents)),
475 Effect.catchCause(() =>
476 Effect.sync(() => {
477 cacheDirty = true;
478 }),
479 ),
480 );
481 }
482 }).pipe(persistLock.withPermit, Effect.withSpan("UsageService.persistCaches"));
483
484 const pendingPersists = new Set<Fiber.Fiber<void>>();
485 /** Writes dirty caches in the background, after the summary that dirtied them answers. */
486 const schedulePersist = Effect.forkDetach(persistCaches).pipe(
487 Effect.map((fiber) => {
488 pendingPersists.add(fiber);
489 fiber.addObserver(() => pendingPersists.delete(fiber));
490 }),
491 );
492 /** Waits for every write scheduled so far, as a restart would need. */
493 const awaitPersisted = Effect.suspend(() => Fiber.awaitAll([...pendingPersists])).pipe(
494 Effect.asVoid,
495 );
496 // A write still running when the service shuts down finishes first, so the
497 // next start does not lose the last scan.
498 yield* Effect.addFinalizer(() => awaitPersisted);
499
500 /**
501 * Parses one transcript, reusing the cached result when it is unchanged.
502 *
503 * A file that only grew re-parses from the cached position, so an actively
504 * written multi-hundred-megabyte rollout costs its appended bytes per scan
505 * rather than a full re-read. The reader verifies the position's guard bytes
506 * and silently restarts from byte 0 when they no longer match.
507 *
508 * A fresh parse comes back as `update` for the caller to cache, with the
509 * entry it was built from. Reads run concurrently, and the caller stores
510 * updates in walk order rather than completion order: saved records of
511 * deleted transcripts aggregate in cache order, where the first copy of a
512 * duplicate wins.
513 */
514 const readFileRecords = (
515 filePath: string,
516 size: number,
517 mtimeMs: number,
518 provider: UsageProviderKind,
519 format: TranscriptUsageFormat<unknown>,
520 ): Effect.Effect<{
521 readonly records: readonly UsageRecord[];
522 readonly failed?: true;
523 readonly update?: { readonly entry: CachedFile; readonly replaces: CachedFile | undefined };
524 }> =>
525 Effect.gen(function* () {
526 const cached = fileCache.get(filePath);
527 // Provider is part of the identity: if both providers were ever pointed
528 // at one directory, a hit parsed by the other parser must not be reused.
529 if (
530 cached &&
531 cached.size === size &&
532 cached.mtimeMs === mtimeMs &&
533 cached.provider === provider
534 ) {
535 return {
536 records:
537 cached.tailRecords.length === 0
538 ? cached.records
539 : [...cached.records, ...cached.tailRecords],
540 };
541 }
542
543 // Only a strictly grown file may resume. Same size with a new mtime, or
544 // a shrunken file, means rewritten content; re-parse it whole.
545 const resumeFrom =
546 cached !== undefined && cached.provider === provider && size > cached.size
547 ? cached.position
548 : undefined;
549
550 const parsed = yield* Effect.promise(() =>
551 readTranscriptRecords(filePath, format, resumeFrom),
552 );
553 // A read failure is not an empty transcript: caching it under this
554 // (size, mtime) would silently drop the file's usage until it changes.
555 if (parsed === null)
556 return {
557 records: cached?.provider === provider ? [...cached.records, ...cached.tailRecords] : [],
558 failed: true,
559 };
560
561 // Stored already de-duplicated within the file, which is 99% of all
562 // duplicates. The aggregator still runs the cross-file dedupe pass. One
563 // seen set spans the cached base, the new lines, and the tail so a
564 // resumed parse dedupes exactly like a full one.
565 const base = parsed.resumed && cached !== undefined ? cached.records : [];
566 const seen = new Set<string>();
567 const records = dedupeWithinFile([...base, ...parsed.records], seen);
568 const tailRecords = dedupeWithinFile(parsed.tailRecords, seen);
569
570 return {
571 records: tailRecords.length === 0 ? records : [...records, ...tailRecords],
572 update: {
573 entry: { size, mtimeMs, provider, records, tailRecords, position: parsed.position },
574 replaces: cached,
575 },
576 };
577 });
578
579 /** One provider directory's walk and parse, before rates are involved. */
580 interface ScannedDir {
581 readonly provider: UsageProviderKind;
582 readonly dir: string;
583 readonly volumeId: string;
584 readonly hostId?: string;
585 readonly status?: UsageSource["status"];
586 readonly message?: string;
587 readonly action?: UsageSource["action"];
588 /** Parsed records per file, or `null` when the directory does not exist. */
589 readonly files:
590 | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[]
591 | null;
592 /** Answered from a cache while a refresh runs. */
593 readonly refreshing?: true;
594 }
595
596 const scanTranscriptDir = Effect.fn("UsageService.scanTranscriptDir")(function* (
597 source: TranscriptSource,
598 windowStartMs: number,
599 ) {
600 const { provider, format, dir, volumeId, fileName } = source;
601 const exists = yield* fileSystem
602 .exists(dir)
603 .pipe(Effect.catchCause(() => Effect.succeed(false)));
604 if (!exists) return { provider, dir, volumeId, files: null } satisfies ScannedDir;
605 const { files, failedPaths } = yield* Effect.promise(() =>
606 listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }),
607 );
608 // A cold parse waits on disk reads, so a few files in flight read
609 // close to twice as fast. Results keep walk order.
610 const read = yield* Effect.forEach(
611 files,
612 (file) =>
613 readFileRecords(file.path, file.size, file.mtimeMs, provider, format).pipe(
614 Effect.map((result) => ({ path: file.path, ...result })),
615 ),
616 { concurrency: TRANSCRIPT_READ_CONCURRENCY },
617 );
618 const parsedFiles = read.map(({ path, records, update }) => {
619 if (update === undefined) return { path, records };
620 // A scan of another window may have cached its own read of this file
621 // meanwhile. Then keep whichever read saw the later file, so a slower
622 // scan never replaces newer usage with older.
623 const current = fileCache.get(path);
624 if (
625 current === update.replaces ||
626 current === undefined ||
627 !isLaterRead(current, update.entry)
628 ) {
629 fileCache.set(path, update.entry);
630 cacheDirty = true;
631 }
632 return { path, records };
633 });
634 // Unread files keep their cached usage, but the total may be short.
635 const unread = failedPaths + read.filter((file) => file.failed).length;
636 return {
637 provider,
638 dir,
639 volumeId,
640 files: parsedFiles,
641 ...(unread > 0
642 ? {
643 status: "partial",
644 message: `${unread} transcript path(s) could not be read; usage may be incomplete.`,
645 }
646 : {}),
647 } satisfies ScannedDir;
648 });
649
650 const collectDirs = Effect.fn("UsageService.collectDirs")(function* (
651 windowStartMs: number,
652 settings: ServerSettingsValue,
653 retentionCutoffMs: number,
654 awaitRefresh: boolean,
655 ) {
656 const dirs = yield* resolveTranscriptDirs(settings, retentionCutoffMs).pipe(
657 Effect.provideContext(readerContext),
658 );
659
660 const scans = Effect.forEach(
661 scanReaders,
662 ({ driver, reader }) =>
663 usageInstances(driver, settings)
664 .pipe(
665 Effect.flatMap((instances) =>
666 reader.scan({ instances, settings, windowStartMs, retentionCutoffMs, awaitRefresh }),
667 ),
668 )
669 .pipe(
670 Effect.flatMap((sources) =>
671 Effect.forEach(sources, ({ volumeId, ...source }) =>
672 Effect.map(
673 volumeId === undefined
674 ? Effect.promise(() => readDirectoryVolumeId(source.dir))
675 : Effect.succeed(volumeId),
676 (resolved): ScannedDir => ({
677 ...source,
678 provider: reader.provider,
679 volumeId: resolved,
680 }),
681 ),
682 ),
683 ),
684 Effect.provideContext(readerContext),
685 ),
686 { concurrency: "unbounded" },
687 );
688
689 // Independent sources scan together. Transcript directories go one at a
690 // time, so open files stay at `TRANSCRIPT_READ_CONCURRENCY`. The result
691 // keeps this order, since aggregation keeps the first copy of a duplicate.
692 const [transcripts, scanDirs] = yield* Effect.all(
693 [Effect.forEach(dirs, (dir) => scanTranscriptDir(dir, windowStartMs)), scans],
694 { concurrency: "unbounded" },
695 );
696 const scanned: readonly ScannedDir[] = [...transcripts, ...scanDirs.flat()];
697 return scanned;
698 });
699
700 const scanSummary = Effect.fn("UsageService.scanSummary")(function* (
701 input: UsageSummaryInput,
702 settings: ServerSettingsValue,
703 ) {
704 if (input.sinceDay > input.untilDay) {
705 return yield* new UsageReadError({
706 reason: "invalidWindow",
707 detail: `sinceDay '${input.sinceDay}' is after untilDay '${input.untilDay}'`,
708 });
709 }
710
711 let hourlyWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null = null;
712 if (input.resolution === "hour") {
713 const sinceTime =
714 input.sinceTime === undefined ? Option.none() : DateTime.make(input.sinceTime);
715 const untilTime =
716 input.untilTime === undefined ? Option.none() : DateTime.make(input.untilTime);
717 if (Option.isNone(sinceTime) || Option.isNone(untilTime)) {
718 return yield* new UsageReadError({
719 reason: "invalidWindow",
720 detail: "Hourly usage requires valid sinceTime and untilTime instants",
721 });
722 }
723 const sinceTimeMs = DateTime.toEpochMillis(sinceTime.value);
724 const untilTimeMs = DateTime.toEpochMillis(untilTime.value);
725 const durationMs = untilTimeMs - sinceTimeMs;
726 if (durationMs <= 0 || durationMs > MAX_HOURLY_WINDOW_MS) {
727 return yield* new UsageReadError({
728 reason: "invalidWindow",
729 detail: "Hourly usage window must be greater than zero and at most 24 hours",
730 });
731 }
732 hourlyWindow = { sinceTimeMs, untilTimeMs };
733 }
734
735 const startedAtMs = yield* Clock.currentTimeMillis;
736 yield* ensureScanCacheLoaded;
737
738 const hostId = NodeOS.hostname();
739 const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`);
740 if (Option.isNone(windowStart)) {
741 return yield* new UsageReadError({
742 reason: "invalidWindow",
743 detail: `sinceDay '${input.sinceDay}' is not a valid date`,
744 });
745 }
746 const windowStartMs =
747 (hourlyWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS;
748
749 const retentionCutoffMs = startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000;
750
751 // Pricing only matters once records are aggregated, so the rate table
752 // loads while transcripts stream instead of gating them: a cold rates
753 // fetch on a slow network no longer delays the scan by its own timeout.
754 const [, scannedDirs] = yield* Effect.all(
755 [
756 ensureRates(false),
757 collectDirs(windowStartMs, settings, retentionCutoffMs, input.awaitRefresh === true),
758 ],
759 { concurrency: 2 },
760 );
761
762 const aggregator = new UsageAggregator({
763 timeZone: input.timeZone,
764 sinceDay: input.sinceDay,
765 untilDay: input.untilDay,
766 resolution: input.resolution ?? "day",
767 ...hourlyWindow,
768 rates,
769 priceOverrides: createOverrideRateTable(settings.usagePriceOverrides),
770 modelAliases: resolveModelAliases(settings.usageModelAliases),
771 });
772
773 const sources: UsageSource[] = [];
774
775 // Cleanup may remove transcripts, but the usage we already saved still
776 // contributes to its source through the normal aggregation and dedupe
777 // path. Like the walk, skip files last written before the window: they
778 // cannot hold records inside it.
779 const retainedSinceMs = Math.max(windowStartMs, retentionCutoffMs);
780 const filesByDir = scannedDirs.map(({ provider, dir, files }) => {
781 const retainedFiles = [...(files ?? [])];
782 const livePaths = new Set(retainedFiles.map((file) => file.path));
783 for (const [filePath, entry] of fileCache) {
784 if (
785 entry.provider !== provider ||
786 entry.mtimeMs < retainedSinceMs ||
787 livePaths.has(filePath) ||
788 !isWithinDirectory(filePath, dir)
789 )
790 continue;
791 retainedFiles.push({ path: filePath, records: [...entry.records, ...entry.tailRecords] });
792 }
793 return retainedFiles;
794 });
795 const crossFileSessions = sharedSessions(filesByDir.flat());
796
797 for (const [
798 index,
799 { provider, dir, volumeId, files, status, message, action, refreshing, hostId: sourceHostId },
800 ] of scannedDirs.entries()) {
801 let scannedFiles = 0;
802 let skippedFiles = 0;
803 // Distinct per directory. Buckets carry per-cell session counts, but a
804 // session spans days and models, so clients total this figure instead.
805 const sessionIds = new Set<string>();
806
807 for (const file of filesByDir[index] ?? []) {
808 if (file.records.length === 0) {
809 skippedFiles += 1;
810 continue;
811 }
812 scannedFiles += 1;
813 const eventOccurrences = new Map<string, number>();
814 for (const record of file.records) {
815 let usageRecord = record;
816 if (
817 sharedSessionProviders.has(record.provider) &&
818 crossFileSessions.has(record.sessionId)
819 ) {
820 // Match moved copies without collapsing repeated equal events
821 // within one file (timestamps can have only second precision).
822 // Only sessions seen in several files can have a copy to match.
823 const key = encodeUsageRecordKey([
824 record.provider,
825 record.sessionId,
826 record.timestampMs,
827 record.model,
828 record.totals,
829 ]);
830 const occurrence = (eventOccurrences.get(key) ?? 0) + 1;
831 eventOccurrences.set(key, occurrence);
832 usageRecord = { ...record, dedupeKey: key + ":" + occurrence };
833 }
834 // Only sessions contributing in-window count; the mtime slack can
835 // admit boundary files whose records fall outside the range.
836 if (aggregator.add(usageRecord, dir) && record.sessionId.length > 0) {
837 sessionIds.add(record.sessionId);
838 }
839 }
840 }
841
842 sources.push({
843 fingerprint: { hostId: sourceHostId ?? hostId, provider, resolvedHomePath: dir, volumeId },
844 // Clients exclude missing sources, so saved records remain an available source.
845 status: files === null && scannedFiles === 0 ? "missing" : (status ?? "ok"),
846 scannedFiles,
847 skippedFiles,
848 malformedRecords: 0,
849 distinctSessions: sessionIds.size,
850 message:
851 message ?? (files === null ? "No transcript directory on this environment." : null),
852 ...(action ? { action } : {}),
853 ...(refreshing ? { refreshing } : {}),
854 });
855 }
856
857 const pruned = pruneScanCache(fileCache, retentionCutoffMs);
858 if (pruned > 0) cacheDirty = true;
859
860 const aggregated = aggregator.finish();
861 const readAt = yield* DateTime.now;
862 const finishedAtMs = yield* Clock.currentTimeMillis;
863
864 return {
865 contractVersion: USAGE_CONTRACT_VERSION,
866 readAt: DateTime.formatIso(readAt),
867 timeZone: input.timeZone,
868 sinceDay: input.sinceDay,
869 untilDay: input.untilDay,
870 buckets: aggregated.buckets,
871 sources,
872 pricing: pricing(),
873 scanDurationMs: Math.max(0, finishedAtMs - startedAtMs),
874 } satisfies UsageSummary;
875 });
876
877 /**
878 * In-flight scans by window and settings, so concurrent identical requests (the usage
879 * page open on two clients at once) share one scan instead of racing over
880 * the same corpus twice. Readers read settings of their own, so a scan is
881 * shared only under the same settings snapshot.
882 */
883 const inflightScans = new Map<string, Deferred.Deferred<UsageSummary, UsageReadError>>();
884
885 const scanKey = (input: UsageSummaryInput, settings: ServerSettingsValue): string =>
886 JSON.stringify([
887 input.timeZone,
888 input.sinceDay,
889 input.untilDay,
890 input.resolution ?? "day",
891 input.sinceTime ?? null,
892 input.untilTime ?? null,
893 settings,
894 // A waiting read must never share a scan that answers with `refreshing`.
895 input.awaitRefresh === true,
896 ]);
897
898 const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) {
899 const settings = yield* readSettings;
900 const key = scanKey(input, settings);
901 const deferred = yield* Effect.uninterruptible(
902 Effect.gen(function* () {
903 const existing = inflightScans.get(key);
904 if (existing !== undefined) return existing;
905
906 // Enrollment and detached-fiber creation must be atomic. Otherwise a
907 // canceled first caller can leave a Deferred with no scan to finish it.
908 const created = Deferred.makeUnsafe<UsageSummary, UsageReadError>();
909 inflightScans.set(key, created);
910 // Detached so one departing client cannot tear the scan out from under
911 // the fibers awaiting it; a finished scan warms the cache either way.
912 // The cache write is registered before the waiters resume, so they
913 // can await it, but its fiber starts after they have the summary.
914 yield* scanSummary(input, settings).pipe(
915 Effect.onExit((exit) =>
916 Effect.sync(() => inflightScans.delete(key)).pipe(
917 Effect.andThen(Exit.isSuccess(exit) ? schedulePersist : Effect.void),
918 Effect.andThen(Deferred.done(created, exit)),
919 ),
920 ),
921 Effect.forkDetach,
922 );
923 return created;
924 }),
925 );
926 // Waiting stays interruptible. The detached scan continues for other
927 // callers and still warms the cache if this caller leaves.
928 return yield* Deferred.await(deferred);
929 });
930
931 // `awaitPersisted` is outside the service interface: tests use it to restart
932 // against what a previous instance wrote.
933 return { readSummary, refreshRates, awaitPersisted } as const;
934});
935
936export const layer = Layer.effect(UsageService, make);