Part IV · Five harnesses, one product modelUsage accounting
Chapter 20source checked

Usage accounting without a false ledger

The live context meter and historical transcript usage view are deliberately different measurements: one projects the latest usable context snapshot for a thread, while the other scans provider-owned files into a deduplicated, priced historical estimate.

What this chapter resolves
  • Trace the live context-window snapshot from provider adapter to the web composer.
  • Trace historical Claude and Codex transcript records through parsing, deduplication, bucketing, pricing, and environment merge.
  • Interpret coverage, source fingerprints, cache state, and cost provenance without claiming a billing settlement.
  • Keep task and subagent accounting as a distinct later concern.

“Usage” names two different questions in T3 Code.

The composer asks: how full is this thread’s context window at the latest valid provider update? The Usage screens ask: what token-shaped records can this environment read from selected provider transcript directories over a historical time range? They share words such as model, tokens, and session, but they do not share a source of truth, an update cadence, or a settlement guarantee.

The live lane projects a current context snapshot

The runtime contract calls the canonical event thread.token-usage.updated. Its snapshot requires usedTokens; it may also carry the provider’s total processed tokens, maximum context size, input/cache/output breakdown, reasoning subset, duration, tool count, and a compaction flag. The event also carries provider, thread, time, and optional turn and provider-instance identity.

packages/contracts/src/providerRuntime.ts:309–331 ↗verbatim · typescript · d63a433e
export const ThreadTokenUsageSnapshot = Schema.Struct({
  usedTokens: NonNegativeInt,
  totalProcessedTokens: Schema.optional(NonNegativeInt),
  maxTokens: Schema.optional(PositiveInt),
  inputTokens: Schema.optional(NonNegativeInt),
  cachedInputTokens: Schema.optional(NonNegativeInt),
  outputTokens: Schema.optional(NonNegativeInt),
  reasoningOutputTokens: Schema.optional(NonNegativeInt),
  lastUsedTokens: Schema.optional(NonNegativeInt),
  lastInputTokens: Schema.optional(NonNegativeInt),
  lastCachedInputTokens: Schema.optional(NonNegativeInt),
  lastOutputTokens: Schema.optional(NonNegativeInt),
  lastReasoningOutputTokens: Schema.optional(NonNegativeInt),
  toolUses: Schema.optional(NonNegativeInt),
  durationMs: Schema.optional(NonNegativeInt),
  compactsAutomatically: Schema.optional(Schema.Boolean),
});
export type ThreadTokenUsageSnapshot = typeof ThreadTokenUsageSnapshot.Type;
 
const ThreadTokenUsageUpdatedPayload = Schema.Struct({
  usage: ThreadTokenUsageSnapshot,
});
export type ThreadTokenUsageUpdatedPayload = typeof ThreadTokenUsageUpdatedPayload.Type;
Read this as: This union member carries the context snapshot payload. Provider, thread, timestamp, and optional turn/instance identity come from the canonical runtime-event base fields catalogued with the chapter sources.

At this pinned revision, source inspection finds canonical token-usage emission in two adapters:

Canonical live token usage emission paths found in the pinned provider adapters
AdapterProvider-side inputCanonical emission boundaryCoverage qualification
Codexnative thread/tokenUsage/updated notificationCodexAdapter normalizes it, rejecting a non-positive usedTokensemitted when that native notification arrives; total processed is read from native total, current use from native last
Claudenormalized Claude SDK message / task-progress usageemitThreadTokenUsageemitted only when normalization produced usage; the adapter remembers its last known values
Cursor, Grok, OpenCode, ACP adaptersno canonical emission branch found in this revisionabsence in this audit is not evidence that a provider can never expose usage; it means this build does not project it through this event
apps/server/src/provider/Layers/CodexAdapter.ts:159–190 ↗verbatim · typescript · fec887f1
function normalizeCodexTokenUsage(
  usage: EffectCodexSchema.V2ThreadTokenUsageUpdatedNotification["tokenUsage"],
): ThreadTokenUsageSnapshot | undefined {
  const totalProcessedTokens = usage.total.totalTokens;
  const usedTokens = usage.last.totalTokens;
  if (usedTokens === undefined || usedTokens <= 0) {
    return undefined;
  }
 
  const maxTokens = usage.modelContextWindow ?? undefined;
  const inputTokens = usage.last.inputTokens;
  const cachedInputTokens = usage.last.cachedInputTokens;
  const outputTokens = usage.last.outputTokens;
  const reasoningOutputTokens = usage.last.reasoningOutputTokens;
 
  return {
    usedTokens,
    ...(totalProcessedTokens !== undefined && totalProcessedTokens > usedTokens
      ? { totalProcessedTokens }
      : {}),
    ...(maxTokens !== undefined ? { maxTokens } : {}),
    ...(inputTokens !== undefined ? { inputTokens } : {}),
    ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}),
    ...(outputTokens !== undefined ? { outputTokens } : {}),
    ...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}),
    ...(usedTokens !== undefined ? { lastUsedTokens: usedTokens } : {}),
    ...(inputTokens !== undefined ? { lastInputTokens: inputTokens } : {}),
    ...(cachedInputTokens !== undefined ? { lastCachedInputTokens: cachedInputTokens } : {}),
    ...(outputTokens !== undefined ? { lastOutputTokens: outputTokens } : {}),
    ...(reasoningOutputTokens !== undefined
      ? { lastReasoningOutputTokens: reasoningOutputTokens }
      : {}),
Read this as: Codex converts native total/last fields and optional categories to the canonical snapshot.
apps/server/src/provider/Layers/CodexAdapter.ts:1000–1018 ↗verbatim · typescript · b5443e4a
  if (event.method === "thread/tokenUsage/updated") {
    const payload = readPayload(
      EffectCodexSchema.V2ThreadTokenUsageUpdatedNotification,
      event.payload,
    );
    const normalizedUsage = payload ? normalizeCodexTokenUsage(payload.tokenUsage) : undefined;
    if (!normalizedUsage) {
      return [];
    }
    return [
      {
        type: "thread.token-usage.updated",
        ...runtimeEventBase(event, canonicalThreadId),
        payload: {
          usage: normalizedUsage,
        },
      },
    ];
  }
Read this as: Only the native thread/tokenUsage/updated notification reaches the canonical Codex emission path.
apps/server/src/provider/Layers/ClaudeAdapter.ts:2051–2088 ↗verbatim · typescript · 1358344a
  const emitThreadTokenUsage = Effect.fn("emitThreadTokenUsage")(function* (
    context: ClaudeSessionContext,
    usage: ThreadTokenUsageSnapshot | undefined,
    options?: {
      readonly rawMethod?: string;
      readonly rawPayload?: unknown;
    },
  ) {
    if (!usage) {
      return;
    }
 
    context.lastKnownTokenUsage = usage;
    context.lastKnownTotalProcessedTokens =
      usage.totalProcessedTokens ?? context.lastKnownTotalProcessedTokens;
 
    const turnState = context.turnState;
    const stamp = yield* makeEventStamp();
    yield* offerRuntimeEvent({
      type: "thread.token-usage.updated",
      eventId: stamp.eventId,
      provider: PROVIDER,
      createdAt: stamp.createdAt,
      threadId: context.session.threadId,
      ...(turnState ? { turnId: turnState.turnId } : {}),
      payload: {
        usage,
      },
      providerRefs: nativeProviderRefs(context),
      ...(options?.rawMethod || options?.rawPayload
        ? {
            raw: {
              source: "claude.sdk.message" as const,
              ...(options.rawMethod ? { method: options.rawMethod } : {}),
              payload: options.rawPayload,
            },
          }
        : {}),
Read this as: Claude emits only after a usable normalization and stores its last known usage values.

The provider service validates the provider/instance association, optionally writes the canonical event to its best-effort NDJSON log, and publishes it on the hot runtime stream. Runtime ingestion accepts a token-usage event only when usedTokens > 0, then writes one context-window.updated activity with the event id as activity id. The web selector walks activities from newest to oldest and takes the first valid context-window payload. It derives percentage and remaining capacity from usedTokens / maxTokens only when a usable maximum exists. It does not sum snapshots, recover skipped notifications, or consult transcript files.

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:766–784 ↗verbatim · typescript · 32de8fdc
    case "thread.token-usage.updated": {
      const payload = buildContextWindowActivityPayload(event);
      if (!payload) {
        return [];
      }
 
      return [
        {
          id: event.eventId,
          createdAt: event.createdAt,
          tone: "info",
          kind: "context-window.updated",
          summary: "Context window updated",
          payload,
          turnId: toTurnId(event.turnId) ?? null,
          ...maybeSequence,
        },
      ];
    }
Read this as: Ingestion turns an accepted runtime token snapshot into a context-window activity.
apps/web/src/lib/contextWindow.ts:50–96 ↗verbatim · typescript · f414738a
export function deriveLatestContextWindowSnapshot(
  activities: ReadonlyArray<OrchestrationThreadActivity>,
): ContextWindowSnapshot | null {
  for (let index = activities.length - 1; index >= 0; index -= 1) {
    const activity = activities[index];
    if (!activity || activity.kind !== "context-window.updated") {
      continue;
    }
 
    const payload = asRecord(activity.payload);
    const usedTokens = asFiniteNumber(payload?.usedTokens);
    if (usedTokens === null || usedTokens < 0) {
      continue;
    }
 
    const maxTokens = asFiniteNumber(payload?.maxTokens);
    const usedPercentage =
      maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null;
    const remainingTokens =
      maxTokens !== null ? Math.max(0, Math.round(maxTokens - usedTokens)) : null;
    const remainingPercentage = usedPercentage !== null ? Math.max(0, 100 - usedPercentage) : null;
 
    return {
      usedTokens,
      totalProcessedTokens: asFiniteNumber(payload?.totalProcessedTokens),
      maxTokens,
      remainingTokens,
      usedPercentage,
      remainingPercentage,
      inputTokens: asFiniteNumber(payload?.inputTokens),
      cachedInputTokens: asFiniteNumber(payload?.cachedInputTokens),
      outputTokens: asFiniteNumber(payload?.outputTokens),
      reasoningOutputTokens: asFiniteNumber(payload?.reasoningOutputTokens),
      lastUsedTokens: asFiniteNumber(payload?.lastUsedTokens),
      lastInputTokens: asFiniteNumber(payload?.lastInputTokens),
      lastCachedInputTokens: asFiniteNumber(payload?.lastCachedInputTokens),
      lastOutputTokens: asFiniteNumber(payload?.lastOutputTokens),
      lastReasoningOutputTokens: asFiniteNumber(payload?.lastReasoningOutputTokens),
      toolUses: asFiniteNumber(payload?.toolUses),
      durationMs: asFiniteNumber(payload?.durationMs),
      compactsAutomatically: asBoolean(payload?.compactsAutomatically) ?? false,
      updatedAt: activity.createdAt,
    };
  }
 
  return null;
}
Read this as: The selector chooses the latest valid activity; the composer meter renders that one snapshot rather than a historical accumulation.

The historical lane scans files, not orchestration state

The version-4 usage contract intentionally reads selected CLI homes: Claude JSONL under a resolved Claude home (preferring .claude/projects, with a projects fallback) and Codex JSONL under the resolved Codex shared-home sessions tree. The server exposes preaggregated buckets, never raw transcript records. Consequently it can include provider turns made outside T3 Code, but it cannot account for a provider whose history is not in those two scanned layouts.

packages/contracts/src/usage.ts:1–101 ↗verbatim · typescript · f93dd8ad
/**
 * Usage reporting contract.
 *
 * Each environment scans the provider CLIs' own on-disk session transcripts
 * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.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 = 4 as const;
 
export const UsageProviderKind = Schema.Literals(["claude", "codex"]);
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 matched the model against 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,
});
Read this as: The contract fixes version 4, two scan providers, time-zone semantics, and token/cost categories.
apps/server/src/usage/UsageService.ts:187–225 ↗verbatim · typescript · d6526ea3
  /**
   * Claude's config dir is the home itself when overridden, but a default
   * install nests transcripts under `~/.claude/projects`. Probe both.
   */
  const resolveClaudeTranscriptDir = (homePath: string) =>
    Effect.gen(function* () {
      const nested = path.join(homePath, ".claude", "projects");
      const nestedExists = yield* fileSystem
        .exists(nested)
        .pipe(Effect.catchCause(() => Effect.succeed(false)));
      return nestedExists ? nested : path.join(homePath, "projects");
    });
 
  /** Resolves the transcript directory for each provider. */
  const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* () {
    // A settings failure must surface as an error: swallowing it here would
    // present "zero usage from every provider" as a valid answer.
    const settings = yield* settingsService.getSettings.pipe(
      Effect.catchCause(
        (cause) =>
          new UsageReadError({
            reason: "scanFailed",
            // Bounded description; the squashed failure travels as the cause.
            // Squashed, not the Cause tree: a full tree in a Defect field is
            // the unbounded wire payload the bounded detail exists to avoid.
            detail: "Server settings could not be read.",
            cause: Cause.squash(cause),
          }),
      ),
    );
 
    const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent);
    const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome);
    const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex);
 
    return [
      { provider: "claude" as const, dir: claudeDir },
      { provider: "codex" as const, dir: path.join(codexLayout.sharedHomePath, "sessions") },
    ];
Read this as: Discovery follows the legacy Claude and Codex settings paths. Window validation and candidate-file slack are catalogued separately below.

The time rule has two layers. Daily buckets use the caller-selected IANA time zone so a day reflects the user’s experience. Hourly queries have exact inclusive/ exclusive instants and are capped at 24 hours. Candidate files are screened using a UTC window with a 36-hour mtime slack, then individual records are parsed and tested against the actual requested range. A file’s mtime is therefore an optimization filter, not the semantic timestamp of a usage record.

Parser rules are provider-specific. Claude accepts assistant records with a usage object and deduplicates repeated content-block accounting by message/request key. Codex follows turn-context model state and last_token_usage deltas; it subtracts cached tokens from the reported input count and applies a one-second fork-copy suppression heuristic. Codex records carry no global duplicate key: consecutive delta suppression and fork-copy suppression are parser-local. That one-second threshold is implementation behavior, not a portable provider promise.

apps/server/src/usage/usageTranscripts.ts:78–137 ↗verbatim · typescript · 98addede
/**
 * Parses one line of a Claude Code transcript.
 *
 * T3 Code writes one record per assistant *content block*, and every one of
 * those records repeats the same complete `usage` object for the parent
 * message. Summing them overcounts by roughly 2.4x on a real workload, so the
 * caller must drop repeats by `dedupeKey` and keep the first.
 */
export function parseClaudeLine(line: string): UsageRecord | null {
  let parsed: unknown;
  try {
    parsed = JSON.parse(line);
  } catch {
    return null;
  }
  if (typeof parsed !== "object" || parsed === null) return null;
 
  const record = parsed as Record<string, unknown>;
  if (record["type"] !== "assistant") return null;
 
  const message = record["message"];
  if (typeof message !== "object" || message === null) return null;
  const messageRecord = message as Record<string, unknown>;
 
  const usage = messageRecord["usage"];
  if (typeof usage !== "object" || usage === null) return null;
  const usageRecord = usage as Record<string, unknown>;
 
  const timestampMs = parseTimestampMs(record["timestamp"]);
  if (timestampMs === null) return null;
 
  const model = typeof messageRecord["model"] === "string" ? messageRecord["model"] : "";
  if (model.length === 0) return null;
 
  const messageId = typeof messageRecord["id"] === "string" ? messageRecord["id"] : null;
  const requestId = typeof record["requestId"] === "string" ? record["requestId"] : null;
  // Matches ccusage: prefer the message/request pair, fall back to whichever
  // half exists. Records with neither cannot be de-duplicated.
  const dedupeKey =
    messageId === null && requestId === null ? null : `${messageId ?? ""}:${requestId ?? ""}`;
 
  const cost = record["costUSD"];
 
  return {
    provider: "claude",
    timestampMs,
    model,
    sessionId: typeof record["sessionId"] === "string" ? record["sessionId"] : "",
    totals: {
      uncachedInputTokens: int(usageRecord["input_tokens"]),
      cachedInputTokens: int(usageRecord["cache_read_input_tokens"]),
      cacheCreationTokens: int(usageRecord["cache_creation_input_tokens"]),
      outputTokens: int(usageRecord["output_tokens"]),
      // Anthropic folds thinking tokens into output and does not break them out.
      reasoningTokens: 0,
    },
    reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null,
    dedupeKey,
  };
}
Read this as: Claude assistant records require provider-specific normalization and duplicate keys. The separate Codex parser is catalogued below.

Deduplicate before bucketing, then price with provenance

The reader streams JSONL files and uses a cache only when path, provider, size, and mtime still match. A cache miss or unreadable file does not change the semantic deduplication order: after provider-local parser suppression, aggregation removes non-null duplicate keys within a file, then removes matching Claude copy/fork keys across files, then applies time bounds and buckets by day (and optional hour), provider, and model. Totals treat reasoning as a subset of output, not an additional token category.

apps/server/src/usage/usageAggregation.ts:109–178 ↗verbatim · typescript · ca4bb237
  /**
   * Folds one record in. Returns whether it actually contributed, so callers
   * can derive per-window facts (distinct sessions, for one) from the records
   * that landed rather than everything the mtime prefilter happened to admit.
   */
  add(record: UsageRecord): boolean {
    if (record.dedupeKey !== null) {
      if (this.#seen.has(record.dedupeKey)) {
        this.#duplicatesDropped += 1;
        return false;
      }
      this.#seen.add(record.dedupeKey);
    }
 
    if (
      this.#hourlyWindow !== null &&
      (record.timestampMs < this.#hourlyWindow.sinceTimeMs ||
        record.timestampMs >= this.#hourlyWindow.untilTimeMs)
    ) {
      this.#outOfWindow += 1;
      return false;
    }
 
    const day = this.#toDay(record.timestampMs);
    if (
      this.#hourlyWindow === null &&
      (day < this.#options.sinceDay || day > this.#options.untilDay)
    ) {
      this.#outOfWindow += 1;
      return false;
    }
 
    const hourStart =
      this.#hourlyWindow === null
        ? ""
        : new Date(
            this.#hourlyWindow.sinceTimeMs +
              Math.floor((record.timestampMs - this.#hourlyWindow.sinceTimeMs) / HOUR_MS) * HOUR_MS,
          ).toISOString();
    const key = `${day}\u0000${hourStart}\u0000${record.provider}\u0000${record.model}`;
    let bucket = this.#buckets.get(key);
    if (bucket === undefined) {
      bucket = {
        totals: EMPTY_TOTALS,
        costUsd: 0,
        cacheSavingsUsd: 0,
        records: 0,
        unpricedRecords: 0,
        providerReportedRecords: 0,
        sessions: new Set<string>(),
      };
      this.#buckets.set(key, bucket);
    }
 
    const priced = priceUsage(
      this.#options.rates,
      record.model,
      record.totals,
      record.reportedCostUsd,
    );
 
    bucket.totals = addTotals(bucket.totals, record.totals);
    bucket.costUsd += priced.costUsd;
    bucket.cacheSavingsUsd += cacheSavingsUsd(this.#options.rates, record.model, record.totals);
    bucket.records += 1;
    if (priced.costSource === "unpriced") bucket.unpricedRecords += 1;
    if (priced.costSource === "providerReported") bucket.providerReportedRecords += 1;
    if (record.sessionId.length > 0) bucket.sessions.add(record.sessionId);
    return true;
  }
Read this as: Global duplicate keys are applied before time bounds and aggregation into the selected bucket.

When a transcript contains a finite provider-reported USD value, that value wins. Otherwise the server can multiply uncached input, cache reads, cache creation, and output by a LiteLLM rate table. The cache is memory-then-disk, has a 24-hour TTL, and falls back to a still-cached table after a rate-fetch failure; without a usable table, a model is unpriced. The UI therefore labels the computed value as an API estimate. It is not evidence of subscription charges, discounts, enterprise contracts, or a provider invoice.

apps/server/src/usage/usagePricing.ts:85–148 ↗verbatim · typescript · c1d6f1e6
/**
 * Models we never price, regardless of the table.
 *
 * `<synthetic>` marks locally generated messages that were never billed. Bare
 * family names ("opus", "sonnet") are genuinely ambiguous across generations,
 * so we report them as unpriced instead of guessing a generation.
 */
const UNPRICEABLE_MODELS = new Set([
  "<synthetic>",
  "synthetic",
  "opus",
  "sonnet",
  "haiku",
  "fable",
]);
 
export function lookupRate(table: RateTable, model: string): ModelRate | null {
  const normalized = normalizeModelName(model);
  if (normalized.length === 0 || UNPRICEABLE_MODELS.has(normalized)) return null;
  return table.get(normalized) ?? null;
}
 
export interface PricedUsage {
  readonly costUsd: number;
  readonly costSource: UsageCostSource;
}
 
/**
 * Prices a bucket's tokens.
 *
 * `reasoningTokens` is intentionally not charged separately: it is already
 * counted inside `outputTokens`.
 */
export function priceUsage(
  table: RateTable,
  model: string,
  totals: UsageTokenTotals,
  reportedCostUsd: number | null,
): PricedUsage {
  if (reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) {
    return { costUsd: reportedCostUsd, costSource: "providerReported" };
  }
 
  const rate = lookupRate(table, model);
  if (rate === null) return { costUsd: 0, costSource: "unpriced" };
 
  const costUsd =
    totals.uncachedInputTokens * rate.inputCostPerToken +
    totals.cachedInputTokens * rate.cacheReadCostPerToken +
    totals.cacheCreationTokens * rate.cacheCreationCostPerToken +
    totals.outputTokens * rate.outputCostPerToken;
 
  return { costUsd, costSource: "modelPriced" };
}
 
/**
 * What the cached input would have cost at full input rates, minus what it
 * actually cost. Drives the "cache savings" figure.
 */
export function cacheSavingsUsd(table: RateTable, model: string, totals: UsageTokenTotals): number {
  const rate = lookupRate(table, model);
  if (rate === null) return 0;
  return totals.cachedInputTokens * (rate.inputCostPerToken - rate.cacheReadCostPerToken);
}
Read this as: Reported cost wins; otherwise pricing is a base-tier rate-table calculation with explicit unpriced handling. Rate-table acquisition and transcript scan caching are catalogued separately.

Coverage and merge are part of the result

Each environment returns source diagnostics and a fingerprint containing host id, provider, resolved home path, and filesystem volume id. The web and mobile clients request every connected environment, reject an incompatible contract version, and merge only one claimant for an identical non-missing source fingerprint. They sum distinct source sessions rather than adding bucket session counts, which could count one session repeatedly across day/model buckets.

packages/contracts/src/rpc.ts:260–284 ↗verbatim · typescript · a9bdb475
  previewAutomationFocusHost: "previewAutomation.focusHost",
 
  // Server meta
  serverProbe: "server.probe",
  serverGetConfig: "server.getConfig",
  serverRefreshProviders: "server.refreshProviders",
  serverUpdateProvider: "server.updateProvider",
  serverUpdateServer: "server.updateServer",
  serverUpdateServerWithProgress: "server.updateServerWithProgress",
  serverUpsertKeybinding: "server.upsertKeybinding",
  serverRemoveKeybinding: "server.removeKeybinding",
  serverGetSettings: "server.getSettings",
  serverUpdateSettings: "server.updateSettings",
  serverDiscoverSourceControl: "server.discoverSourceControl",
  serverGetTraceDiagnostics: "server.getTraceDiagnostics",
  serverGetProcessDiagnostics: "server.getProcessDiagnostics",
  serverGetProcessResourceHistory: "server.getProcessResourceHistory",
  serverGetResourceTelemetryHistory: "server.getResourceTelemetryHistory",
  serverRetryResourceTelemetry: "server.retryResourceTelemetry",
  serverSignalProcess: "server.signalProcess",
  serverReportClientActivity: "server.reportClientActivity",
  serverReportHostPowerState: "server.reportHostPowerState",
  serverGetBackgroundPolicy: "server.getBackgroundPolicy",
  serverGetUsageSummary: "server.getUsageSummary",
Read this as: The server exposes a typed, versioned Usage summary rather than raw transcript rows.
packages/shared/src/usageMerge.ts:102–163 ↗verbatim · typescript · 2ef3c27f
/**
 * Decides which environment owns each physical transcript directory.
 *
 * Several environments on one machine (worktree servers, for instance) resolve
 * the same provider home and would otherwise double count every token. The
 * first environment in a stable order claims a fingerprint; the rest have that
 * provider's buckets dropped. Environments are sorted by id so the winner does
 * not change between renders.
 */
function claimSources(environments: readonly EnvironmentUsage[]): {
  readonly ownerByFingerprint: ReadonlyMap<string, EnvironmentId>;
  readonly duplicates: readonly string[];
} {
  const ownerByFingerprint = new Map<string, EnvironmentId>();
  const duplicates: string[] = [];
 
  const ordered = [...environments].sort((a, b) => a.environmentId.localeCompare(b.environmentId));
 
  for (const environment of ordered) {
    for (const source of environment.summary.sources) {
      if (source.status === "missing") continue;
      const key = fingerprintKey(source.fingerprint);
      if (ownerByFingerprint.has(key)) {
        duplicates.push(`${environment.label}: ${source.fingerprint.resolvedHomePath}`);
        continue;
      }
      ownerByFingerprint.set(key, environment.environmentId);
    }
  }
 
  return { ownerByFingerprint, duplicates };
}
 
/** Sources this environment owns after fingerprint claims, plus their buckets. */
function ownedContribution(
  environment: EnvironmentUsage,
  ownerByFingerprint: ReadonlyMap<string, EnvironmentId>,
): {
  readonly buckets: readonly UsageBucket[];
  readonly sessionsByProvider: ReadonlyMap<UsageProviderKind, number>;
} {
  const ownedProviders = new Set<UsageProviderKind>();
  const sessionsByProvider = new Map<UsageProviderKind, number>();
  for (const source of environment.summary.sources) {
    if (source.status === "missing") continue;
    const key = fingerprintKey(source.fingerprint);
    if (ownerByFingerprint.get(key) === environment.environmentId) {
      const provider = source.fingerprint.provider;
      ownedProviders.add(provider);
      // Distinct within a directory. Summing per-bucket session counts instead
      // would count a session once per day and model it spans.
      sessionsByProvider.set(
        provider,
        (sessionsByProvider.get(provider) ?? 0) + source.distinctSessions,
      );
    }
  }
  return {
    buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)),
    sessionsByProvider,
  };
}
Read this as: The client merge rejects stale contract versions and uses exact source fingerprints to choose one environment's contribution. Total reconstruction is catalogued separately.
Figure 20.1 · Two measurements travel through different evidence chainsthe lanes share no accumulator
Live context and historical transcript accountingDiagram loading

Zoom with the controls, +/, or Ctrl/ + trackpad scroll. Enable Pan to drag, use two-finger scrolling, or use the arrow keys. 0 fits the diagram; Esc leaves Pan or expanded view.

Live context and historical transcript accounting
Text equivalent

The left lane starts with Codex or Claude provider events, validates a positive used-token snapshot, persists a context-window activity, selects the newest valid activity for one thread, and renders a context meter. The right lane starts with selected Claude and Codex transcript directories, streams JSONL records, parses provider-specific usage, removes within-file and cross-file duplicates, applies time bounds and buckets, obtains provider-reported or rate-table cost provenance, and merges environment summaries by a host/provider/home/volume fingerprint. There is no arrow from the context meter into the historical total.

Figure 20.1. Live context updates become a latest-valid thread activity and a composer meter. Historical usage scans selected provider transcript homes, normalizes and deduplicates records, optionally prices buckets, then merges equivalent physical sources across environments. No edge is drawn between the lanes: neither lane settles the other.

There is an important diagnostic limitation at this pinned revision. The contract permits ok, missing, partial, and failed source states, but the service path observed here emits missing when the directory is absent—or when the existence check itself fails—and otherwise reports ok. Directory listing errors are swallowed by the reader; unreadable files increment skipped-file work but return no records; malformed-record count is initialized to zero. An apparently ok zero is therefore not a proof that every eligible record was readable. Treat coverage indicators as useful evidence, not as completeness certificates.

apps/server/src/usage/usageTranscriptReader.ts:92–140 ↗verbatim · typescript · a4603871
/**
 * Streams one transcript and returns the usage records it contains, or `null`
 * when the file could not be read.
 *
 * The distinction matters to the caller's cache: a genuinely empty transcript
 * is a stable fact worth memoising, while a transient read failure memoised
 * under the same `(size, mtime)` key would silently drop that file's usage
 * until the file next changes.
 *
 * Codex carries the active model on `turn_context` lines that hold no usage of
 * their own, so those still have to pass through the reducer to keep model
 * attribution correct.
 */
export async function readTranscriptRecords(
  filePath: string,
  provider: UsageProviderKind,
): Promise<readonly UsageRecord[] | null> {
  const records: UsageRecord[] = [];
  const codexState = initialCodexScanState();
 
  try {
    const lines = NodeReadline.createInterface({
      input: NodeFS.createReadStream(filePath, { encoding: "utf8" }),
      crlfDelay: Infinity,
    });
 
    for await (const line of lines) {
      if (provider === "codex") {
        if (
          !mightCarryUsage(line, provider) &&
          !line.includes('"turn_context"') &&
          !line.includes('"session_meta"')
        ) {
          continue;
        }
        const record = parseCodexLine(line, codexState);
        if (record !== null) records.push(record);
        continue;
      }
 
      if (!mightCarryUsage(line, provider)) continue;
      const record = parseClaudeLine(line);
      if (record !== null) records.push(record);
    }
  } catch {
    return null;
  }
 
  return records;
Read this as: The reader returns null for an unreadable file rather than failing the whole scan. Directory-listing behavior is catalogued separately.
apps/server/src/usage/UsageService.ts:352–408 ↗verbatim · typescript · 619a5fb4
    const sources: UsageSource[] = [];
    const livePaths = new Set<string>();
    const walkedRoots: string[] = [];
 
    for (const { provider, dir } of dirs) {
      const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir));
      const exists = yield* fileSystem
        .exists(dir)
        .pipe(Effect.catchCause(() => Effect.succeed(false)));
 
      if (!exists) {
        sources.push({
          fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId },
          status: "missing",
          scannedFiles: 0,
          skippedFiles: 0,
          malformedRecords: 0,
          distinctSessions: 0,
          message: "No transcript directory on this environment.",
        });
        continue;
      }
 
      walkedRoots.push(dir);
      const files = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs));
      let scannedFiles = 0;
      let skippedFiles = 0;
      // Distinct per directory. Buckets carry per-cell session counts, but a
      // session spans days and models, so clients total this figure instead.
      const sessionIds = new Set<string>();
 
      for (const file of files) {
        livePaths.add(file.path);
        const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider);
        if (records.length === 0) {
          skippedFiles += 1;
          continue;
        }
        scannedFiles += 1;
        for (const record of records) {
          // Only sessions that contributed in-window count: the mtime slack
          // admits boundary files whose records fall outside the range.
          if (aggregator.add(record) && record.sessionId.length > 0) {
            sessionIds.add(record.sessionId);
          }
        }
      }
 
      sources.push({
        fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId },
        status: "ok",
        scannedFiles,
        skippedFiles,
        malformedRecords: 0,
        distinctSessions: sessionIds.size,
        message: null,
      });
Read this as: For an existing directory, this path reports status ok even when files were skipped, while malformedRecords is set to zero.

Work the accounting lab by hand

The lab keeps the two lanes visible. Its historical miniature has one duplicate inside a Claude file, one resumed/forked duplicate across files, and a distinct Codex record. Advance the stages to see why the final total is 218 tokens, while a live 88,000 / 200,000 context snapshot remains deliberately outside that total.

Interactive accounting laboratory

A file-derived total is built in stages

Move through the historical lane. The live context sample stays visible as a deliberately excluded operational signal.

Live lane, not an input: thread T-9 currently reports 88,000 / 200,000 context tokens. It changes the composer meter only; it contributes 0 tokens and $0 to the historical total below.

Stage 1 of 6 · parse records

Four candidate records arrive from three files

The scanner streams provider JSONL. These are candidate usage records, not yet a total.

FileRecordDedupe identityTokensStatus
claude/a.jsonlC-1am-1:r-1160first copy
claude/a.jsonlC-1bm-1:r-1160within-file duplicate
claude/b.jsonlC-1cm-1:r-1160cross-file copy
codex/x.jsonlX-1none · parser-local suppression58distinct record
Candidate tokens
538
Historical total
not calculated
Live meter input
excluded

Formulas used by the lab

processed = uncached input + cached input + cache creation + output

cost = uncached×inputRate + cached×cacheReadRate + creation×creationRate + output×outputRate

historical total = Σ kept, in-range records after source-fingerprint ownership

reasoning ⊆ output; do not add it a second time

Static walkthrough and no-JavaScript reference
  1. Parse. Candidate rows total 538 tokens: three 160-token Claude copies and one 58-token Codex record.
  2. Normalize. Claude's usage maps to 100 uncached + 30 cached + 10 creation + 20 output = 160. Codex's input includes cache, so it maps to 40 uncached + 10 cached + 0 creation + 8 output = 58.
  3. Within-file dedupe. Keep C-1a; drop C-1b, which repeats the same Claude message/request key in a.jsonl.
  4. Cross-file dedupe. Keep the first surviving Claude key; drop C-1c from the copied/resumed file. Codex has no global key here: its parser-local delta/fork suppression has already accepted distinct record X-1. The subtotal is 218 tokens.
  5. Bucket and price. Place the two retained records in their day/provider/model buckets. The displayed formula applies model rates only when no finite provider-reported cost exists; price provenance remains part of the result.
  6. Merge environments. Environment A owns the Claude source fingerprint. Environment B reports the identical fingerprint and is a shadow copy, so its Claude bucket is excluded. Environment C owns a different Codex fingerprint. The merged historical total remains 218 tokens.

Still excluded: the live 88,000 / 200,000 thread context snapshot. It answers a capacity question, not this historical file-accounting question.

Time, sessions, and scope are constraints—not decoration

  • A daily range is inclusive by local day in the supplied IANA zone. An hourly range uses exact [since, until) instants and can span at most 24 hours. Invalid IANA input falls back to UTC in the aggregation helper, so callers should supply a real zone rather than infer one after the fact.
  • Transcript session totals describe distinct contributing source sessions. They do not establish a one-to-one mapping to T3 threads, provider native sessions, live context snapshots, invoices, or a user’s total work.
  • Scan-home resolution uses legacy Claude/Codex provider settings. It is not a registry-wide scan of every possible provider instance configuration.
  • The scan cache trades repeated parsing for path/size/mtime reuse. It is local implementation state, not an immutable evidence store; cross-file dedupe still runs after cached records are read.
T3
Source-locked editionRead against fa219001d · 23 Aug 2026
Book search

Find a concept, module, or source path

Type two or more characters.