1/**
2 * The usage reader contract: how a driver tells the server's usage page where
3 * its history lives and how to read it.
4 *
5 * A driver either points at JSONL transcripts that the server's shared engine
6 * streams, caches and resumes (`kind: "transcripts"`), or scans its own
7 * sources and returns their records (`kind: "scan"`). The server owns
8 * pricing, aggregation, de-duplication and the scan cache.
9 *
10 * @module provider-core/server/usage
11 */
12import type {
13 ProviderInstanceId,
14 ServerSettings,
15 UsageProviderKind,
16 UsageReadError,
17 UsageSource,
18 UsageTokenTotals,
19} from "@t3tools/contracts";
20import type * as Effect from "effect/Effect";
21import type * as Schema from "effect/Schema";
22
23/**
24 * Billing speed of a request. Faster speeds bill at a model-specific premium.
25 * Claude fast mode and Codex `priority` are `fast`; Codex `ultrafast` is its
26 * own, more expensive tier.
27 */
28export type UsageSpeed = "standard" | "fast" | "ultrafast";
29
30export interface UsageRecord {
31 readonly provider: UsageProviderKind;
32 readonly timestampMs: number;
33 readonly model: string;
34 /**
35 * Rate-table key when the provider's display name carries tiers the table
36 * does not know, such as Cursor's `claude-opus-5-5-high`. Defaults to `model`.
37 */
38 readonly rateModel?: string;
39 readonly sessionId: string;
40 readonly totals: UsageTokenTotals;
41 readonly reportedCostUsd: number | null;
42 /** Only Claude Code and Codex record a speed; other providers are `standard`. */
43 readonly speed: UsageSpeed;
44 /**
45 * Key for cross-file de-duplication, or `null` when the record is inherently
46 * unique and needs no dedup.
47 */
48 readonly dedupeKey: string | null;
49}
50
51export const EMPTY_TOTALS: UsageTokenTotals = {
52 uncachedInputTokens: 0,
53 cachedInputTokens: 0,
54 cacheCreationTokens: 0,
55 outputTokens: 0,
56 reasoningTokens: 0,
57};
58
59export function totalTokens(totals: UsageTokenTotals): number {
60 // reasoningTokens is a subset of outputTokens and must not be added again.
61 return (
62 totals.uncachedInputTokens +
63 totals.cachedInputTokens +
64 totals.cacheCreationTokens +
65 totals.outputTokens
66 );
67}
68
69/** A token count from untrusted JSON: a positive finite number, truncated, else 0. */
70export function tokenCount(value: unknown): number {
71 return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
72}
73
74/** Epoch milliseconds of an ISO timestamp string, or `null`. */
75export function parseTimestampMs(value: unknown): number | null {
76 if (typeof value !== "string") return null;
77 const parsed = Date.parse(value);
78 return Number.isNaN(parsed) ? null : parsed;
79}
80
81/** JSON fields a format reads. `true` keeps the whole subtree. */
82export type SelectedFields = { readonly [key: string]: true | SelectedFields };
83
84/**
85 * How the server's JSONL engine reads one provider's transcript lines.
86 *
87 * Lines arrive one at a time. A line too large to parse whole is projected to
88 * `selectFields` and handed to `parseProjected` instead of `parseLine`.
89 */
90export interface TranscriptUsageFormat<State> {
91 readonly selectFields: SelectedFields;
92 /**
93 * ASCII substrings, at least one of which every line that matters contains.
94 * Transcripts are mostly tool output, so the engine matches these against a
95 * line's raw bytes and neither decodes nor parses the rest, which is worth an
96 * order of magnitude. Lines that only update `State` must match too.
97 */
98 readonly usageMarkers: readonly [string, ...string[]];
99 parseLine(line: string, state: State): readonly UsageRecord[];
100 parseProjected(projected: unknown, state: State): readonly UsageRecord[];
101 /**
102 * Present for formats whose records depend on earlier lines. The state at a
103 * resume point is persisted in the scan cache through `schema`, so a grown
104 * file resumes with it. Absent means every line stands alone.
105 */
106 readonly state?: {
107 readonly initial: () => State;
108 readonly schema: Schema.Codec<State, unknown>;
109 };
110 /**
111 * Whether one session's records may appear in several files, such as a
112 * rollout copied when it moves. The server then keys those sessions'
113 * records for cross-file de-duplication.
114 */
115 readonly sharedSessionsAcrossFiles?: true;
116}
117
118/** A configured instance of the driver, its config already decoded by `driver.configSchema`. */
119export interface ProviderUsageInstance<Config> {
120 readonly instanceId: ProviderInstanceId;
121 /**
122 * `undefined` when the stored config does not decode. The instance still has
123 * history on disk, so readers that need no config read it anyway.
124 */
125 readonly config: Config | undefined;
126 /** The instance's environment merged over the host environment. */
127 readonly environment: NodeJS.ProcessEnv;
128 /** Whether settings list the instance, rather than it being the implicit default. */
129 readonly configured: boolean;
130}
131
132/** One source a `scan` reader read. */
133export interface ProviderUsageScan {
134 readonly dir: string;
135 /** Identity of the source across hosts. Defaults to the filesystem identity of `dir`. */
136 readonly volumeId?: string;
137 /** Overrides the server's host name in the source fingerprint, for account-wide sources. */
138 readonly hostId?: string;
139 readonly status?: UsageSource["status"];
140 readonly message?: string;
141 readonly action?: UsageSource["action"];
142 /** Parsed records per file, or `null` when the source does not exist. */
143 readonly files:
144 | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[]
145 | null;
146 /** Answered from a cache while a refresh runs. */
147 readonly refreshing?: true;
148}
149
150export interface ProviderUsageScanInput<Config> {
151 readonly instances: ReadonlyArray<ProviderUsageInstance<Config>>;
152 /** The settings snapshot the whole read runs against. */
153 readonly settings: ServerSettings;
154 /** Files last written before this cannot hold records inside the window. */
155 readonly windowStartMs: number;
156 /** The oldest usage the server keeps; account caches cover back to here. */
157 readonly retentionCutoffMs: number;
158 /** Wait for a slow refresh instead of answering from a cache. */
159 readonly awaitRefresh: boolean;
160}
161
162export type ProviderUsageReader<Config, R> =
163 | {
164 readonly kind: "transcripts";
165 readonly provider: UsageProviderKind;
166 readonly format: TranscriptUsageFormat<unknown>;
167 /**
168 * Transcript directories for one instance. The server canonicalizes,
169 * de-duplicates and scans them; `fileName` limits a walk to one basename.
170 */
171 readonly directories: (
172 instance: ProviderUsageInstance<Config>,
173 ) => Effect.Effect<
174 ReadonlyArray<{ readonly dir: string; readonly fileName?: string }>,
175 never,
176 R
177 >;
178 }
179 | {
180 readonly kind: "scan";
181 readonly provider: UsageProviderKind;
182 /** Fails only when the whole usage read cannot go on; an unreadable source is a status. */
183 readonly scan: (
184 input: ProviderUsageScanInput<Config>,
185 ) => Effect.Effect<ReadonlyArray<ProviderUsageScan>, UsageReadError, R>;
186 };