Part V · Six providers, one product modelUsage accounting
Chapter 35source checked

Usage accounting without a false ledger

Live thread context, historical transcript accounting, and provider-reported subscription limits answer different questions and keep separate evidence, merge, pricing, and refresh rules.

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.
  • Explain Grok transcript accounting, custom prices, pooled subscription windows, hubs, and reset credits.
  • 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 three different questions in T3 Code.

The composer asks: how full is this thread’s context window at the latest valid provider update? The Usage history asks: what token-shaped records can this environment read from provider transcript directories over a historical time range? The Limits view asks: how much provider-reported subscription capacity remains across the selected accounts and environments? They share words such as model, tokens, account, 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 · a1a7e41b
const ThreadMetadataUpdatedPayload = Schema.Struct({
  name: Schema.optional(TrimmedNonEmptyStringSchema),
  metadata: Schema.optional(UnknownRecordSchema),
});
export type ThreadMetadataUpdatedPayload = typeof ThreadMetadataUpdatedPayload.Type;
 
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),
  autoCompactThreshold: Schema.optional(PositiveInt),
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.

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 revision—absence 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 · e401d51b
    method,
    detail: error.message,
    cause: error,
  });
}
 
type CodexLifecycleItem =
  | EffectCodexSchema.V2ItemStartedNotification["item"]
  | EffectCodexSchema.V2ItemCompletedNotification["item"];
 
type CodexToolUserInputQuestion =
  | EffectCodexSchema.ServerRequest__ToolRequestUserInputQuestion
  | EffectCodexSchema.ToolRequestUserInputParams__ToolRequestUserInputQuestion;
 
const ApprovalDecisionPayload = Schema.Struct({
  decision: ProviderApprovalDecision,
});
 
function readPayload<A>(
  schema: Schema.Schema<A>,
  payload: ProviderEvent["payload"],
): A | undefined {
  const isPayload = Schema.is(schema);
  return isPayload(payload) ? payload : undefined;
}
 
function trimText(value: string | undefined | null): string | undefined {
  const trimmed = value?.trim();
  return trimmed && trimmed.length > 0 ? trimmed : undefined;
}
 
function asUnknownRecord(value: unknown): Record<string, unknown> | undefined {
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 · b9c3a0d0
    readPayload(EffectCodexSchema.V2ItemCompletedNotification, event.payload);
  const item = payload?.item;
  if (!item) {
    return undefined;
  }
  const itemType = toCanonicalItemType(item.type);
  if (itemType === "unknown" && lifecycle !== "item.updated") {
    return undefined;
  }
 
  const detail = itemDetail(itemType, item);
  const toolPresentation = item.type === "mcpToolCall" ? mcpToolPresentation(item) : {};
  const title = itemTitle(itemType, item, toolPresentation);
  const status =
    lifecycle === "item.started"
      ? "inProgress"
      : lifecycle === "item.completed"
        ? "status" in item && (item.status === "failed" || item.status === "declined")
          ? item.status
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 · 8d97f57f
      resumeCursor,
      updatedAt: yield* nowIso,
    };
  });
 
  const ensureAssistantTextBlock = Effect.fn("ensureAssistantTextBlock")(function* (
    context: ClaudeSessionContext,
    blockIndex: number,
    options?: {
      readonly fallbackText?: string;
      readonly streamClosed?: boolean;
    },
  ) {
    const turnState = context.turnState;
    if (!turnState) {
      return undefined;
    }
 
    const existing = turnState.assistantTextBlocks.get(blockIndex);
    if (existing && !existing.completionEmitted) {
      if (existing.fallbackText.length === 0 && options?.fallbackText) {
        existing.fallbackText = options.fallbackText;
      }
      if (options?.streamClosed) {
        existing.streamClosed = true;
      }
      return { blockIndex, block: existing };
    }
 
    const block: AssistantTextBlockState = {
      itemId: yield* randomUUIDv4,
      blockIndex,
      emittedTextDelta: false,
      fallbackText: options?.fallbackText ?? "",
      streamClosed: options?.streamClosed ?? false,
      completionEmitted: false,
    };
    turnState.assistantTextBlocks.set(blockIndex, block);
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 · b7b9aaab
        },
      ];
    }
 
    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,
Read this as: Ingestion turns an accepted runtime token snapshot into a context-window activity.
apps/web/src/lib/contextWindow.ts:50–90 ↗verbatim · typescript · 7f8275c0
    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,
      autoCompactThreshold: asFiniteNumber(payload?.autoCompactThreshold),
      updatedAt: activity.createdAt,
    };
  }
 
  return null;
}
 
export function formatContextWindowTokens(value: number | null): string {
  if (value === null || !Number.isFinite(value)) {
    return "0";
  }
  if (value < 1_000) {
    return `${Math.round(value)}`;
  }
  if (value < 10_000) {
    return `${(value / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
  }
  if (value < 1_000_000) {
    return `${Math.round(value / 1_000)}k`;
  }
  return `${(value / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`;
Read this as: The selector chooses the latest valid activity and derives remaining-token values from that one snapshot rather than a historical accumulation.

The historical lane scans three provider homes, not orchestration state

The version-5 usage contract reads selected CLI homes: Claude JSONL under a resolved Claude home (preferring .claude/projects, with a projects fallback), Codex JSONL under the resolved Codex shared-home sessions tree, and Grok updates.jsonl files under its resolved sessions home. 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 three scanned layouts.

packages/contracts/src/usage.ts:1–101 ↗verbatim · typescript · d41abdde
/**
 * 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.
Read this as: The contract fixes version 5, three scan providers, compatibility with version 4 summaries, time-zone semantics, and token/cost categories.
apps/server/src/usage/UsageService.ts:187–225 ↗verbatim · typescript · b74098d7
      }
    }
 
    const fetched = yield* httpClient.get(LITELLM_RATES_URL).pipe(
      Effect.flatMap(HttpClientResponse.filterStatusOk),
      Effect.flatMap((response) => response.json),
      Effect.timeout(10_000),
      Effect.catchCause(() => Effect.succeed(null)),
    );
    if (fetched === null) {
      // The refresh failed; whatever we are serving is now past its TTL and
      // must not keep claiming to be fresh.
      if (rates.size > 0) ratesStatus = "cached";
      return;
    }
 
    const parsed = parseRateTable(fetched);
    if (parsed.size === 0) return;
 
    rates = parsed;
    ratesFetchedAtMs = now;
    ratesStatus = "fresh";
 
    yield* encodeRatesCache({ fetchedAtMs: now, document: fetched }).pipe(
      Effect.flatMap((serialized) => fileSystem.writeFileString(ratesCachePath, serialized)),
      Effect.catchCause(() => Effect.void),
    );
  });
 
  const ensureRates = (force: boolean) => ratesLock.withPermit(loadRates(force));
 
  const refreshRates = ensureRates(true).pipe(
    Effect.map(pricing),
    Effect.withSpan("UsageService.refreshRates"),
  );
 
  /**
   * Claude's config dir is the home itself when overridden, but a default
   * install nests transcripts under `~/.claude/projects`. Probe both.
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. Grok reads completed-turn updates, can split model-level usage, converts provider cost ticks, and uses session, prompt, and model identity for deduplication when available. 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 · 0e09c48f
 * headless `total_cost_usd_ticks`. Convert to dollars for pricing.
 */
export const GROK_COST_USD_TICKS_PER_DOLLAR = 10_000_000_000;
 
function grokCostTicksToUsd(ticks: unknown): number | null {
  if (typeof ticks !== "number" || !Number.isFinite(ticks) || ticks < 0) return null;
  return ticks / GROK_COST_USD_TICKS_PER_DOLLAR;
}
 
/* -------------------------------------------------------------------------- */
/* Claude Code                                                                */
/* -------------------------------------------------------------------------- */
 
/**
 * 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,
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 · f68e5e1f
 
  /**
   * 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,
      this.#options.priceOverrides,
    );
 
    bucket.totals = addTotals(bucket.totals, record.totals);
    bucket.costUsd += priced.costUsd;
    bucket.cacheSavingsUsd += cacheSavingsUsd(
      this.#options.rates,
      record.model,
      record.totals,
      this.#options.priceOverrides,
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 · 5490c87f
    const key = normalizeRateKey(name);
    if (key.length === 0) continue;
    table.set(key, {
      inputCostPerToken: input,
      outputCostPerToken: output,
      // Anthropic bills cache reads at a discount and cache writes at a
      // premium. When a model omits them, cached input is priced as plain
      // input rather than as free.
      cacheReadCostPerToken: finiteNumber(entry.cache_read_input_token_cost) ?? input,
      cacheCreationCostPerToken: finiteNumber(entry.cache_creation_input_token_cost) ?? input,
    });
  }
 
  // `null` marks a bare name claimed at conflicting rates: no alias for it.
  const aliasCandidates = new Map<string, ModelRate | null>();
  for (const [key, rate] of table) {
    const alias = bareModelName(key);
    if (alias.length === 0 || alias === key || table.has(alias)) continue;
    const held = aliasCandidates.get(alias);
    if (held === undefined) {
      aliasCandidates.set(alias, rate);
    } else if (held !== null && !sameRate(held, rate)) {
      aliasCandidates.set(alias, null);
    }
  }
  for (const [alias, rate] of aliasCandidates) {
    if (rate !== null) table.set(alias, rate);
  }
 
  return table;
}
 
function sameRate(a: ModelRate, b: ModelRate): boolean {
  return (
    a.inputCostPerToken === b.inputCostPerToken &&
    a.outputCostPerToken === b.outputCostPerToken &&
    a.cacheReadCostPerToken === b.cacheReadCostPerToken &&
    a.cacheCreationCostPerToken === b.cacheCreationCostPerToken
  );
}
 
function normalizeRateKey(model: string): string {
  return model.trim().toLowerCase();
}
 
function bareModelName(key: string): string {
  const slash = key.lastIndexOf("/");
  return slash === -1 ? key : key.slice(slash + 1);
}
 
/**
 * Drops a bracketed variant suffix such as `claude-fable-5-1[1m]`, which
 * Claude Code writes for the 1M context tier. The rate table only knows the
 * base name, and we price at the base tier anyway.
 */
function stripVariantSuffix(key: string): string {
  const bracket = key.indexOf("[");
  return bracket === -1 ? key : key.slice(0, bracket);
}
 
/**
 * Models we never price, regardless of the table.
 *
 * `<synthetic>` marks locally generated messages that were never billed. Bare
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.

Custom prices are environment settings

Automatic model pricing is a convenience, not an authority. Web and desktop let a user set exact per-million input, output, cache-read, and cache-write rates for a model on one or several environments. Blank cache rates inherit the input rate; zero means free. Saving is fan-out work: each destination reports success, and a failed destination can be retried without rewriting the environments that already accepted the change. Closing the dialog does not create an offline write queue.

The historical scan applies these overrides before automatic pricing. A client that selects several environments can therefore see a mixed value when their local price settings differ. This is an intentional consequence of environment ownership.

Subscription limits are provider and account state

Provider snapshots can carry normalized rolling windows with stable ids, a kind, label, used percentage, optional reset time, and optional duration. Sparse updates merge by window id so one rate-limit event does not erase unrelated windows. An unavailable reason distinguishes an account that cannot report limits from a probe that failed while a prior good snapshot may still be useful.

The Limits view pools equivalent accounts across selected environments and optional CLIProxyAPI hubs. It presents remaining capacity, keeps the same account segment in the same visual column across windows, and explains the next reset. Codex accounts may also report banked reset credits; redemption pins the request to the displayed credit id so a retry cannot silently consume a different credit.

/usage-limits reads the same snapshot inside a thread. It does not run an agent or refresh provider state. API-key accounts and some proxy configurations may never report subscription windows, which is different from a temporary probe failure.

Three independent usage productsDiagram 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.

Three independent usage products
packages/contracts/src/rpc.ts:260–284 ↗verbatim · typescript · 41f4187f
  projectsListEntries: "projects.listEntries",
  projectsReadFile: "projects.readFile",
  projectsSearchContents: "projects.searchContents",
  projectsSearchEntries: "projects.searchEntries",
  projectsWriteFile: "projects.writeFile",
 
  // Shell methods
  shellOpenInEditor: "shell.openInEditor",
 
  // Filesystem methods
  filesystemBrowse: "filesystem.browse",
  agentSessionsScan: "agentSessions.scan",
  agentSessionsImport: "agentSessions.import",
  assetsCreateUrl: "assets.createUrl",
  attachmentsCreateUploadUrl: "attachments.createUploadUrl",
  attachmentsDelete: "attachments.delete",
 
  // Provider methods
  providerUploadFeedback: "provider.uploadFeedback",
  providerAuthStart: "provider.auth.start",
  providerConsumeResetCredit: "provider.consumeResetCredit",
  providerAuthComplete: "provider.auth.complete",
  providerAuthCancel: "provider.auth.cancel",
  providerAuthLogout: "provider.auth.logout",
  providerAuthSubscribe: "provider.auth.subscribe",
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 · acc6d30d
 
/**
 * 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 35.1 · Operational context and historical accounting 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 a provider event, 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 Claude, Codex, and Grok transcript directories, streams JSONL records, parses provider-specific usage, removes duplicates, applies time bounds and buckets, obtains provider-reported or configured/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 35.1. Live context updates become a latest-valid thread activity and a composer meter. Historical usage scans 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. 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 · 71265f90
 * Errors on individual entries are swallowed: session files rotate and get
 * removed while the walk is in flight, and a partial listing is far better than
 * failing the page.
 *
 * `fileName` restricts the walk to a single basename (Grok's `updates.jsonl`).
 * Grok sessions also ship multi-megabyte `chat_history` and `events` logs that
 * never carry usage, so the basename filter keeps a cold scan off those files.
 */
export async function listTranscriptFiles(
  root: string,
  sinceMs: number,
  options?: { readonly fileName?: string },
): Promise<readonly TranscriptFile[]> {
  const found: TranscriptFile[] = [];
  const fileName = options?.fileName;
 
  const walk = async (dir: string): Promise<void> => {
    let entries;
    try {
      entries = await NodeFSP.readdir(dir, { withFileTypes: true });
    } catch {
      return;
    }
    for (const entry of entries) {
      const child = NodePath.join(dir, entry.name);
      if (entry.isDirectory()) {
        await walk(child);
        continue;
      }
      if (fileName !== undefined) {
        if (entry.name !== fileName) continue;
      } else if (!entry.name.endsWith(".jsonl")) {
        continue;
      }
      try {
        const stats = await NodeFSP.stat(child);
        if (stats.mtimeMs >= sinceMs) {
          found.push({ path: child, size: stats.size, mtimeMs: stats.mtimeMs });
        }
      } catch {
        // Vanished between readdir and stat.
      }
    }
  };
 
  await walk(root);
  return found;
}
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 · 7de0ee2d
      // resumed parse dedupes exactly like a full one.
      const base = parsed.resumed && cached !== undefined ? cached.records : [];
      const seen = new Set<string>();
      const records = dedupeWithinFile([...base, ...parsed.records], seen);
      const tailRecords = dedupeWithinFile(parsed.tailRecords, seen);
 
      fileCache.set(filePath, {
        size,
        mtimeMs,
        provider,
        records,
        tailRecords,
        position: parsed.position,
      });
      cacheDirty = true;
      return tailRecords.length === 0 ? records : [...records, ...tailRecords];
    });
 
  /** One provider directory's walk and parse, before rates are involved. */
  interface ScannedDir {
    readonly provider: UsageProviderKind;
    readonly dir: string;
    readonly volumeId: string;
    /** Parsed records per file, or `null` when the directory does not exist. */
    readonly files:
      | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[]
      | null;
  }
 
  const collectDirs = Effect.fn("UsageService.collectDirs")(function* (
    windowStartMs: number,
    settings: ServerSettingsValue,
  ) {
    // The home resolvers ask for `Path` themselves; satisfy them from the
    // instance we already hold so the scan stays context-free.
    const dirs = yield* resolveTranscriptDirs(settings).pipe(
      Effect.provideService(Path.Path, path),
    );
    const scanned: ScannedDir[] = [];
    for (const { provider, dir, fileName } of dirs) {
      const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir));
      const exists = yield* fileSystem
        .exists(dir)
        .pipe(Effect.catchCause(() => Effect.succeed(false)));
      if (!exists) {
        scanned.push({ provider, dir, volumeId, files: null });
        continue;
      }
      const files = yield* Effect.promise(() =>
        listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }),
      );
      const parsedFiles: { path: string; records: readonly UsageRecord[] }[] = [];
      for (const file of files) {
        const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider);
        parsedFiles.push({ path: file.path, records });
      }
      scanned.push({ provider, dir, volumeId, files: parsedFiles });
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 859304b78 · 10 Sept 2026
Book search

Find a concept, module, or source path

Type two or more characters.