/** * Token Usage Storage Service * * Tracks token usage per agent session with in-memory storage and * optional periodic flush to ~/.crewly/token-usage.json. * * @module services/monitoring/token-usage.service */ /** * A single token usage event recorded after an agent message */ export interface TokenUsageEvent { /** Timestamp of the usage event */ timestamp: string; /** Agent identifier */ agentId: string; /** Number of input tokens consumed */ input: number; /** Number of output tokens generated */ output: number; /** AI model used for this event */ model: string; /** Optional task ID for per-task tracking */ taskId?: string; /** * Portion of `input` the provider served from its prompt cache (billed at * a fraction of the input rate). Absent when the runtime cannot tell. */ cachedInput?: number; /** Model round-trips the run took (agentic steps). Absent when unknown. */ steps?: number; /** * Of `cachedInput`, the tokens written to the cache (billed above the input * rate) rather than read from it. Claude only; absent when unknown. */ cacheWrite?: number; /** * Runtime that produced the event (`codex-cli`, `antigravity-cli`, …), set * by the sources that know it. Absent on older events and on sources that * only know the model; {@link runtimeOfEvent} then infers it from the model. */ runtime?: string; /** * Run trace the session was working on when the usage happened * (specs/2026-10-03-run-traces.md). Absent outside a trace. */ traceId?: string; /** * Provider message id of the turn (Claude transcripts: `message.id`), so a * later import can dedupe by id. Absent on older events and on sources * without one (specs/2026-10-03-usage-ledger-durability.md). */ messageId?: string; } /** Optional per-event detail beyond the raw input/output counts. */ export interface TokenUsageDetail { cachedInput?: number; /** See {@link TokenUsageEvent.cacheWrite}. */ cacheWrite?: number; steps?: number; /** * When the usage actually happened, ISO-8601. * * Backfilled sources (a Claude Code transcript read minutes later) must pass * the turn's own timestamp, otherwise every event lands at import time and * any per-day or per-hour view of the data is wrong. */ timestamp?: string; /** See {@link TokenUsageEvent.runtime}. */ runtime?: string; /** See {@link TokenUsageEvent.traceId}; looked up from the session when absent. */ traceId?: string; /** See {@link TokenUsageEvent.messageId}. */ messageId?: string; } /** * Per-session token usage record containing all events and aggregates */ export interface SessionUsageRecord { /** Session name */ sessionName: string; /** Agent identifier associated with this session */ agentId: string; /** Total input tokens across all events */ totalInput: number; /** Total output tokens across all events */ totalOutput: number; /** Number of usage events recorded */ eventCount: number; /** Total cached (cache-hit) input tokens across all events; absent on records written before 2026-09-18 */ totalCachedInput?: number; /** Individual usage events */ events: TokenUsageEvent[]; } /** * Aggregated usage summary for a single agent */ export interface AgentUsageSummary { /** Total input tokens consumed by this agent */ totalInput: number; /** Total output tokens generated by this agent */ totalOutput: number; /** Number of usage events */ eventCount: number; } /** * Per-session summary for dashboard display */ export interface SessionUsageSummary { /** Session name */ sessionName: string; /** Agent identifier */ agentId: string; /** Total input tokens */ totalInput: number; /** Total output tokens */ totalOutput: number; /** Number of events */ eventCount: number; /** Total cache-hit input tokens, when the runtime reports them */ totalCachedInput?: number; /** Computed cost in USD based on model pricing */ cost: number; /** Per-model breakdown of token usage */ modelBreakdown: ModelUsageBreakdown[]; } /** * Token usage breakdown for a specific model */ export interface ModelUsageBreakdown { /** Model identifier */ model: string; /** Input tokens for this model */ inputTokens: number; /** Output tokens for this model */ outputTokens: number; /** Computed cost for this model in USD */ cost: number; } /** * Per-model token cost rates in USD per token. * Used for server-side cost calculation. */ export declare const TOKEN_COSTS: Record; /** * Calculate the cost for a given number of tokens and model. * * When the model has a `cachedInput` rate and the caller passes how many of * the input tokens were cache hits, those tokens are billed at the hit rate * and only the remainder at the full input rate. * * @param inputTokens - Total input tokens for the call(s), cache hits included * @param outputTokens - Number of output tokens * @param model - Model identifier * @param cachedInputTokens - Portion of `inputTokens` served from cache (default 0) * @returns Cost in USD */ export declare function calculateCost(inputTokens: number, outputTokens: number, model: string, cachedInputTokens?: number): number; /** * USD cost of one recorded usage event — the one cost computation every * spend view uses (session windows, the team budget gate, the ticket * autopilot budget, the per-agent spend cap). * * The ledger holds two shapes of event: * - in-process runs (`provider/model`, e.g. DeepSeek): `cachedInput` is the * cache-hit PART of `input`, priced with {@link TOKEN_COSTS}; * - Claude Code transcript turns: `input` is the fresh tokens only and * `cachedInput` (cache reads + `cacheWrite`) comes on top, priced with the * cache-aware model-pricing table. Pricing these with {@link TOKEN_COSTS} * dropped every cached token and used sonnet rates for opus. * * @param event - The usage event * @returns Cost in USD */ export declare function eventCostUsd(event: Pick): number; /** * How confidently {@link eventCostUsd} priced an event's model — the same * branching it uses: `exact` (listed by id), `family` (matched on a family * substring) or `default` (nothing matched; a default rate was assumed). * * @param model - Model id as recorded * @returns Rate source */ export declare function eventCostRateSource(model: string): 'exact' | 'family' | 'default'; /** The token figures of one event in the owner-facing unit (see {@link eventTokens}). */ export interface EventTokens { /** Every input token, cached ones included */ input: number; /** The cached part of {@link input} (cache reads + cache writes) */ cachedInput: number; output: number; /** * {@link input} + {@link output}: the RAW total, for reporting. Cache reads * count in full here, so it overstates what a long-lived agent costs ~10x. */ total: number; /** Of {@link cachedInput}, the part written to the cache (Claude only) */ cacheWrite: number; /** * The COST-WEIGHTED total ("budget tokens"): what every budget, cap and * boost compares. fresh input x w.input + output x w.output + cache writes * x w.cacheWrite + cache reads x w.cacheRead, with the weights of the * event's model family ({@link budgetWeightsFor}). Not rounded. */ budget: number; } /** How many budget tokens one token of each kind is worth. */ export interface BudgetWeights { /** Fresh (uncached) input */ input: number; /** Output */ output: number; /** Tokens written to the prompt cache */ cacheWrite: number; /** Tokens read from the prompt cache */ cacheRead: number; } /** * The default budget weights, and the Claude ones: input x1 + output x1 + * cache writes x1.25 + cache reads x0.1. They equal the cache/input price * ratios of most Anthropic tiers in `model-pricing.ts` (cache reads 0.1x, * 5-minute cache writes 1.25x); every Claude model uses them. Output stays at * x1 on purpose: the budget is a measure of context re-reading, not a USD * figure, and an owner's existing numbers keep their meaning. */ export declare const DEFAULT_BUDGET_WEIGHTS: Readonly; /** * The budget weights of a model family. Cache weights are the ratios of the * family's own cache price to its input price, read from the pricing table * (`model-pricing.ts`) so there is one source for them: * * | Family | cache read | cache write | * |---|---|---| * | Claude (opus / sonnet / haiku / fable) | 0.1 | 1.25 | * | OpenAI gpt-5 / codex (Codex CLI) | 0.1 | 1 (no separate write price) | * | DeepSeek (crewly-agent) | ~0.26 | 1 | * | Gemini 2.5 pro / flash (Antigravity) | ~0.25 | 1 | * | Unknown | {@link DEFAULT_BUDGET_WEIGHTS} | * * @param model - Model id as recorded * @returns Weights; `input` and `output` are always 1 * * @example * budgetWeightsFor('claude-sonnet-5-5') // { input: 1, output: 1, cacheWrite: 1.25, cacheRead: 0.1 } */ export declare function budgetWeightsFor(model: string): BudgetWeights; /** * Whether an event's `cachedInput` is part of its `input` (true) or comes * on top of it (false). * * - In-process runs (`provider/model`, e.g. DeepSeek) report the provider's * prompt-token count, which includes cache hits: part of `input`. * - Claude Code transcript turns, Codex rollouts and Antigravity record * `input` as the fresh tokens only, with the cached ones on top. * * @param event - Usage event * @returns True when cached tokens are already inside `input` */ export declare function cachedIsPartOfInput(event: Pick): boolean; /** * The token unit. Two figures from one function: * * total = input tokens (fresh + cached) + output tokens (raw, for reports) * budget = cost-weighted total (cache reads x0.1, writes x1.25 for Claude) * * Every cap, boost and budget GATE compares `budget`; `total` stays for * reporting and is shown beside it. Whether an account is billed by * subscription or by API makes no difference. * specs/2026-10-02-spend-cap.md §Token unit. * * @param event - Usage event * @returns Input (cached included), cached input, output, total * * @example * eventTokens({ model: 'claude-opus-5-5', input: 10, cachedInput: 990, output: 50 }).total // 1050 * eventTokens({ model: 'deepseek/deepseek-chat', input: 1000, cachedInput: 990, output: 50 }).total // 1050 */ export declare function eventTokens(event: Pick & { cacheWrite?: number; }): EventTokens; /** * Set a cost override for a session. When present, getSummary() uses this * instead of recalculating from events. */ export declare function setSessionCostOverride(sessionName: string, cost: number): void; /** * Get a cost override if one exists. */ export declare function getSessionCostOverride(sessionName: string): number | undefined; /** * Service for tracking and aggregating token usage across agent sessions. * * Uses an in-memory Map keyed by sessionName, with optional periodic * flush to ~/.crewly/token-usage.json for persistence across restarts. * * @example * ```typescript * const service = TokenUsageService.getInstance(); * service.recordUsage('session-1', 'agent-a', 500, 200, 'claude-3-opus'); * const summary = service.getUsageByAgent('agent-a'); * ``` */ export declare class TokenUsageService { private static instance; /** In-memory storage keyed by session name */ private readonly sessions; /** Active task context per session (sessionName → { taskId, runId }) */ private readonly taskContexts; /** Timer handle for periodic flush */ private flushTimer; /** Directory for persisting usage data */ private readonly storageDir; /** * `not-loaded` until {@link loadFromDisk} ran; `blocked` while the file on * disk is bad and could not be copied aside (flushes refuse to write). */ private loadState; /** Why the ledger is blocked */ private blockedReason; private logger; /** * Create a new TokenUsageService instance * * @param storageDir - Directory for persistence file (defaults to ~/.crewly) */ constructor(storageDir?: string); /** * Get the singleton TokenUsageService instance * * @returns The shared TokenUsageService instance */ static getInstance(): TokenUsageService; /** * Reset the singleton instance (for testing) */ static resetInstance(): void; /** * Record a single token usage event for a session. * * Creates the session record if it doesn't exist, then appends the event * and updates running totals. * * @param sessionName - The session name (e.g. 'crewly-orc') * @param agentId - The agent identifier * @param input - Number of input tokens consumed * @param output - Number of output tokens generated * @param model - AI model identifier used * @param taskId - Optional task the usage belongs to * @param detail - Optional cache-hit and step counts for this event */ recordUsage(sessionName: string, agentId: string, input: number, output: number, model: string, taskId?: string, detail?: TokenUsageDetail): void; /** * Set a cache-aware cost override for a session. * Used when the caller has pre-calculated cost with proper cache pricing. */ overrideSessionCost(sessionName: string, cost: number): void; /** * Get aggregated token usage for a specific agent across all sessions. * * @param agentId - The agent identifier to query * @returns Aggregated usage summary for the agent */ getUsageByAgent(agentId: string): AgentUsageSummary; /** * Get per-session usage summaries for the dashboard. * * @returns Array of session usage summaries */ getUsageBySessions(taskId?: string): SessionUsageSummary[]; /** * Set the current task context for a session. * All subsequent recordUsage calls for this session will be tagged with the taskId. * * @param sessionName - Session name * @param taskId - Task ID to associate, or null to clear * @param runId - Optional run ID for precise run-level attribution */ setTaskContext(sessionName: string, taskId: string | null, runId?: string): void; /** * Get the current task context for a session (taskId only, backward compatible). * * @param sessionName - Session name * @returns Current task ID or null */ getTaskContext(sessionName: string): string | null; /** * Get the full task+run context for a session. * * @param sessionName - Session name * @returns Object with taskId and optional runId, or null if no context */ getRunContext(sessionName: string): { taskId: string; runId?: string; } | null; /** * Get aggregated token usage grouped by task ID. * * @returns Map of taskId to aggregated token totals */ getUsageByTask(): Map; /** * Get token usage events for a specific session recorded after a given timestamp. * Used to compute the token delta for a single Request when the orchestrator * handles the work directly (no WorkItem delegation). * * @param sessionName - The session to query (e.g. 'crewly-orc') * @param since - Only return events recorded at or after this time * @param until - Only return events before this time (defaults to now) * @returns Aggregated input tokens, output tokens, and cost in the window, * plus the token-unit figures ({@link eventTokens}): `totalTokens` (raw, * for reporting), `budgetTokens` (cost-weighted; what caps and budgets * compare) and `cachedInputTokens` */ getSessionUsageSince(sessionName: string, since: Date, until?: Date): { inputTokens: number; outputTokens: number; cost: number; totalTokens: number; budgetTokens: number; cachedInputTokens: number; }; /** * Visit every recorded event at or after `since` (all events when omitted). * Read-only; used by the spend ledger for per-day / per-agent views. * * @param visit - Called with the session name and the event * @param since - Lower bound (inclusive) */ forEachEvent(visit: (sessionName: string, event: TokenUsageEvent) => void, since?: Date): void; /** * Clear all tracked token usage data. */ resetUsage(): void; /** * Start periodic flush of usage data to disk. * * Writes the current in-memory state to ~/.crewly/token-usage.json * at a fixed interval. */ startPeriodicFlush(): void; /** * Stop the periodic flush timer. */ stopPeriodicFlush(): void; /** * Flush current usage data to disk. * * Writes sessions as a JSON array, atomically (temp file → fsync → rename), * so a full disk or a crash leaves the previous file intact instead of a * truncated one (the 2026-10-03 ledger loss). Refuses to write while a bad * ledger file is still waiting to be copied aside, and loads the file first * if this process never read it. * * @throws When the write fails (logged as an error) or the ledger is blocked */ flushToDisk(): Promise; /** * Load previously persisted usage data from disk. * * Merges loaded data into the current in-memory state. * * - missing file: start fresh; * - unreadable or invalid file: copied aside to * `token-usage.json.corrupt-`, logged as an error, start fresh; * - and if that copy fails, or the file cannot be read at all (EMFILE, * EIO…): logged, and the ledger is marked blocked. Each flush reads it * again first and writes only once it was loaded or set aside. */ loadFromDisk(): Promise; /** * Whether the ledger refuses to flush because its file is bad and could * not be copied aside yet. * * @returns True while blocked */ isBlocked(): boolean; /** * Add historical events (a backfill) to a session. * * Unlike {@link recordUsage}, no trace or task context is attached — those * describe what the session is doing now, not on the day being rebuilt. * Events already in the ledger (in any session) are skipped: by * `messageId` when both sides have one, else by {@link ledgerEventKey}. * * @param sessionName - Session the events belong to * @param agentId - Agent id for a new session record * @param events - Events to add * @returns How many were added and how many were already present */ importEvents(sessionName: string, agentId: string, events: TokenUsageEvent[]): { added: number; present: number; }; /** * Whether an event is already in the ledger (any session). * * @param event - Candidate event * @returns True when present by message id or by {@link ledgerEventKey} */ hasEvent(event: TokenUsageEvent): boolean; /** Component logger, created on first use. */ private getLogger; /** * Get the number of tracked sessions (for testing). * * @returns Number of sessions in the store */ getSessionCount(): number; } /** * Identity of a ledger event for dedupe: the same turn recorded twice has * the same timestamp, token counts and model. * * @param e - Event * @returns Key */ export declare function ledgerEventKey(e: Pick): string; /** * Remove usage events that appear in more than one session's record. * * Two agents cannot produce the same usage at the same millisecond. Before the * shared-cwd guard (2026-09-22) the transcript sync pointed several agents at * one foreign transcript, so each of them was booked every one of its turns — * about 130 turns copied into five records. Every copy is dropped: the turns * belonged to none of them. Totals are recomputed for records that changed. * * @param records - Records as loaded from disk; modified in place * @returns How many events were removed */ export declare function dropCrossSessionDuplicates(records: SessionUsageRecord[]): number; //# sourceMappingURL=token-usage.service.d.ts.map