// Durable spend ledger: per-account token and cost totals. import { z } from "zod"; // One row per attributed turn, appended at turn end and never pruned, unlike the activity log which shrinks as it // prunes. Rollups project this on read, so a new grouping needs no new storage. export const UsageTurnSchema = z.object({ // Epoch ms when the turn ended; kept alongside `day` so a timezone-aware rollup needs no data migration. at: z.number().describe("When the turn ended, in milliseconds."), // UTC calendar day (YYYY-MM-DD) `at` fell in, precomputed so rollups skip timezone arithmetic. day: z.string().describe("The day it fell in, as YYYY-MM-DD in UTC, worked out once so nothing downstream has to do timezone arithmetic."), provider: z.string().describe("Which model provider served it."), // Absent on an env-token turn, which has no account to attribute to. account: z.string().optional().describe("Which account paid. Absent for a turn run on a plain key, which belongs to no account."), // The model that actually ran, past the pick and every default; absent only when an unnamed default served it. model: z .string() .optional() .describe( "The model that actually ran, past whatever was asked for and every default. Absent only when the provider's own default served it without being named.", ), // What was asked for, distinct from `model` (what ran); differs when something resolved or substituted it. modelRequested: z .string() .optional() .describe("The model that was asked for, when one was named. Differs from `model` when something resolved it."), harness: z.string().describe("Which agentic loop it ran on."), // The runtime that served it, resolved from provider and harness: `harness` is what was asked for, and rows written // before requests were normalized carry "claude-code" for providers that only ever run their own loop. runtime: z .string() .optional() .describe("The runtime that actually served it (claude-code, codex, opencode, cursor, acp, pi…). Absent on rows written before it was recorded."), // cancelled: stopped before it finished, not a failure: the user pressed Stop, or a daemon restart cut it (then // `errorCode` is `interrupted` and `errorMessage` says what became of it). // error: the provider or request killed the turn. // Absent means the row predates recording, not that the turn succeeded. outcome: z .enum(["ok", "error", "cancelled"]) .optional() .describe("How it ended: finished, failed, or stopped before it finished (by the user, or by a restart, whose errorCode is `interrupted`)."), // The failing frame's code (e.g. rate_limit, provider-outage); absent even on error means unclassified. errorCode: z.string().optional().describe("The failure's code, when it had one."), // The failing frame's own sentence, capped at ERROR_MESSAGE_CHARS. errorMessage: z.string().optional().describe("What the failure said, trimmed."), // Which conversation this turn belonged to; absent only for an internal one-shot with no conversation. conversationId: z .string() .optional() .describe( "Which conversation it belonged to, so spending can be traced to a card. Absent only for an internal one-off with no conversation at all.", ), // The ids a turn is followed by elsewhere: its turn id (activity log, failure lines), the run its transcript rows are // stamped with, and the provider session it ran in. Absent on rows written before they were recorded. turnId: z.string().optional().describe("The turn's id in the activity log and the daemon's log lines."), run: z.string().optional().describe("The run whose id the turn's transcript rows carry."), sessionId: z.string().optional().describe("The provider session the turn ran in."), // The provider's own count for the request, since one exchange can be several; 1 when unreported. turns: z .number() .describe("The provider's own count for the request, since one exchange can be several under the hood. One when it reported none."), inputTokens: z.number().describe("Uncached input tokens, excluding cache reads and cache writes."), outputTokens: z.number().describe("Tokens received."), cacheReadTokens: z.number().describe("Tokens served from cache, which cost less."), cacheCreationTokens: z.number().describe("Tokens written to cache, which cost more up front and less afterwards."), costUsd: z.number().describe("What it cost, in dollars."), // Whether `costUsd` was reported by the vendor (true or absent) or is zero only because the vendor does not price // its calls (false). Old rows before this field was added carry no marker, which readers treat as known: they // already recorded a real number. Runtimes like Codex and Cursor report tokens but no cost, so their turns land // with `costUsd: 0, costKnown: false` rather than silently claiming they were free. costKnown: z.boolean().optional().describe("False when the vendor did not report a cost and the recorded zero is a placeholder, not a price. Absent or true means the cost is real."), durationMs: z.number().describe("How long it took, in milliseconds."), // Arm of the search-teaching experiment; stable per conversation. Absent means unmeasured. iqSearchArm: z.boolean().optional(), // Hash of the plugin nudge and skill body for this arm; recorded on control turns too, for report pairing. iqSearchCohort: z.string().optional(), // Every tool call that searched, including CLI searches like `iq q`; absent means unmeasured, not zero. searchCalls: z.number().optional(), // Of searchCalls, the ones that ran iq: the search teaching's uptake. Absent on rows written before 2026-10-19. iqSearches: z.number().optional(), // Search calls before the first file touch; absent as searchCalls; a turn touching nothing counts them all. openingSearches: z.number().optional(), // Directory listings (ls, tree, LS tool) before the turn's first file touch, same window as openingSearches. openingListings: z.number().optional(), // Tool calls before the turn first touched a file it later edited; absent (not zero) if it edited nothing. callsBeforeTarget: z.number().optional(), // Calls that ended in error, counted once each however many updates reported it; absent means unmeasured, not a // clean turn. failedCalls: z.number().optional(), // Arm of the project-map experiment, stable per conversation; mapChars is the note's length when sent. mapArm: z.boolean().optional(), mapChars: z.number().optional(), // Which comparison `mapArm` belongs to: absent on the first map-or-none rows; "compact-vs-full" from 2026-10-06 to // 10-12, when the arm was the compact map and the holdout the full one; "map-vs-none" for 2026-10-12 to 10-13, the map // against none; "map-vs-none-2" since, the same with readings off the main agent's calls and spawned children out. mapCohort: z.string().optional(), // Arm of the field-notes experiment, stable per conversation; notesChars is what the budget let through. notesArm: z.boolean().optional(), notesChars: z.number().optional(), // Hash of the brief that was sent, recorded on control turns too. Load-bearing rather than decorative: the file is // rewritten monthly, so a 30-day window holds two different treatments and pooling them would measure neither. notesCohort: z.string().optional(), // Arm of the tool-search experiment, stable per conversation: true loads connected tools' schemas on demand, false // sends them all with every request. Absent where the turn was not a native Claude one on the Claude Code loop. toolSearchArm: z.boolean().optional(), // Arm of the stable-prompt experiment, stable per conversation: true says the guidance that moves between turns on // the message when it changed, false keeps it in the system block. Absent outside the Claude Code loop. stablePromptArm: z.boolean().optional(), // Assigned on cold-eligible follow-ups only, including controls; cleared results only when a copy was resumed. coldTrimArm: z.boolean().optional(), coldTrimmed: z.number().int().min(0).optional(), // What became of pre-turn retrieval on this turn, and how long it took. Assignment and DELIVERY are different // facts: the first version of this mechanism was assigned to every eligible turn and reached four in five of them, // which is the difference between a null result and a mechanism that never ran. Absent means the flag was off, so // nothing was attempted; `ineligible` means the message carried nothing to look up. turnContext: z.enum(["delivered", "ineligible", "deadline", "indexing", "no-hits", "failed"]).optional(), turnContextMs: z.number().optional(), // Zero-based turn count in its conversation; 0 marks the opening turn. Absent means unmeasured. turnIndex: z.number().optional(), // outcome alone can't tell a turn that finished from one that stopped talking; these fields carry the difference // instead of a single stop-reason word. // verification: verified/failing (check passed/failed after the last edit), unproven (nothing ran), no-code // (nothing editable changed). Folded from tool-call frames, subagents included. // check: the command that spoke, so a targeted test is never read as the whole suite. // filesEdited: every file written, prose included; verification speaks only about code. // toolCalls: every call the turn made, subagents' included; zero is a turn that spoke and acted on nothing. verification: z.enum(["verified", "unproven", "failing", "no-code"]).optional(), check: z.string().optional(), filesEdited: z.number().optional(), toolCalls: z.number().optional(), // The agent's own checklist at turn end (Task tool frames); checklistOpen is pending plus in-progress. Absent means // the turn kept no checklist at all. checklistTotal: z.number().optional(), checklistOpen: z.number().optional(), // compactions counts context compactions this turn. contextTokens/contextWindow are the last context_usage frame, // absent if unreported; raw numbers, not a percentage, since window size varies by model. compactions: z.number().optional(), contextTokens: z.number().optional(), contextWindow: z.number().optional(), // Absent is a turn; `keep-warm` is a cache refresh sent while the conversation sat idle, billed but not a turn. purpose: z.enum(["keep-warm"]).optional().describe("What the row is when it is not a turn: `keep-warm` is a cache refresh sent while the conversation sat idle."), // The first request alone: a warm resume reads nearly the whole context here, a cold one writes it. openingCacheReadTokens: z.number().optional().describe("Tokens the turn's first request read from the provider's cache."), openingCacheCreationTokens: z.number().optional().describe("Tokens the turn's first request wrote to the provider's cache."), // Equal on two turns means the prefix both sent was assembled from the same parts (PromptFingerprintSchema). promptFingerprint: z.string().optional().describe("A short hash over the parts of the prompt a cache is keyed on; a change between two turns names why the second could not reuse the first's cache."), // This turn's model was chosen by the Auto judge reading the conversation's opening message, not picked by hand. // Marked on that one turn only; a later row of the same conversation naming a different model is the user // overruling it, which is the escalation rate this feature has to be able to answer for. autoPicked: z.boolean().optional(), }); export type UsageTurn = z.infer; // Ledger grouped by day, provider, account, model, harness and conversation, one panel's worth of rows per active day. // Conversation is part of the key so cost-by-agent can be windowed like every other panel. export const UsageRollupRowSchema = z.object({ day: z.string().describe("The day, as YYYY-MM-DD in UTC."), provider: z.string().describe("Which model provider."), account: z.string().optional().describe("Which account. Absent for work run on a plain key."), model: z.string().optional().describe("Which model."), harness: z.string().describe("Which agentic loop."), conversationId: z.string().optional().describe("Which conversation."), turns: z.number().describe("Turns in this group."), inputTokens: z.number().describe("Uncached input tokens, excluding cache reads and cache writes."), outputTokens: z.number().describe("Tokens received."), cacheReadTokens: z.number().describe("Tokens served from cache."), cacheCreationTokens: z.number().describe("Tokens written to cache."), costUsd: z.number().describe("What the group cost, in dollars."), // True when every turn in the group reported its cost; false when at least one did not, making `costUsd` a // lower bound rather than the true total. Absent on rollups from before this field existed. costKnown: z.boolean().optional().describe("False when the cost includes unpriced turns and is a lower bound rather than the true total."), durationMs: z.number().describe("Time spent, in milliseconds."), }); export type UsageRollupRow = z.infer; // Inclusive UTC day bounds (YYYY-MM-DD); both absent means the whole ledger. Shared by every windowed ledger read, so // filters stay in step. export const DayWindowQuerySchema = z.object({ from: z.string().optional().describe("First day to include, as YYYY-MM-DD in UTC. Leave it out for everything up to the end day."), to: z .string() .optional() .describe( "Last day to include, as YYYY-MM-DD in UTC, and it is included rather than excluded. Leave it out for everything from the start day onwards.", ), }); export type DayWindowQuery = z.infer; export const UsageRollupSchema = z.object({ rows: z .array(UsageRollupRowSchema) .describe( "Spending grouped by day, provider, account, model and conversation. Everything a cost screen shows is a rearrangement of these rows, which is why there is no second call for any of it.", ), }); // Account picker's headroom readout, folded from the ledger all-time (not a windowed log), grouped by provider and // account. Env-token turns are excluded, not pooled under a blank id. export const UsageAccountSchema = z.object({ provider: z.string(), account: z.string(), turns: z.number(), inputTokens: z.number(), outputTokens: z.number(), cacheReadTokens: z.number(), cacheCreationTokens: z.number(), costUsd: z.number(), }); export type UsageAccount = z.infer; export const UsageSummarySchema = z.object({ accounts: z.array(UsageAccountSchema) });