import type { SqliteDatabase } from '../driver.js'; import type { ClientId, CostTotals, TokenTotals, TurnKind, UsageRecord } from '../../models/usage-record.js'; export interface UsageFilter { /** Inclusive lower bound, ISO 8601. */ since?: string; /** Exclusive upper bound, ISO 8601. */ until?: string; /** * Scope filters, each matching ANY of the values given. * * Lists rather than single values because one value could not answer "these * two projects" or "these three models" without N calls and a client-side * join. An EMPTY list means "none of them" and matches nothing -- it is not * treated as "no filter", which is how an empty `--model ""` used to answer a * narrowed question with the whole database. */ clients?: ClientId[]; models?: string[]; projectPaths?: string[]; sessionId?: string; /** * Include subagent/sidechain turns. Defaults to true: they are real spend. * The same default is used by the CLI and the MCP tools -- see README. */ includeSubagents?: boolean; /** * Every model id the pricing table can price, so each aggregate can report how * many of its records no estimate was even attempted for. * * Deliberately a caller-supplied list rather than a lookup: the repository has * no business importing the pricing table, and a caller who does not supply one * gets `unpricedRecords: undefined` -- "not asked" -- instead of a 0 that would * claim every model is priced. An empty array is a real answer (nothing is * priced) and is not the same as omitting it. */ pricedModels?: string[]; } export interface AggregateRow extends TokenTotals { records: number; sessions: number; /** * Cache writes split by TTL. Kept alongside the combined `cacheWriteTokens` * because the two are priced differently (1.25x vs 2x of the input rate), so * anything re-pricing a period needs them separately. */ cacheWrite5mTokens: number; cacheWrite1hTokens: number; cost: CostTotals; firstTimestamp?: string; lastTimestamp?: string; } /** * Totals for one (client, model, speed) combination -- the smallest grouping that * can be honestly re-priced at a different model's rates -- further split at * each long-context threshold asked for, so no group straddles a rate card. */ export interface RepriceGroup extends AggregateRow { client: ClientId; model: string; /** Absent when the source never recorded a speed. Not the same as 'standard'. */ speed?: string; /** * The longest single prompt in the group (input + cache read + cache write). * Every prompt in the group falls on the same side of each threshold the * groups were split at, so this picks the rate card all of them get. */ maxPromptTokens: number; } /** * One stored turn. The first non-aggregate shape in this repository: every other * read collapses rows, which makes per-turn questions -- how context grew, what a * single turn cost -- unanswerable. */ export interface TurnRow { id: string; client: ClientId; provider: string; model: string; sessionId: string; projectPath?: string; timestamp: string; turnKind: TurnKind; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; cacheWrite5mTokens: number; cacheWrite1hTokens: number; reasoningTokens: number; totalTokens: number; cost?: number; estimatedCost?: number; costBasis: string; /** As recorded by the source; 'fast' billed at premium rates. Absent if unsaid. */ speed?: string; /** Git branch the turn ran on. Absent when the source does not record one. */ gitBranch?: string; } /** Sessions reach thousands of turns, so a row read is always bounded. */ export declare const TURNS_DEFAULT_LIMIT = 200; export declare const TURNS_MAX_LIMIT = 5000; export interface GroupedRow extends AggregateRow { key: string; } /** * How a list of rows is ordered. * * There is deliberately no plain `cost`. Reported and estimated cost are * separate figures that must never be summed, so "order by cost" has no single * answer: ordering by one silently sorts every row priced on the other basis as * though it were $0. The caller has to say which, and the report says how many * rows that ordering could not speak for. */ export type SortKey = 'tokens' | 'reported-cost' | 'estimated-cost' | 'records' | 'sessions' | 'recent'; export declare const SORT_KEYS: SortKey[]; /** * Time buckets, all evaluated in local time. * * `hour-of-day` is not a finer timeline -- it collapses every day onto one * 24-slot clock, which is the only grain that answers "when during the day do I * burn tokens". The other two are ordinary timelines. */ export type TimeGrain = 'hour' | 'day' | 'hour-of-day'; export declare const TIME_GRAINS: TimeGrain[]; /** * A dimension a breakdown can be cut by. * * The time axes are the same expressions {@link TimeGrain} uses, so a * `project x day` breakdown and `daily --project X` bucket identically -- two * reports that disagreed about what a day is would be worse than one report. */ export type GroupAxis = 'client' | 'model' | 'provider' | 'project' | 'session' | 'day' | 'hour' | 'hour-of-day'; export declare const GROUP_AXES: GroupAxis[]; /** * Crossing three axes on a busy database is already tens of thousands of rows, * and every one of them has to be rendered somewhere. More than that is a * question better asked of the exported records. */ export declare const MAX_GROUP_AXES = 3; /** One cell of a cross-tab: the value on each axis, plus the usual aggregate. */ export interface CrossTabRow extends AggregateRow { /** Keyed by axis name, in the order the axes were requested. */ keys: Record; } export interface PageRequest { limit?: number; offset?: number; sort?: SortKey; } /** * A page of rows that knows what it is a page OF. * * `--limit` alone tells a caller nothing about what it did not see, so walking a * list or reporting "the top 5" was guesswork. `total` counts the groups the * filter matches, ignoring limit and offset. */ export interface Page { rows: T[]; /** Groups matching the filter, before `limit`/`offset`. */ total: number; offset: number; /** Absent when the caller set no limit, i.e. every row was returned. */ limit?: number; hasMore: boolean; /** The offset that fetches the next page, or undefined when there is none. */ nextOffset?: number; sort: SortKey; /** * Rows carrying no figure on the sorted basis, which therefore sort as $0. * * They are not cheap; they are priced on the other basis, or not at all. Only * meaningful for the two cost sorts, where it is 0 otherwise. */ rowsWithoutSortValue: number; } export interface SessionRow extends AggregateRow { sessionId: string; client: ClientId; projectPath?: string; models: string[]; startedAt: string; endedAt: string; /** Wall-clock span between the first and last priced turn, in seconds. */ durationSeconds: number; mainRecords: number; subagentRecords: number; /** The client's own title for the session. Absent when it has none. */ title?: string; /** Branch of the session's most recent turn that recorded one. */ gitBranch?: string; } export declare class UsageRepository { private readonly db; private readonly upsertStmt; constructor(db: SqliteDatabase); /** Idempotent by design: re-syncing the same source data must not change totals. */ upsertMany(records: UsageRecord[]): number; totals(filter?: UsageFilter): AggregateRow; /** * One page of grouped rows, plus what it is a page of. * * The group count is a second query over the same WHERE clause rather than a * window function, because `node:sqlite` and `better-sqlite3` must agree and * the driver contract here is deliberately small. */ private groupedPage; /** * Total groups, and how many of them carry nothing on the sorted basis. * * The second number is what stops "sorted by estimated cost" from quietly * presenting every reported-cost row as a $0 at the bottom of the list. */ private groupStats; byClient(filter?: UsageFilter, page?: PageRequest): Page; byModel(filter?: UsageFilter, page?: PageRequest): Page; byProvider(filter?: UsageFilter, page?: PageRequest): Page; /** Every session's totals, unpaged. For reconciliation, not for display. */ bySession(filter?: UsageFilter): GroupedRow[]; /** * Token totals grouped by every dimension that changes what they would cost: * `client`, `model` and `speed`. * * `byModel` is not enough to re-price a period. `client` decides whether * reasoning tokens are already inside `output_tokens` (Claude Code) or a * sibling of them (OpenCode), and `speed` decides whether the premium fast-mode * rates applied. Collapsing either one and then re-pricing would quietly get * the arithmetic wrong -- see docs/DATA_SOURCES.md. */ /** * @param splitAbove Prompt lengths to split each group at -- the long-context * thresholds of the models about to price it. */ repriceGroups(filter?: UsageFilter, splitAbove?: number[]): RepriceGroup[]; byProject(filter?: UsageFilter, page?: PageRequest): Page; /** * Per-day totals bucketed in LOCAL time, matching how `resolvePeriod` derives * its bounds from local midnight. Bucketing on `substr(timestamp,1,10)` would * be UTC, which puts a turn made late in the evening into the wrong day for * every user east of Greenwich -- and silently disagrees with the very period * filter that selected the rows. `localtime` reads the OS timezone database, * so it stays correct across DST changes where a fixed offset would not. */ byDay(filter?: UsageFilter): GroupedRow[]; /** * Totals bucketed on the time axis at the requested grain, newest first. * * Every grain is evaluated in LOCAL time with `localtime`, matching how * `resolvePeriod` derives its bounds from local midnight. Bucketing on * `substr(timestamp,...)` would be UTC, which puts a turn made late in the * evening in the wrong bucket for every user east of Greenwich -- and silently * disagrees with the very period filter that selected the rows. `localtime` * reads the OS timezone database, so it stays correct across DST changes where * a fixed offset would not. */ byTime(filter?: UsageFilter, grain?: TimeGrain): GroupedRow[]; /** * Totals cut by two or more dimensions at once. * * `daily --project X` answers one project's trend, but answering "which of my * projects is getting more expensive" meant enumerating projects, issuing one * call each, and joining client-side -- an N+1 that is not feasible from an * agent loop at all. One query, one tidy row set. */ crossTab(axes: GroupAxis[], filter?: UsageFilter, page?: PageRequest): Page; /** Cells matching the filter, and how many carry nothing on the sorted basis. */ private crossTabStats; /** The first and last activity matching a filter, for deciding zero-fill bounds. */ timeBounds(filter?: UsageFilter): { first?: string; last?: string; }; /** * Individual turns, oldest first so a caller can read a session as a series. * Always bounded: `limit` defaults to {@link TURNS_DEFAULT_LIMIT} and is capped * at {@link TURNS_MAX_LIMIT}, because a single session can exceed 2,000 turns. */ turns(filter?: UsageFilter, options?: { limit?: number; offset?: number; }): TurnRow[]; /** * Every turn matching the filter, grouped by session and oldest first within * each, streamed rather than loaded. For whole-period analysis (the usage * report), which must see every turn and cannot page through {@link turns}. */ eachTurn(filter?: UsageFilter): Generator; /** The client's own title for a session, if it has one. */ sessionTitle(client: ClientId, sessionId: string): string | undefined; /** Stores the clients' own session titles; the latest one seen wins. */ upsertSessionTitles(client: ClientId, titles: { sessionId: string; title: string; }[]): void; /** * Models that have stored rows with no cost, among these clients, that the * pricing table can now price. * * A row is `unavailable` when its model was missing from the table at the time * it was collected. Incremental sync never revisits it, so without this the * row stays unpriced for good, however many releases later the table learns * the model. */ unpricedModelsNowPriced(clients: readonly ClientId[], pricedModels: string[]): string[]; /** Every unpriced stored row for one model, unpaged: read only in order to price it. */ unpricedTurns(clients: readonly ClientId[], model: string): TurnRow[]; /** * Records estimates for rows that had none, in one transaction. * * Guarded on `cost_basis = 'unavailable'`, so a row a concurrent sync has * already priced is left as that sync wrote it. */ setEstimates(estimates: { id: string; estimatedCost: number; }[]): number; /** Total turns matching a filter, so a caller can page without guessing. */ countTurns(filter?: UsageFilter): number; /** * A page of sessions. * * Recency is still the default, but it is now a choice rather than the only * option: `--limit` on a recency-ordered list actively HIDES the most * expensive session unless it also happens to be recent. */ sessions(filter?: UsageFilter, page?: PageRequest): Page; /** Resolves a full or unambiguous partial session id. */ findSessionIds(partial: string, limit?: number): string[]; modelsForSession(sessionId: string): GroupedRow[]; /** * Which of `values` appear in `column` nowhere in the database. * * Deliberately unfiltered by period: it separates "you typed a name that does * not exist" from "that project was quiet last week". Both used to render as * the same empty report and the same exit 0. * * `column` is not caller data -- it comes from a fixed set in the service -- * but it is asserted against an allow-list anyway, because a column name is * the one part of this query that cannot be a bound parameter. */ absentValues(column: 'model' | 'project_path' | 'client', values: string[]): string[]; recordCount(): number; countsByClient(): { client: ClientId; records: number; lastTimestamp: string | null; }[]; /** Distinct token-bearing turn kinds present, for honest "includes subagents" labelling. */ turnKindCounts(filter?: UsageFilter): Record; /** * How many records a prune would remove, so a destructive command can say what * it is about to do before it does it. */ countBefore(cutoff: string, filter?: UsageFilter): number; /** * Removes records older than `cutoff`, honouring the same scope filters as a * read so a prune cannot be broader than the report that justified it. * * `until` is exclusive everywhere else in this repository, and stays exclusive * here: `--before 2026-01-01` removes 2025 and keeps New Year's Day. An * off-by-one in the one irreversible operation is not a class of bug worth * risking for a tidier boundary. */ deleteBefore(cutoff: string, filter?: UsageFilter): number; deleteByClient(client: ClientId): number; }