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.
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;At this pinned revision, source inspection finds canonical token-usage emission in two adapters:
| Adapter | Provider-side input | Canonical emission boundary | Coverage qualification |
|---|---|---|---|
| Codex | native thread/tokenUsage/updated notification | CodexAdapter normalizes it, rejecting a non-positive usedTokens | emitted when that native notification arrives; total processed is read from native total, current use from native last |
| Claude | normalized Claude SDK message / task-progress usage | emitThreadTokenUsage | emitted only when normalization produced usage; the adapter remembers its last known values |
| Cursor, Grok, OpenCode, ACP adapters | no 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 |
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 }
: {}), 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,
},
},
];
} 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,
},
}
: {}),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.
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,
},
];
}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;
}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.
/**
* 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,
}); /**
* 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") },
];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.
/**
* 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,
};
}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.
/**
* 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;
}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.
/**
* 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);
}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.
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",/**
* 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,
};
}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.
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.
packages/contracts/src/providerRuntime.ts:309–331 ↗apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:766–784 ↗apps/web/src/lib/contextWindow.ts:50–96 ↗packages/contracts/src/usage.ts:1–101 ↗apps/server/src/usage/usageAggregation.ts:109–178 ↗apps/server/src/usage/usagePricing.ts:85–148 ↗packages/contracts/src/rpc.ts:260–284 ↗packages/shared/src/usageMerge.ts:102–163 ↗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.
/**
* 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; 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,
});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.
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.
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.
| File | Record | Dedupe identity | Tokens | Status |
|---|---|---|---|---|
claude/a.jsonl | C-1a | m-1:r-1 | 160 | first copy |
claude/a.jsonl | C-1b | m-1:r-1 | 160 | within-file duplicate |
claude/b.jsonl | C-1c | m-1:r-1 | 160 | cross-file copy |
codex/x.jsonl | X-1 | none · parser-local suppression | 58 | distinct 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
- Parse. Candidate rows total 538 tokens: three 160-token Claude copies and one 58-token Codex record.
- Normalize. Claude's usage maps to
100 uncached + 30 cached + 10 creation + 20 output = 160. Codex's input includes cache, so it maps to40 uncached + 10 cached + 0 creation + 8 output = 58. - Within-file dedupe. Keep
C-1a; dropC-1b, which repeats the same Claude message/request key ina.jsonl. - Cross-file dedupe. Keep the first surviving Claude key; drop
C-1cfrom the copied/resumed file. Codex has no global key here: its parser-local delta/fork suppression has already accepted distinct recordX-1. The subtotal is 218 tokens. - 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.
- 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.