/** * Redis-backed `TokenUsageStore` (ADR-135 telemetry surface, durable variant). * * The in-memory `createInMemoryTokenUsageStore` (see `./token-usage-store.ts`) * loses every counter on a process restart, and — more importantly for the * operator console — it only ever sees the samples THIS process recorded. In a * real deployment the adapter loop that consumes provider usage runs in a * SEPARATE process (the ibatexas runtime), which writes per-session token * totals to Redis under `llm:tokens:*`. The console runs no adapter loop, so it * must READ those keys to populate the Token Governance section with real data. * * # Why a write-through cache, not a fully-async store * * `TokenUsageStore`'s read methods (`sessions`/`tenants`/`exhaustionEvents`/ * `totalConsumed`) are SYNCHRONOUS (they back the admin-sdk port directly). To * satisfy that interface AND read from Redis, this is a WRITE-THROUGH / * read-through CACHE: an in-memory `createInMemoryTokenUsageStore` backs the * synchronous reads, and an async `refresh()` re-folds the live `llm:tokens:*` * keyspace into a FRESH in-memory store via SCAN. The caller (the console port) * `await store.refresh()` before reading; `refresh()` is rate-limited to at * most once per `cacheTtlMs` (default ~1 min) so a burst of admin requests does * not SCAN Redis on every request. Mirrors the durable-via-cache shape of * `createPostgresRemediationProposalStore` (adjutant). * * # SCAN, never KEYS * * Enumeration uses the injected client's `scan(pattern)` — production wires it * to node-redis `scanIterator` (NOT the O(N)-blocking `KEYS`), exactly as * `createLazyRedisApprovalAdapter().keys()` does. A large keyspace never stalls * Redis. * * # Determinism boundary — unchanged * * This is TELEMETRY, strictly OUTSIDE the kernel determinism boundary (see the * module docs on `./token-usage-store.ts`). It NEVER feeds a kernel decision. * `Date.now()` is permitted here (the cache TTL clock) — it is not a recorded * value and not a kernel input. Recorded sample timestamps are still * source-supplied (from the Redis value, or `nowIso()` when the producer wrote * none). * * # Wire format (shared with the producer) * * Each key `${keyPrefix}:` holds one of: * - a JSON object `{ sessionId?, tenantId?, total, prompt?, completion?, at? }` * (`total` REQUIRED; `sessionId` defaults to the key suffix), or * - a bare integer string (the combined token total; tenant unknown). * The ibatexas runtime writes a combined total (no provider split), so the * common case carries `total` only — the console omits the USD cost column for * those rows (no split to price). A malformed value is skipped, never thrown. * * Fail-open: a Redis blip during `refresh()` is swallowed — the last good * snapshot keeps serving. The operator UI must never 500 on a telemetry read. */ import { type CreateInMemoryTokenUsageStoreOptions, type TokenUsageStore } from "./token-usage-store.js"; /** * Minimal Redis surface the adopter injects — intentionally narrow so the * package does NOT hard-depend on a concrete client. `scan` enumerates keys by * glob pattern (SCAN-backed, never the blocking `KEYS`); `get` reads one value. */ export interface TokenUsageRedisClient { /** Prefix-scoped key enumeration (glob). SCAN-backed in production clients. */ scan(pattern: string): Promise; get(key: string): Promise; /** * Optional batched read (#28-8). When present, `refresh()` reads the scanned * keyspace with chunked MGETs (ceil(N/500) round-trips) instead of N GETs. * Omit it and the store transparently falls back to the per-key `get` path. */ mget?(keys: readonly string[]): Promise; } export interface CreateRedisTokenUsageStoreOptions extends CreateInMemoryTokenUsageStoreOptions { readonly redis: TokenUsageRedisClient; /** Key namespace the producer writes under. Default `"llm:tokens"`. */ readonly keyPrefix?: string; /** Min interval between SCAN refreshes (ms). Default 60_000 (~1 min). */ readonly cacheTtlMs?: number; /** Fallback ISO timestamp when a value carries no `at`. Default: wall-clock. */ readonly nowIso?: () => string; /** Best-effort refresh-error hook (default: swallow). */ readonly onRefreshError?: (err: unknown) => void; } /** * A `TokenUsageStore` whose synchronous reads are served from an in-memory * snapshot re-folded from the live `${keyPrefix}:*` Redis keyspace. Adds * `refresh()` over the base interface; call it before a read (it self-throttles * to `cacheTtlMs`). */ export interface RedisTokenUsageStore extends TokenUsageStore { /** * Re-SCAN the keyspace and rebuild the in-memory snapshot. Rate-limited to at * most once per `cacheTtlMs`; concurrent calls share one in-flight refresh. * Fail-open: a Redis error keeps the previous snapshot and is reported to * `onRefreshError`. */ refresh(): Promise; } /** * Build a durable, read-through `TokenUsageStore` over Redis `llm:tokens:*`. * Synchronous reads come from an in-memory snapshot; `refresh()` re-folds the * live keyspace (SCAN) into a fresh snapshot, self-throttled to `cacheTtlMs`. */ export declare function createRedisTokenUsageStore(opts: CreateRedisTokenUsageStoreOptions): RedisTokenUsageStore; //# sourceMappingURL=token-usage-store-redis.d.ts.map