/** * M84 — the runtime-neutral half of cost tracking. * * ## Why it moved * * 1 715 LOC of observability and cost lived under `packages/theo/src/server/**`, and `theokit` is * the Vite/React web framework. The only real product built on this stack depends on * `@theokit/agents`, `@theokit/tui` and `@theokit/presenter`, and never on `theokit` — a grep for * `from 'theokit` in it returns zero. So none of it was reachable, and what would have been is * HTTP-shaped. * * What crosses is the vocabulary and the in-memory adapter: a record, a query, a result, and a place * to put them. The HTTP wiring (`trackAgentRun`, spans named `http.request`, keying by `requestId`) * stays in `theokit`, because that is what it is about. * * ## `userId` is optional here, and that is the point * * The HTTP path always has one. A terminal agent has no user — it has a person at a keyboard — and * requiring the field forced every terminal product to invent a constant. An invented identifier is * worse than an absent one: it looks like data. */ /** Per-LLM-call cost record. */ interface UsageRecord { /** Discriminator. Optional for backward compatibility; absent ⇒ `'llm'`. */ kind?: 'llm'; /** Who the cost belongs to. Absent for a single-user surface such as a terminal. */ userId?: string; /** Model id (`claude-sonnet-4-5-20250929`). */ model: string; /** * The token split this cost is for. * * `cacheRead` and `cacheWrite` are OPTIONAL and absent by default. Cached reads are billed at a * fraction of input tokens, so two runs with identical `input` totals and different cache ratios * cost different amounts — and a record that carried only the totals stated a number it could not * explain. The audit trail showed the figure and not the reason. * * ABSENT, never `0`, when a provider does not report cache usage. "Not reported" and "reported as * zero" are different facts, and defaulting would claim a measurement nobody made — the same * strictness `RunUsageSnapshot` keeps by returning `undefined` before a provider has spoken. * * The spellings are this layer's own (`cacheRead`/`cacheWrite`), matching `DoneEvent.usage`'s * `cacheReadTokens`/`cacheWriteTokens` rather than the wire's * `cache_read_input_tokens`. A survey looking for the wire spellings measured zero here and * concluded the concept was absent; it has been carried on the stream since V4-O. */ tokens: { input: number; output: number; cacheRead?: number; cacheWrite?: number; }; /** USD in fractional dollars. */ costUsd: number; timestamp: Date; } /** Per-tool-invocation record. */ interface ToolUsageRecord { kind: 'tool'; userId?: string; conversationId: string; toolName: string; /** Unique per invocation; correlates start with end/error. */ callId: string; success: boolean; durationMs: number; /** Populated only when `success === false`. */ errorMessage?: string; timestamp: Date; } interface UsageQuery { /** Omitted ⇒ every record, which is what a single-user surface wants. */ userId?: string; period?: { from: Date; to: Date; }; /** * Narrow to one model — what a per-model cost breakdown is. * * It is a QUERY rather than a second shape on {@link UsageResult}, because a breakdown returned * alongside a total is two numbers that can disagree. One source, asked a different question, * cannot. * * A TOOL record has no model and is already excluded from the LLM totals; narrowing by model * cannot resurrect one. */ model?: string; } interface UsageResult { totalTokens: number; totalCostUsd: number; runs: number; } /** Where usage goes. The framework owns the vocabulary; the app owns the storage. */ interface UsageStorageAdapter { readonly name: string; record(input: UsageRecord | ToolUsageRecord): Promise; getUsage(query: UsageQuery): Promise; } /** * M84 — usage in memory. * * Deliberately does NOT implement `theokit`'s `StorageAdapter`: that is a lifecycle contract of the * web framework, and requiring it here is exactly the coupling that made cost tracking unreachable * from a terminal product. The `theokit` side extends this one and adds the lifecycle. * * Concurrency: Node's event loop makes `Array.push` atomic, so concurrent `record()` calls cannot * lose data. This is single-process by construction — a multi-instance deploy supplies its own * adapter, which is what the interface is for. */ declare class InMemoryUsageStorage implements UsageStorageAdapter { #private; readonly name = "memory"; record(input: UsageRecord | ToolUsageRecord): Promise; getUsage(query?: UsageQuery): Promise; /** * The most recent LLM record, or `undefined`. * * The question a terminal asks after every turn — "what did that cost?" — and the reason a * consumer wrote a ten-line file to answer it. Tool records are excluded: a tool invocation has no * token cost of its own, and returning one here would make the footer show zero after every tool. */ latestUsage(): UsageRecord | undefined; } /** * The run's REAL token usage, reachable from inside a tool handler. * * ## The gap this closes * * A coding agent that wants to manage a long task the way Codex's `get_context_remaining` does needs * one number from inside a tool: how much of the context window this run has already spent. Measured * against this package, there were four routes to it and none arrived: * * 1. The tool handler's `ctx` is `{signal, context, messages, threadId}`. `messages` is a text-only * projection of the transcript — a tool can COUNT it, but counting is an estimate. * 2. No hook context carries usage: `PostAssistantReplyContext` is `{prompt, reply, agentId, runId}`. * 3. `BudgetTracker` does carry real provider counts, but it is an `Agent.create` option nobody * inside a tool holds, and the compiled agent exposes no seam to install one. * 4. The number a surface displays is read surface-side, off the client thread — outside this * package entirely, and unreachable from a handler running inside the loop. * * What was left was `~4 chars per token` over `ctx.messages`. An estimate presented as a measurement * is the failure mode this layer already paid for once (M93 classified transience by a regex over an * error message and read `ECONNREFUSED …:443` as a 4xx). A consumer that refuses to ship that and * ships nothing instead is making the right call with the wrong options. * * ## Where the number comes from — the provider, never an estimate * * `BudgetTracker.track(event)` is called by the SDK agent loop after EACH LLM completion, with the * provider's own `inputTokens` / `outputTokens` (verified in the shipped `@theokit/sdk@4.52.1` * bundle: `chunk-KELIQH7K.js:6325-6345`, inside `runIteration`, immediately after `streamLlmTurn` * and BEFORE the tool dispatch of that same iteration — so a tool called on iteration N reads the * usage of iterations 1..N). * * The SDK's own `.d.ts` disclaims that wiring ("wired to the type surface only … consumers passing a * custom tracker today get the type guarantee but NOT runtime enforcement"), and * `sdk-adapter.ts::applyStepCeiling` refuses to build the step cap on it for exactly that reason. * That refusal was right for a CAP and does not transfer to a READING, because the two fail in * opposite directions: a cap built on a disclaimed option silently stops capping, while a reading * built on it silently reports **nothing** — and "nothing" is what {@link RunUsageSnapshot} is * designed to say. An SDK that does not call `track()` leaves this seam answering `undefined` * forever, which is true, rather than answering `0`, which would not be. * * ## `undefined` and `0` are different facts * * `snapshot()` returns `undefined` until the FIRST provider report arrives, and the tokens it then * reports are only ever ones a provider stated. "We do not know yet" and "zero tokens were used" are * both real states of a run and only one of them is ever true at a time; collapsing them into `0` * would hand the model a number to reason with in the exact rounds where there is nothing to reason * from. */ /** * A reading of the run's token usage so far. Every field is provider-reported or derived from * provider-reported values — nothing here is estimated. */ interface RunUsageSnapshot { /** Prompt tokens the provider has reported across this run's completed LLM calls. */ readonly inputTokens: number; /** Completion tokens the provider has reported across this run's completed LLM calls. */ readonly outputTokens: number; /** `inputTokens + outputTokens`. */ readonly totalTokens: number; /** * The model's context window in tokens — present ONLY when the caller DECLARED one * (`ModelSelection.contextWindow`), which is the same input the SDK's own * `resolveEffectiveContextWindow` treats as the authoritative `override`. * * Absent for a bare-string model id, and deliberately NOT filled from * `resolveModelCapabilities(modelId).maxContextTokens`: that resolver answers unknown models with * "conservative defaults (all false, minimum tokens)" and returns no flag saying which happened, * so a catalog miss is indistinguishable from a catalog hit. A window invented for an unknown * model is the same plausible-looking lie as an estimated token count, one layer up. */ readonly contextWindowTokens?: number; /** * `contextWindowTokens - totalTokens`, floored at zero. Present exactly when * {@link contextWindowTokens} is — "remaining" needs both halves, and reporting it from one would * be reporting a subtraction against a number nobody supplied. */ readonly remainingTokens?: number; } /** * Read {@link RunUsageSnapshot} off a tool handler's `ctx`. * * The typed door into a field the SDK's `ToolContext` does not declare. The adapter injects `usage` * the same way it injects `context` (M7): theokit owns the concern, so theokit puts it on the ctx * rather than waiting for the SDK's tool-call internals to forward it. Reading it through this * function instead of `(ctx as {usage}).usage` keeps the cast in ONE place and keeps a consumer from * inventing its own idea of the shape. * * Returns `undefined` for every honest form of "not known": the surface did not opt in * (`exposeUsageToTools`), no provider report has arrived yet, or the ctx is not a tool ctx at all. * A caller that must distinguish those three has a design question, not a data question — every one * of them means the same thing to a model asking how much room is left. */ declare function readRunUsage(ctx: unknown): RunUsageSnapshot | undefined; export { InMemoryUsageStorage, type RunUsageSnapshot, type ToolUsageRecord, type UsageQuery, type UsageRecord, type UsageResult, type UsageStorageAdapter, readRunUsage };