/** * Usage reporting contract. * * Each environment scans the provider CLIs' own on-disk session transcripts * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`, * `~/.grok/sessions/**\/updates.jsonl`) rather than relying on T3 Code's own * orchestration projections, so usage stays complete even for turns that were * never driven through T3 Code. This mirrors the approach `ccusage` takes. * * Environments return pre-aggregated `(day, hourStart?, provider, model)` * buckets. Raw transcript records never cross the wire. * * @module usage */ import * as Schema from "effect/Schema"; import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; /** * Bumped whenever the shape of {@link UsageSummary} changes incompatibly. The * client renders partial coverage when an environment reports an older version * rather than failing the whole page. */ export const USAGE_CONTRACT_VERSION = 5 as const; /** * Oldest {@link UsageSummary} version a current client will still merge. * * v5 only adds `grok` to {@link UsageProviderKind}; v4 Claude/Codex buckets * remain valid, so mixed-version environments keep those totals instead of * treating every older server as stale. */ export const USAGE_MERGE_COMPATIBLE_SINCE = 4 as const; export const UsageProviderKind = Schema.Literals(["claude", "codex", "grok"]); export type UsageProviderKind = typeof UsageProviderKind.Type; /** * A calendar day in the reporting time zone, formatted `YYYY-MM-DD`. * * Days are bucketed server-side so that 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; export const UsageResolution = Schema.Literals(["day", "hour"]); export type UsageResolution = typeof UsageResolution.Type; /** * Why a bucket's cost is what it is. * * - `providerReported` - the transcript carried an explicit cost figure. * - `modelPriced` - we used a custom price override or the LiteLLM rate table. * - `unpriced` - tokens are known, rates are not. Counted in 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, hourStart?, provider, model)` cell. `hourStart` is the UTC start * instant of a rolling bucket and is present only for hourly requests. * * `costUsd` is the raw API-equivalent cost of these tokens. It is not money * spent: subscription plans bill separately. `unpricedRecords` counts records * whose tokens are included in the token totals but which contributed nothing * to `costUsd`. */ export const UsageBucket = Schema.Struct({ day: UsageDay, hourStart: Schema.optional(TrimmedNonEmptyString), 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 the same 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 in a fleet resolves * `/Users//.claude`, so two machines that happen to share a hostname * would look like one source and have their usage 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, scannedFiles: NonNegativeInt, skippedFiles: NonNegativeInt, /** Records that parsed but carried no recognisable usage payload. */ malformedRecords: 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. An offset would be wrong for * any window that crosses a DST boundary. */ timeZone: TrimmedNonEmptyString, /** Defaults to daily for older clients. */ resolution: Schema.optional(UsageResolution), /** Inclusive UTC instant for an hourly rolling window. */ sinceTime: Schema.optional(TrimmedNonEmptyString), /** Exclusive UTC instant for an hourly rolling window. */ untilTime: Schema.optional(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.TaggedError()("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}`; } }