| 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 | */ |
| 12 | import * as Schema from "effect/Schema"; |
| 13 | |
| 14 | import { 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 | */ |
| 24 | export 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 | */ |
| 32 | export const USAGE_MERGE_COMPATIBLE_SINCE = 4 as const; |
| 33 | |
| 34 | export const UsageProviderKind = Schema.Literals([ |
| 35 | "claude", |
| 36 | "codex", |
| 37 | "grok", |
| 38 | "cursor", |
| 39 | "opencode", |
| 40 | "antigravity", |
| 41 | "pi", |
| 42 | ]); |
| 43 | export 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 | */ |
| 51 | const USAGE_DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; |
| 52 | |
| 53 | export const UsageDay = TrimmedNonEmptyString.check(Schema.isPattern(USAGE_DAY_PATTERN)).pipe( |
| 54 | Schema.brand("UsageDay"), |
| 55 | ); |
| 56 | export type UsageDay = typeof UsageDay.Type; |
| 57 | |
| 58 | export const UsageResolution = Schema.Literals(["day", "hour"]); |
| 59 | export 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 | */ |
| 69 | export const UsageCostSource = Schema.Literals(["providerReported", "modelPriced", "unpriced"]); |
| 70 | export 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 | */ |
| 80 | export const UsageTokenTotals = Schema.Struct({ |
| 81 | uncachedInputTokens: NonNegativeInt, |
| 82 | cachedInputTokens: NonNegativeInt, |
| 83 | cacheCreationTokens: NonNegativeInt, |
| 84 | outputTokens: NonNegativeInt, |
| 85 | reasoningTokens: NonNegativeInt, |
| 86 | }); |
| 87 | export 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 | */ |
| 93 | export const UsageCategoryCost = Schema.Struct({ |
| 94 | input: Schema.Number, |
| 95 | cacheRead: Schema.Number, |
| 96 | cacheWrite: Schema.Number, |
| 97 | output: Schema.Number, |
| 98 | }); |
| 99 | export 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 | */ |
| 110 | export 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 | }); |
| 142 | export 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 | */ |
| 151 | export 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 | }); |
| 166 | export type UsageSourceFingerprint = typeof UsageSourceFingerprint.Type; |
| 167 | |
| 168 | export const UsageSourceStatus = Schema.Literals(["ok", "missing", "partial", "failed"]); |
| 169 | export type UsageSourceStatus = typeof UsageSourceStatus.Type; |
| 170 | |
| 171 | export 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 | }); |
| 194 | export type UsageSource = typeof UsageSource.Type; |
| 195 | |
| 196 | export const UsagePricingStatus = Schema.Literals(["fresh", "cached", "unavailable"]); |
| 197 | export 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 | */ |
| 203 | export const UsagePricing = Schema.Struct({ |
| 204 | status: UsagePricingStatus, |
| 205 | source: TrimmedNonEmptyString, |
| 206 | fetchedAt: Schema.NullOr(Schema.String), |
| 207 | knownModels: NonNegativeInt, |
| 208 | }); |
| 209 | export type UsagePricing = typeof UsagePricing.Type; |
| 210 | |
| 211 | export 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 | }); |
| 234 | export type UsageSummaryInput = typeof UsageSummaryInput.Type; |
| 235 | |
| 236 | export 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 | }); |
| 248 | export type UsageSummary = typeof UsageSummary.Type; |
| 249 | |
| 250 | export 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 | } |