1/**
2 * Usage reporting contract.
3 *
4 * Each environment scans native session files and databases, including work
5 * driven outside T3 Code. Source status describes gaps in local coverage.
6 *
7 * Environments return pre-aggregated `(day, hourStart?, provider, model, sourcePath?)`
8 * buckets. Raw transcript records never cross the wire.
9 *
10 * @module usage
11 */
12import * as Schema from "effect/Schema";
13
14import { ForwardCompatibleArray, NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
15
16/**
17 * Bumped whenever the shape of {@link UsageSummary} changes incompatibly. The
18 * client renders partial coverage when an environment reports an older version
19 * rather than failing the whole page.
20 * Adding providers or other array-element variants is additive: unknown
21 * entries are skipped on decode and do not require a version bump. So are
22 * optional bucket fields, which older clients ignore.
23 */
24export const USAGE_CONTRACT_VERSION = 6 as const;
25
26/**
27 * Oldest {@link UsageSummary} version a current client will still merge.
28 *
29 * v5/v6 add providers and optional source attribution; v4 Claude/Codex
30 * buckets remain valid in mixed-version environments.
31 */
32export const USAGE_MERGE_COMPATIBLE_SINCE = 4 as const;
33
34export const UsageProviderKind = Schema.Literals([
35 "claude",
36 "codex",
37 "grok",
38 "cursor",
39 "opencode",
40 "antigravity",
41 "pi",
42]);
43export type UsageProviderKind = typeof UsageProviderKind.Type;
44
45/**
46 * A calendar day in the reporting time zone, formatted `YYYY-MM-DD`.
47 *
48 * Days are bucketed server-side so that a turn always lands on the day the user
49 * experienced it, not the UTC day.
50 */
51const USAGE_DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
52
53export const UsageDay = TrimmedNonEmptyString.check(Schema.isPattern(USAGE_DAY_PATTERN)).pipe(
54 Schema.brand("UsageDay"),
55);
56export type UsageDay = typeof UsageDay.Type;
57
58export const UsageResolution = Schema.Literals(["day", "hour"]);
59export type UsageResolution = typeof UsageResolution.Type;
60
61/**
62 * Why a bucket's cost is what it is.
63 *
64 * - `providerReported` - the transcript carried an explicit cost figure.
65 * - `modelPriced` - we used a custom price override or the LiteLLM rate table.
66 * - `unpriced` - tokens are known, rates are not. Counted in totals, excluded
67 * from cost.
68 */
69export const UsageCostSource = Schema.Literals(["providerReported", "modelPriced", "unpriced"]);
70export type UsageCostSource = typeof UsageCostSource.Type;
71
72/**
73 * Token counts for a bucket.
74 *
75 * `cachedInputTokens` and `cacheCreationTokens` are disjoint from
76 * `uncachedInputTokens`; summing all three gives total input. `reasoningTokens`
77 * is a *subset* of `outputTokens` (Codex reports it that way, and Anthropic
78 * folds thinking into output), so it must never be added on top.
79 */
80export const UsageTokenTotals = Schema.Struct({
81 uncachedInputTokens: NonNegativeInt,
82 cachedInputTokens: NonNegativeInt,
83 cacheCreationTokens: NonNegativeInt,
84 outputTokens: NonNegativeInt,
85 reasoningTokens: NonNegativeInt,
86});
87export type UsageTokenTotals = typeof UsageTokenTotals.Type;
88
89/**
90 * A bucket's cost split by token category, in USD. A provider-reported cost is
91 * split in proportion to the model's list rates.
92 */
93export const UsageCategoryCost = Schema.Struct({
94 input: Schema.Number,
95 cacheRead: Schema.Number,
96 cacheWrite: Schema.Number,
97 output: Schema.Number,
98});
99export type UsageCategoryCost = typeof UsageCategoryCost.Type;
100
101/**
102 * One `(day, hourStart?, provider, model)` cell. `hourStart` is the UTC start
103 * instant of a rolling bucket and is present only for hourly requests.
104 *
105 * `costUsd` is the raw API-equivalent cost of these tokens. It is not money
106 * spent: subscription plans bill separately. `unpricedRecords` counts records
107 * whose tokens are included in the token totals but which contributed nothing
108 * to `costUsd`.
109 */
110export const UsageBucket = Schema.Struct({
111 day: UsageDay,
112 hourStart: Schema.optional(TrimmedNonEmptyString),
113 provider: UsageProviderKind,
114 model: TrimmedNonEmptyString,
115 /** Source directory, so overlapping multi-home environments merge once per source. */
116 sourcePath: Schema.optional(TrimmedNonEmptyString),
117 totals: UsageTokenTotals,
118 costUsd: Schema.Number,
119 /**
120 * What the cached input would have cost at full input rates minus what it
121 * actually cost. Requires the rate table, so it is computed alongside cost
122 * rather than derived on the client.
123 */
124 cacheSavingsUsd: Schema.Number,
125 /**
126 * `costUsd` by token category. Cost with no known rates stays out of it, and
127 * it is absent when nothing could be split or the server predates it.
128 */
129 categoryCostUsd: Schema.optional(UsageCategoryCost),
130 /** Cost of fast and ultrafast requests. Absent when zero; the rest is standard. */
131 fastCostUsd: Schema.optional(Schema.Number),
132 ultrafastCostUsd: Schema.optional(Schema.Number),
133 /** What fast and ultrafast requests cost above the standard rate. Absent when zero. */
134 speedPremiumUsd: Schema.optional(Schema.Number),
135 costSource: UsageCostSource,
136 /** Distinct assistant responses, after de-duplication. */
137 records: NonNegativeInt,
138 unpricedRecords: NonNegativeInt,
139 /** Distinct transcript sessions that contributed to this cell. */
140 sessions: NonNegativeInt,
141});
142export type UsageBucket = typeof UsageBucket.Type;
143
144/**
145 * Identifies the physical transcript directory a source read from.
146 *
147 * Two environments on the same machine (worktree servers, for example) resolve
148 * the same provider home and would otherwise double count. The client drops
149 * duplicate fingerprints before merging.
150 */
151export const UsageSourceFingerprint = Schema.Struct({
152 hostId: TrimmedNonEmptyString,
153 provider: UsageProviderKind,
154 resolvedHomePath: TrimmedNonEmptyString,
155 /**
156 * Filesystem identity of the transcript directory, as `device:inode`.
157 *
158 * Hostname and path alone are not enough: every Mac in a fleet resolves
159 * `/Users/<user>/.claude`, so two machines that happen to share a hostname
160 * would look like one source and have their usage silently dropped. The
161 * device/inode pair is stable for two servers reading the same directory and
162 * effectively never collides across machines. Empty when it cannot be read.
163 */
164 volumeId: Schema.String,
165});
166export type UsageSourceFingerprint = typeof UsageSourceFingerprint.Type;
167
168export const UsageSourceStatus = Schema.Literals(["ok", "missing", "partial", "failed"]);
169export type UsageSourceStatus = typeof UsageSourceStatus.Type;
170
171export const UsageSource = Schema.Struct({
172 fingerprint: UsageSourceFingerprint,
173 status: UsageSourceStatus,
174 scannedFiles: NonNegativeInt,
175 skippedFiles: NonNegativeInt,
176 /** Records that parsed but carried no recognisable usage payload. */
177 malformedRecords: NonNegativeInt,
178 /**
179 * Distinct transcript sessions seen under this directory. Buckets also carry
180 * per-bucket session counts, but a session spans days and models, so summing
181 * those overcounts; this is the figure clients should total.
182 */
183 distinctSessions: NonNegativeInt,
184 message: Schema.NullOr(TrimmedNonEmptyString),
185 /** An action the client can offer to make this source available. */
186 action: Schema.optionalKey(Schema.Literal("enableCursorKeychain")),
187 /**
188 * Present when this source answered from its cache while a slow refresh (an
189 * account API, for example) runs. Repeat the request with `awaitRefresh` to
190 * get the refreshed source.
191 */
192 refreshing: Schema.optionalKey(Schema.Literal(true)),
193});
194export type UsageSource = typeof UsageSource.Type;
195
196export const UsagePricingStatus = Schema.Literals(["fresh", "cached", "unavailable"]);
197export type UsagePricingStatus = typeof UsagePricingStatus.Type;
198
199/**
200 * Provenance for the rate table, so the UI can be honest about how good the
201 * cost figures are.
202 */
203export const UsagePricing = Schema.Struct({
204 status: UsagePricingStatus,
205 source: TrimmedNonEmptyString,
206 fetchedAt: Schema.NullOr(Schema.String),
207 knownModels: NonNegativeInt,
208});
209export type UsagePricing = typeof UsagePricing.Type;
210
211export const UsageSummaryInput = Schema.Struct({
212 /** Inclusive first day of the window, in `timeZone`. */
213 sinceDay: UsageDay,
214 /** Inclusive last day of the window, in `timeZone`. */
215 untilDay: UsageDay,
216 /**
217 * IANA zone the client wants days bucketed in. An offset would be wrong for
218 * any window that crosses a DST boundary.
219 */
220 timeZone: TrimmedNonEmptyString,
221 /** Defaults to daily for older clients. */
222 resolution: Schema.optional(UsageResolution),
223 /** Inclusive UTC instant for an hourly rolling window. */
224 sinceTime: Schema.optional(TrimmedNonEmptyString),
225 /** Exclusive UTC instant for an hourly rolling window. */
226 untilTime: Schema.optional(TrimmedNonEmptyString),
227 /**
228 * Wait for slow sources to finish refreshing instead of answering from their
229 * cache. Clients send it as the follow-up to a summary with a `refreshing`
230 * source. Older servers ignore it and always wait.
231 */
232 awaitRefresh: Schema.optional(Schema.Boolean),
233});
234export type UsageSummaryInput = typeof UsageSummaryInput.Type;
235
236export const UsageSummary = Schema.Struct({
237 contractVersion: Schema.Number,
238 readAt: Schema.String,
239 timeZone: TrimmedNonEmptyString,
240 sinceDay: UsageDay,
241 untilDay: UsageDay,
242 buckets: ForwardCompatibleArray(UsageBucket),
243 sources: ForwardCompatibleArray(UsageSource),
244 pricing: UsagePricing,
245 /** Wall-clock cost of the scan, surfaced in diagnostics. */
246 scanDurationMs: NonNegativeInt,
247});
248export type UsageSummary = typeof UsageSummary.Type;
249
250export class UsageReadError extends Schema.TaggedError<UsageReadError>()("UsageReadError", {
251 reason: Schema.Literals(["scanFailed", "invalidWindow"]),
252 /** Stable, bounded description. The underlying failure travels in `cause`. */
253 detail: TrimmedNonEmptyString,
254 cause: Schema.optional(Schema.Defect()),
255}) {
256 override get message(): string {
257 return `Usage read failed (${this.reason}): ${this.detail}`;
258 }
259}