214 lines · 8.0 KB · TypeScript
RawDownload
/** * 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/<user>/.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.TaggedErrorClass<UsageReadError>()("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}`;  }}