diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index ffc26dbc90..716636bfdc 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -124,7 +124,7 @@ See [Combos](/guides/combos/) for target strategies, cooldowns, aliases, and rou | `GET /api/debug/usage-logs` | Read bounded usage-debug entries | — | | `GET /api/debug/injection-logs` | Read bounded guidance-injection debug entries | — | | `GET /api/claude/inbound-debug` | Read Claude inbound debug state and entries | — | -| `GET /api/usage` | Summarize usage by range and client surface; Codex responses also include an `accounts` breakdown keyed by stable non-PII log labels | Returns an `error: "read_failed"` summary if storage cannot be read | +| `GET /api/usage` | Stream the complete usage ledger into compact aggregates, then incrementally fold verified appends; summarize by range and client surface, with a Codex `accounts` breakdown keyed by stable non-PII log labels | Returns an `error: "read_failed"` summary if storage cannot be read | | `GET /api/storage` | Scan Codex storage usage by bucket | Returns an `error: "scan_failed"` payload on scan failure | | `POST /api/storage/cleanup/preview` | Preview archived-session cleanup and return a binding digest | 400 `invalid_json` or `invalid_percent` | | `POST /api/storage/cleanup` | Quarantine or permanently remove the previewed archived set | 400 invalid input; 409 stale/busy/referenced state; 500 filesystem/database failure | @@ -135,6 +135,25 @@ See [Combos](/guides/combos/) for target strategies, cooldowns, aliases, and rou | `POST /api/storage/cleanup-policy/run` | Start a manual cleanup-policy run | 409 `already_running`; 500 `cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` | Test-only policy stream hook | 404 `not_found` when unavailable | +`GET /api/usage` reads `~/.opencodex/usage.jsonl` from the beginning through the current ledger +snapshot on a cold start. It processes fixed 1 MiB chunks and retains compact aggregate state rather +than every normalized request row. Later refreshes validate the previous line boundary and fold only +newly appended complete rows. Concurrent callers share the same refresh. Range and surface predicates +are applied to the complete aggregate, so the former read-byte window and parsed-row cap cannot omit +an earlier file prefix from 7-day, 30-day, or all-history totals. `managementUsageMaxReadBytes` remains +accepted for compatibility with bounded legacy readers, but changing it no longer expands or reduces +the history summarized by this endpoint. + +The runtime ledger is append-only. Replacing or truncating it, or changing local pricing/time-zone +inputs, triggers a complete rebuild. If you manually edit an older row in place while the proxy is +running, restart the proxy (or replace the file) before relying on the new total; incremental refreshes +verify the append boundary, not every previously aggregated byte. + +The response still includes `historyTruncated`, `truncatedPrefixBytes`, `entriesTruncated`, and +`entriesDropped` so older clients can consume the same wire shape. A successful whole-ledger scan +reports `false`, `0`, `false`, and `0`, respectively. These are legacy compatibility fields, not a +signal that the endpoint read only a configured-size tail. + For `GET /api/usage?range=30d&surface=codex`, `accounts` contains one row per observed Codex pool label. Each row reports `accountLogLabel`, token totals, `usageCoverageRatio`, and an optional `estimatedCostUsd` based on the currently configured display pricing. Active user `modelCosts` diff --git a/gui/src/pages/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts index 9e2c4cda3a..6f84950ce1 100644 --- a/gui/src/pages/use-dashboard-data.ts +++ b/gui/src/pages/use-dashboard-data.ts @@ -270,7 +270,7 @@ export function useDashboardData(apiBase: string) { (signal) => fetchDashboardUsage(apiBase, signal), // 30d usage is documented ~5s cold; this shared key has four subscribers, so // every one of them carries the same raised deadline (mount-order independent). - { enabled: overviewReady, deadlineMs: 60_000 }, + { enabled: overviewReady, pollMs: 60_000, deadlineMs: 60_000 }, ); const diagnosticsPoll = useKeyedClientResource( diff --git a/gui/tests/dashboard-contracts.test.ts b/gui/tests/dashboard-contracts.test.ts index c411168daf..6b7ec2c6a6 100644 --- a/gui/tests/dashboard-contracts.test.ts +++ b/gui/tests/dashboard-contracts.test.ts @@ -53,7 +53,7 @@ test("Dashboard usage polling cannot delay core health and settings", async () = expect(hook).toContain("fetchDashboardUsage(apiBase, signal)"); expect(hook).toContain("fetchDashboardSidecars"); expect(hook).toContain("fetchDashboardOverview"); - expect(hook).not.toMatch(/usageSummary30dResourceKey\(apiBase\)[\s\S]*pollMs: 60_000/); + expect(hook).toMatch(/usageSummary30dResourceKey\(apiBase\)[\s\S]*pollMs: 60_000/); }); test("Dashboard interactive controls load independently of health/providers", async () => { diff --git a/src/config.ts b/src/config.ts index 2ec7354600..6cd87ef29f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1010,7 +1010,9 @@ const configSchema = z.object({ // A malformed present client block must remain diagnosable from raw config and // fail closed through src/client/state.ts; unrelated provider state still loads. client: clientConnectionSchema.optional().catch(undefined), - managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024), + managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024).describe( + "Deprecated compatibility limit for bounded legacy usage readers; GET /api/usage always aggregates the complete ledger", + ), // Invalid hand edits disable only this opt-in circuit. Live writes remain strict. upstreamHostCircuitThreshold: z.number().int() .min(0) diff --git a/src/lib/app-owned-memory-stores.ts b/src/lib/app-owned-memory-stores.ts index 032be3ff0b..a4c3dbad43 100644 --- a/src/lib/app-owned-memory-stores.ts +++ b/src/lib/app-owned-memory-stores.ts @@ -38,6 +38,10 @@ import { discardRetainedUsageSnapshot, retainedUsageSnapshotStats, } from "../usage/log"; +import { + discardRetainedUsageAggregate, + usageAggregateRetainedStats, +} from "../server/management/usage-aggregate-cache"; import { cursorBlobRetainedStoreSnapshot, evictOldestCursorBlobForBudget, @@ -61,18 +65,33 @@ function ringSnapshot(metrics: { entries: number; bytes: number; oldestAt: numbe }; } -/** The retained usage tail is a single all-or-nothing entry: evicting it drops the whole tail. */ +/** Legacy parsed tail and streaming aggregate share one stable public store id. */ function usageSnapshotRetainedStoreSnapshot(): RetainedStoreSnapshot { - const stats = retainedUsageSnapshotStats(); + const legacy = retainedUsageSnapshotStats(); + const aggregate = usageAggregateRetainedStats(); + const oldest = [legacy.oldestAt, aggregate.oldestAt] + .filter((value): value is number => value !== null) + .sort((a, b) => a - b)[0] ?? null; return { - count: stats.count, - bytes: stats.bytes, - evictableBytes: stats.bytes, - pinnedBytes: 0, - oldestAt: stats.oldestAt, + count: legacy.count + aggregate.count, + bytes: legacy.bytes + aggregate.bytes, + evictableBytes: legacy.bytes + aggregate.evictableBytes, + pinnedBytes: aggregate.pinnedBytes, + oldestAt: oldest, }; } +function evictOldestUsageSnapshot(): number { + const legacy = retainedUsageSnapshotStats(); + const aggregate = usageAggregateRetainedStats(); + if (legacy.bytes > 0 + && (aggregate.evictableBytes === 0 + || (legacy.oldestAt ?? Number.POSITIVE_INFINITY) <= (aggregate.oldestAt ?? Number.POSITIVE_INFINITY))) { + return discardRetainedUsageSnapshot(); + } + return discardRetainedUsageAggregate(); +} + function providerDebugSnapshot(): RetainedStoreSnapshot { return ringSnapshot(debugBufferMetrics()); } @@ -154,7 +173,7 @@ export const APP_OWNED_RETAINED_STORE_REGISTRATIONS = [ id: "usage_snapshot", category: "caches", snapshot: usageSnapshotRetainedStoreSnapshot, - evictOldest: discardRetainedUsageSnapshot, + evictOldest: evictOldestUsageSnapshot, }, { id: "cursor_blobs", diff --git a/src/server/management/api-key-usage.ts b/src/server/management/api-key-usage.ts index 61519aeb95..6a8664dee2 100644 --- a/src/server/management/api-key-usage.ts +++ b/src/server/management/api-key-usage.ts @@ -1,9 +1,9 @@ import { currentUsageLogRevision, - readUsageSnapshotForManagement, usageLogIdentityKey, type PersistedUsageEntry, } from "../../usage/log"; +import { scanUsageLedgerCooperatively } from "../../usage/ledger-scanner"; /** * Per-key usage as the API tab renders it. @@ -29,6 +29,11 @@ export interface ApiKeyUsageSnapshot { attributionSince?: string; } +export interface ApiKeyUsageAccumulator { + add(entry: PersistedUsageEntry): void; + snapshot(): ApiKeyUsageSnapshot; +} + const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; /** @@ -58,6 +63,21 @@ export function rollupApiKeyUsage( configuredIds: string[], now: number = Date.now(), ): ApiKeyUsageSnapshot { + const accumulator = createApiKeyUsageAccumulator(configuredIds, now); + for (const entry of entries) accumulator.add(entry); + return accumulator.snapshot(); +} + +/** + * Constant-memory fold for API-key attribution while the usage ledger streams. + * + * Only configured IDs are retained, so a hand-edited ledger containing an + * unbounded set of arbitrary `apiKeyId` values cannot grow this accumulator. + */ +export function createApiKeyUsageAccumulator( + configuredIds: string[], + now: number = Date.now(), +): ApiKeyUsageAccumulator { const duplicated = new Set(); const seen = new Set(); for (const id of configuredIds) { @@ -69,37 +89,40 @@ export function rollupApiKeyUsage( let attributionSince: number | undefined; const cutoff = now - SEVEN_DAYS_MS; - for (const entry of entries) { - if (!entry.admissionKind) continue; - const timestamp = usableTimestamp(entry.timestamp); - if (timestamp !== null && (attributionSince === undefined || timestamp < attributionSince)) { - attributionSince = timestamp; - } - if (entry.admissionKind !== "configured" || !entry.apiKeyId) continue; - - const bucket = totals.get(entry.apiKeyId) ?? { requests7d: 0, totalRequests: 0 }; - // The request happened even if its clock reading is unusable, so it still - // counts toward the total; only the time-based fields are skipped. - bucket.totalRequests += 1; - if (timestamp !== null) { - if (timestamp >= cutoff) bucket.requests7d += 1; - const iso = new Date(timestamp).toISOString(); - if (!bucket.lastUsedAt || iso > bucket.lastUsedAt) bucket.lastUsedAt = iso; - } - totals.set(entry.apiKeyId, bucket); - } - - const rollup = new Map(); - for (const id of configuredIds) { - if (duplicated.has(id)) { - rollup.set(id, { ambiguous: true }); - continue; - } - rollup.set(id, totals.get(id) ?? { requests7d: 0, totalRequests: 0 }); - } return { - rollup, - ...(attributionSince !== undefined ? { attributionSince: new Date(attributionSince).toISOString() } : {}), + add(entry) { + if (!entry.admissionKind) return; + const timestamp = usableTimestamp(entry.timestamp); + if (timestamp !== null && (attributionSince === undefined || timestamp < attributionSince)) { + attributionSince = timestamp; + } + if (entry.admissionKind !== "configured" || !entry.apiKeyId || !seen.has(entry.apiKeyId)) return; + + const bucket = totals.get(entry.apiKeyId) ?? { requests7d: 0, totalRequests: 0 }; + // The request happened even if its clock reading is unusable, so it still + // counts toward the total; only the time-based fields are skipped. + bucket.totalRequests += 1; + if (timestamp !== null) { + if (timestamp >= cutoff) bucket.requests7d += 1; + const iso = new Date(timestamp).toISOString(); + if (!bucket.lastUsedAt || iso > bucket.lastUsedAt) bucket.lastUsedAt = iso; + } + totals.set(entry.apiKeyId, bucket); + }, + snapshot() { + const rollup = new Map(); + for (const id of configuredIds) { + if (duplicated.has(id)) { + rollup.set(id, { ambiguous: true }); + continue; + } + rollup.set(id, totals.get(id) ?? { requests7d: 0, totalRequests: 0 }); + } + return { + rollup, + ...(attributionSince !== undefined ? { attributionSince: new Date(attributionSince).toISOString() } : {}), + }; + }, }; } @@ -111,6 +134,7 @@ export function rollupApiKeyUsage( * caching it costs nothing; a new row changes the revision and invalidates it. */ let rollupCache: { revisionKey: string; expiresAt: number; lastSeenSize?: number; snapshot: ApiKeyUsageSnapshot } | null = null; +const rollupFlights = new Map>(); /** * The rollup is a function of the log AND of the clock: a request ages out of @@ -127,6 +151,7 @@ const ROLLUP_CACHE_TTL_MS = 60_000; /** Test seam: the cache is module state and would otherwise leak between cases. */ export function clearApiKeyUsageCacheForTests(): void { rollupCache = null; + rollupFlights.clear(); } /** @@ -159,6 +184,25 @@ export function cacheApiKeyUsageFromSnapshot( return rolled; } +/** Seed the API-key cache from the accumulator already fed by `/api/usage`. */ +export function cacheApiKeyUsageFromRollup( + snapshot: ApiKeyUsageSnapshot, + configuredIds: string[], + identityKey: string, + lastSeenSize: number, + maxReadBytes: number | undefined, + now: number = Date.now(), +): ApiKeyUsageSnapshot { + const idsKey = JSON.stringify([configuredIds, maxReadBytes]); + rollupCache = { + revisionKey: `${identityKey}|${idsKey}`, + expiresAt: now + ROLLUP_CACHE_TTL_MS, + lastSeenSize, + snapshot, + }; + return snapshot; +} + export async function readApiKeyUsageRollup(configuredIds: string[], maxReadBytes?: number): Promise { // JSON rather than a joined string: ids are only validated as non-empty // strings, so `["a\0b","c"]` and `["a","b\0c"]` join to the same value and one @@ -173,18 +217,28 @@ export async function readApiKeyUsageRollup(configuredIds: string[], maxReadByte return rollupCache.snapshot; } - const snapshot = await readUsageSnapshotForManagement(maxReadBytes); - const rolled = { - ...rollupApiKeyUsage(snapshot.entries, configuredIds, now), - ...(snapshot.truncatedPrefixBytes > 0 || snapshot.entriesTruncated ? { historyTruncated: true as const } : {}), - }; - rollupCache = { - revisionKey: `${usageLogIdentityKey(snapshot.revision)}|${idsKey}`, - expiresAt: now + ROLLUP_CACHE_TTL_MS, - lastSeenSize: snapshot.revision?.size ?? 0, - snapshot: rolled, - }; - return rolled; + const existing = rollupFlights.get(idsKey); + if (existing) return await existing; + + const flight = (async (): Promise => { + const accumulator = createApiKeyUsageAccumulator(configuredIds, now); + const scan = await scanUsageLedgerCooperatively({ onEntry: entry => accumulator.add(entry) }); + if (scan.oversizedRows > 0) throw new Error("usage ledger contains an oversized row"); + return cacheApiKeyUsageFromRollup( + accumulator.snapshot(), + configuredIds, + usageLogIdentityKey(scan.revision), + scan.revision?.size ?? 0, + maxReadBytes, + now, + ); + })(); + rollupFlights.set(idsKey, flight); + try { + return await flight; + } finally { + if (rollupFlights.get(idsKey) === flight) rollupFlights.delete(idsKey); + } } catch { const rollup = new Map(); for (const id of configuredIds) rollup.set(id, { requests7d: 0, totalRequests: 0 }); diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index 03d8f96f59..5d909283f2 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -47,13 +47,11 @@ import { } from "../../storage/policy-job"; import { currentUsageLogRevision, - readUsageSnapshotForManagement, usageLogIdentityKey, usageLogRevisionKey, - type PersistedUsageEntry, } from "../../usage/log"; import { getUsageDebugLogEntries } from "../../usage/debug"; -import { USAGE_RANGES, USAGE_SURFACES, parseRange, parseUsageSurface, projectUsageSummary, rangeWindow, summarizeUsage, type UsageRange, type UsageSummary, type UsageSurface } from "../../usage/summary"; +import { USAGE_RANGES, USAGE_SURFACES, parseRange, parseUsageSurface, rangeWindow, type UsageRange, type UsageSummary, type UsageSurface } from "../../usage/summary"; import { stripCodexRuntimeProviderFields } from "../../codex/auth-context"; import { getProviderRegistryEntry } from "../../providers/registry"; import { getDebugLogEntries } from "../../lib/debug-log-buffer"; @@ -83,7 +81,7 @@ import { getUsageSummaryCacheEntry, setUsageSummaryCacheEntry, } from "./usage-summary-cache"; -import { cacheApiKeyUsageFromSnapshot } from "./api-key-usage"; +import { getFilteredUsageAggregate, getUsageAggregate } from "./usage-aggregate-cache"; function nextLocalMidnight(now: number): number { const next = new Date(now); @@ -92,7 +90,6 @@ function nextLocalMidnight(now: number): number { } function usageSummaryExpiresAt( - _entries: PersistedUsageEntry[], _range: UsageRange, _surface: UsageSurface, now: number, @@ -105,29 +102,6 @@ function refreshedUsageSummary end) end = at; - } - return { start, end }; -} - export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, syncClaudeAgentDefsBestEffort } = ctx; @@ -195,18 +169,16 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise(summary: T, entries?: PersistedUsageEntry[]) => - projectUsageSummary(summary, filter, entries); - const filterRequested = Boolean(filter.provider ?? filter.model ?? filter.apiKeyId); + const filterRequested = [filter.provider, filter.model, filter.apiKeyId] + .some(value => typeof value === "string" && value.trim() !== ""); const now = Date.now(); try { const cacheKey = `${range}:${surface}`; @@ -214,52 +186,68 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise= cached.lastSeenSize) { return jsonResponse(refreshedUsageSummary(cached.summary, range, now)); } if (cached && !filterRequested) discardUsageSummaryCacheEntry(cacheKey); - // Capture the overlay version BEFORE reading/computing: the cache entry - // must be stamped with the version the summary was priced under. Reading - // it again at stamp time could cache an old-price summary as current, - // and the next request would then accept stale pricing for the whole - // cache lifetime. - const overlayVersion = userCostOverlayVersion(); - const snapshot = await readUsageSnapshotForManagement(effectiveReadLimit); + if (filterRequested) { + const filteredAggregate = await getFilteredUsageAggregate(filter); + const accumulator = filteredAggregate.accumulator; + return jsonResponse({ + ...accumulator.summarize(range, now, surface), + historyTruncated: false, + truncatedPrefixBytes: 0, + entriesTruncated: false, + entriesDropped: 0, + snapshotWindowStart: accumulator.snapshotWindow.start, + snapshotWindowEnd: accumulator.snapshotWindow.end, + }); + } + + const configuredApiKeyIds = (config.apiKeys ?? []).map(key => key.id); + const aggregate = await getUsageAggregate({ + now, + configuredApiKeyIds, + managementUsageMaxReadBytes: effectiveReadLimit, + }); + const baseAccumulator = aggregate.accumulator; const revisionReadAt = Date.now(); - const window = snapshotWindow(snapshot.entries); - const summary = { - ...summarizeUsage(snapshot.entries, range, now, surface), - historyTruncated: snapshot.truncatedPrefixBytes > 0 || snapshot.entriesTruncated, - truncatedPrefixBytes: snapshot.truncatedPrefixBytes, - entriesTruncated: snapshot.entriesTruncated, - entriesDropped: snapshot.entriesDropped, - snapshotWindowStart: window.start, - snapshotWindowEnd: window.end, + const freshUntil = now + 60_000; + const snapshotIdentity = `${usageLogIdentityKey(aggregate.revision)}\0${effectiveReadLimit}`; + const revisionKey = `${usageLogRevisionKey(aggregate.revision)}\0${effectiveReadLimit}`; + const lastSeenSize = aggregate.revision?.size ?? 0; + const baseReadMetadata = { + historyTruncated: false, + truncatedPrefixBytes: 0, + entriesTruncated: false, + entriesDropped: 0, + snapshotWindowStart: baseAccumulator.snapshotWindow.start, + snapshotWindowEnd: baseAccumulator.snapshotWindow.end, + } as const; + const requestedSummary = { + ...baseAccumulator.summarize(range, now, surface), + ...baseReadMetadata, }; - if (userCostOverlayVersion() !== overlayVersion) { - // The overlay changed while the summary was being computed, so this - // summary may mix old and new prices. Serve it uncached: the next - // request recomputes against the settled overlay instead of caching a - // mixed-price entry under either version. - return jsonResponse(project(summary, snapshot.entries)); + const currentOverlayVersion = userCostOverlayVersion(); + const currentTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + if (currentOverlayVersion !== aggregate.overlayVersion + || currentTimeZone !== aggregate.timeZone) { + // The aggregate is internally consistent, but an input changed after + // its scan. Serve it uncached and let the next request rebuild. + return jsonResponse(requestedSummary); } - const freshUntil = now + 60_000; - const snapshotIdentity = `${usageLogIdentityKey(snapshot.revision)}\0${effectiveReadLimit}`; - const revisionKey = `${usageLogRevisionKey(snapshot.revision)}\0${effectiveReadLimit}`; - const lastSeenSize = snapshot.revision?.size ?? 0; // Derived from the canonical constants rather than re-listed: a subset // literal type-checks perfectly happily, so a range added to the union // and forgotten here would never be warmed and never invalidated @@ -268,21 +256,19 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise key.id), - usageLogIdentityKey(snapshot.revision), - snapshot.revision?.size ?? 0, - snapshot.truncatedPrefixBytes > 0 || snapshot.entriesTruncated, - effectiveReadLimit, - now, - ); - return jsonResponse(project(summary, snapshot.entries)); + return jsonResponse(requestedSummary); } catch { return jsonResponse({ range, diff --git a/src/server/management/usage-aggregate-cache.ts b/src/server/management/usage-aggregate-cache.ts new file mode 100644 index 0000000000..5e65c26ad7 --- /dev/null +++ b/src/server/management/usage-aggregate-cache.ts @@ -0,0 +1,464 @@ +import { enforceAppOwnedMemoryBudget } from "../../lib/app-owned-memory"; +import { + currentUsageLogRevision, + usageLogIdentityKey, + usageLogRevisionKey, + type UsageLogRevision, +} from "../../usage/log"; +import { + scanUsageLedgerCooperatively, + UsageLedgerRebuildRequiredError, +} from "../../usage/ledger-scanner"; +import { + createUsageSummaryAccumulator, + type UsageSummaryAccumulator, +} from "../../usage/summary"; +import { userCostOverlayVersion } from "../../usage/user-cost-overlays"; + +import { + cacheApiKeyUsageFromRollup, + createApiKeyUsageAccumulator, +} from "./api-key-usage"; + +interface RetainedUsageAggregate { + accumulator: UsageSummaryAccumulator; + revision: UsageLogRevision | null; + identityKey: string; + revisionKey: string; + processedThroughBytes: number; + processedThroughDigest: string; + overlayVersion: number; + timeZone: string; + retainedAt: number; +} + +export interface UsageAggregateResult { + accumulator: UsageSummaryAccumulator; + revision: UsageLogRevision | null; + processedThroughBytes: number; + overlayVersion: number; + timeZone: string; + update: "unchanged" | "append" | "rebuild"; +} + +export interface UsageAggregateOptions { + now?: number; + configuredApiKeyIds?: string[]; + managementUsageMaxReadBytes?: number; +} + +export interface UsageAggregateRetainedStats { + count: number; + bytes: number; + evictableBytes: number; + pinnedBytes: number; + oldestAt: number | null; +} + +const MAX_REBUILD_ATTEMPTS = 2; +const MAX_RETAINED_FILTERED_AGGREGATES = 4; + +let retainedAggregate: RetainedUsageAggregate | null = null; +const pinnedAggregates = new Set(); +let baseFlight: Promise | null = null; +const filteredFlights = new Map>(); +const retainedFilteredAggregates = new Map(); + +function currentTimeZone(): string { + return Intl.DateTimeFormat().resolvedOptions().timeZone; +} + +function resultFrom( + state: RetainedUsageAggregate, + update: UsageAggregateResult["update"], +): UsageAggregateResult { + return { + accumulator: state.accumulator, + revision: state.revision, + processedThroughBytes: state.processedThroughBytes, + overlayVersion: state.overlayVersion, + timeZone: state.timeZone, + update, + }; +} + +function publishRetainedAggregate(state: RetainedUsageAggregate): UsageAggregateResult { + retainedAggregate = state; + // The budget may evict the state immediately. The request that built it still + // owns the returned accumulator and can finish this response safely. + enforceAppOwnedMemoryBudget(); + return resultFrom(state, "rebuild"); +} + +function makeRetainedAggregate( + accumulator: UsageSummaryAccumulator, + scan: Awaited>, + overlayVersion: number, + timeZone: string, +): RetainedUsageAggregate { + return { + accumulator, + revision: scan.revision, + identityKey: usageLogIdentityKey(scan.revision), + revisionKey: usageLogRevisionKey(scan.revision), + processedThroughBytes: scan.processedThroughBytes, + processedThroughDigest: scan.processedThroughDigest, + overlayVersion, + timeZone, + retainedAt: Date.now(), + }; +} + +async function rebuildAggregate(options: UsageAggregateOptions): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < MAX_REBUILD_ATTEMPTS; attempt += 1) { + const overlayVersion = userCostOverlayVersion(); + const timeZone = currentTimeZone(); + const accumulator = createUsageSummaryAccumulator({ mode: "row-unique" }); + const apiKeyAccumulator = options.configuredApiKeyIds + ? createApiKeyUsageAccumulator(options.configuredApiKeyIds, options.now) + : null; + try { + const scan = await scanUsageLedgerCooperatively({ + onEntry(entry) { + accumulator.add(entry); + apiKeyAccumulator?.add(entry); + }, + }); + if (scan.oversizedRows > 0) { + throw new Error("usage ledger contains an oversized row"); + } + if (userCostOverlayVersion() !== overlayVersion || currentTimeZone() !== timeZone) { + lastError = new Error("usage aggregation inputs changed during rebuild"); + continue; + } + + const state = makeRetainedAggregate(accumulator, scan, overlayVersion, timeZone); + const result = publishRetainedAggregate(state); + if (apiKeyAccumulator && options.configuredApiKeyIds) { + cacheApiKeyUsageFromRollup( + apiKeyAccumulator.snapshot(), + options.configuredApiKeyIds, + state.identityKey, + state.revision?.size ?? 0, + options.managementUsageMaxReadBytes, + options.now, + ); + } + return result; + } catch (error) { + lastError = error; + if (!(error instanceof UsageLedgerRebuildRequiredError) || attempt + 1 >= MAX_REBUILD_ATTEMPTS) { + throw error; + } + } + } + throw lastError ?? new Error("usage aggregate rebuild did not settle"); +} + +function requiresRebuild( + state: RetainedUsageAggregate, + observed: UsageLogRevision | null, + overlayVersion: number, + timeZone: string, +): boolean { + if (state.overlayVersion !== overlayVersion || state.timeZone !== timeZone) return true; + if (state.identityKey !== usageLogIdentityKey(observed)) return true; + if (!state.revision || !observed) return state.revision !== observed; + if (observed.size < state.revision.size) return true; + // At the same size, metadata movement cannot be an append. Rebuild so a + // detectable same-inode replacement/edit never extends stale counters. + return observed.size === state.revision.size && usageLogRevisionKey(observed) !== state.revisionKey; +} + +async function appendAggregate( + state: RetainedUsageAggregate, + options: UsageAggregateOptions, +): Promise { + pinnedAggregates.add(state); + let rebuildAfterUnpin = false; + try { + // Clone first and publish only after the scanner verifies the captured + // suffix. A callback error, mutation, or oversized row leaves retained + // state byte-for-byte untouched. + const candidate = state.accumulator.clone(); + const scan = await scanUsageLedgerCooperatively({ + startAtBytes: state.processedThroughBytes, + expectedIdentityKey: state.identityKey, + expectedProcessedThroughDigest: state.processedThroughDigest, + onEntry: entry => candidate.add(entry), + }); + if (scan.oversizedRows > 0) { + if (retainedAggregate === state) retainedAggregate = null; + throw new Error("usage ledger contains an oversized row"); + } + if (userCostOverlayVersion() !== state.overlayVersion || currentTimeZone() !== state.timeZone) { + if (retainedAggregate === state) retainedAggregate = null; + rebuildAfterUnpin = true; + } else { + const next: RetainedUsageAggregate = { + ...state, + accumulator: candidate, + revision: scan.revision, + identityKey: usageLogIdentityKey(scan.revision), + revisionKey: usageLogRevisionKey(scan.revision), + processedThroughBytes: scan.processedThroughBytes, + processedThroughDigest: scan.processedThroughDigest, + retainedAt: Date.now(), + }; + retainedAggregate = next; + enforceAppOwnedMemoryBudget(); + return resultFrom(next, "append"); + } + } catch (error) { + if (retainedAggregate === state) retainedAggregate = null; + if (error instanceof UsageLedgerRebuildRequiredError) rebuildAfterUnpin = true; + else throw error; + } finally { + pinnedAggregates.delete(state); + } + if (rebuildAfterUnpin) return rebuildAggregate(options); + throw new Error("usage aggregate append did not settle"); +} + +async function refreshAggregate(options: UsageAggregateOptions): Promise { + const state = retainedAggregate; + if (!state) return rebuildAggregate(options); + + const observed = currentUsageLogRevision(); + const overlayVersion = userCostOverlayVersion(); + const timeZone = currentTimeZone(); + if (requiresRebuild(state, observed, overlayVersion, timeZone)) { + retainedAggregate = null; + return rebuildAggregate(options); + } + if (usageLogRevisionKey(observed) === state.revisionKey) return resultFrom(state, "unchanged"); + return appendAggregate(state, options); +} + +export async function getUsageAggregate( + options: UsageAggregateOptions = {}, +): Promise { + if (baseFlight) return baseFlight; + const flight = refreshAggregate(options); + baseFlight = flight; + try { + return await flight; + } finally { + if (baseFlight === flight) baseFlight = null; + } +} + +function normalizeFilterValue(value: string | null | undefined): string | null { + const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; + return normalized || null; +} + +function normalizeExactFilterValue(value: string | null | undefined): string | null { + const normalized = typeof value === "string" ? value.trim() : ""; + return normalized || null; +} + +export async function getFilteredUsageAggregate(filter: { + provider?: string | null; + model?: string | null; + apiKeyId?: string | null; +}): Promise { + const normalizedFilter = { + provider: normalizeFilterValue(filter.provider), + model: normalizeFilterValue(filter.model), + apiKeyId: normalizeExactFilterValue(filter.apiKeyId), + }; + const key = JSON.stringify([ + normalizedFilter.provider, + normalizedFilter.model, + normalizedFilter.apiKeyId, + ]); + const existing = filteredFlights.get(key); + if (existing) return existing; + + const flight = refreshFilteredAggregate(key, normalizedFilter); + filteredFlights.set(key, flight); + try { + return await flight; + } finally { + if (filteredFlights.get(key) === flight) filteredFlights.delete(key); + } +} + +type NormalizedUsageFilter = { + provider: string | null; + model: string | null; + apiKeyId: string | null; +}; + +function trimRetainedFilteredAggregates(): void { + while (retainedFilteredAggregates.size > MAX_RETAINED_FILTERED_AGGREGATES) { + const oldest = [...retainedFilteredAggregates] + .filter(([, state]) => !pinnedAggregates.has(state)) + .sort(([, left], [, right]) => left.retainedAt - right.retainedAt)[0]; + if (!oldest) return; + retainedFilteredAggregates.delete(oldest[0]); + } +} + +function publishFilteredAggregate( + key: string, + state: RetainedUsageAggregate, + update: UsageAggregateResult["update"], +): UsageAggregateResult { + retainedFilteredAggregates.set(key, state); + trimRetainedFilteredAggregates(); + enforceAppOwnedMemoryBudget(); + return resultFrom(state, update); +} + +async function rebuildFilteredAggregate( + key: string, + filter: NormalizedUsageFilter, +): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < MAX_REBUILD_ATTEMPTS; attempt += 1) { + const overlayVersion = userCostOverlayVersion(); + const timeZone = currentTimeZone(); + const accumulator = createUsageSummaryAccumulator({ filter, mode: "row-unique" }); + try { + const scan = await scanUsageLedgerCooperatively({ onEntry: entry => accumulator.add(entry) }); + if (scan.oversizedRows > 0) throw new Error("usage ledger contains an oversized row"); + if (userCostOverlayVersion() !== overlayVersion || currentTimeZone() !== timeZone) { + lastError = new Error("usage aggregation inputs changed during filtered scan"); + continue; + } + const state = makeRetainedAggregate(accumulator, scan, overlayVersion, timeZone); + return publishFilteredAggregate(key, state, "rebuild"); + } catch (error) { + lastError = error; + if (!(error instanceof UsageLedgerRebuildRequiredError) || attempt + 1 >= MAX_REBUILD_ATTEMPTS) { + throw error; + } + } + } + throw lastError ?? new Error("filtered usage scan did not settle"); +} + +async function appendFilteredAggregate( + key: string, + state: RetainedUsageAggregate, + filter: NormalizedUsageFilter, +): Promise { + pinnedAggregates.add(state); + let rebuildAfterUnpin = false; + try { + const candidate = state.accumulator.clone(); + const scan = await scanUsageLedgerCooperatively({ + startAtBytes: state.processedThroughBytes, + expectedIdentityKey: state.identityKey, + expectedProcessedThroughDigest: state.processedThroughDigest, + onEntry: entry => candidate.add(entry), + }); + if (scan.oversizedRows > 0) { + if (retainedFilteredAggregates.get(key) === state) retainedFilteredAggregates.delete(key); + throw new Error("usage ledger contains an oversized row"); + } + if (userCostOverlayVersion() !== state.overlayVersion || currentTimeZone() !== state.timeZone) { + if (retainedFilteredAggregates.get(key) === state) retainedFilteredAggregates.delete(key); + rebuildAfterUnpin = true; + } else { + const next: RetainedUsageAggregate = { + ...state, + accumulator: candidate, + revision: scan.revision, + identityKey: usageLogIdentityKey(scan.revision), + revisionKey: usageLogRevisionKey(scan.revision), + processedThroughBytes: scan.processedThroughBytes, + processedThroughDigest: scan.processedThroughDigest, + retainedAt: Date.now(), + }; + return publishFilteredAggregate(key, next, "append"); + } + } catch (error) { + if (retainedFilteredAggregates.get(key) === state) retainedFilteredAggregates.delete(key); + if (error instanceof UsageLedgerRebuildRequiredError) rebuildAfterUnpin = true; + else throw error; + } finally { + pinnedAggregates.delete(state); + trimRetainedFilteredAggregates(); + } + if (rebuildAfterUnpin) return rebuildFilteredAggregate(key, filter); + throw new Error("filtered usage append did not settle"); +} + +async function refreshFilteredAggregate( + key: string, + filter: NormalizedUsageFilter, +): Promise { + const state = retainedFilteredAggregates.get(key); + if (!state) return rebuildFilteredAggregate(key, filter); + const observed = currentUsageLogRevision(); + const overlayVersion = userCostOverlayVersion(); + const timeZone = currentTimeZone(); + if (requiresRebuild(state, observed, overlayVersion, timeZone)) { + retainedFilteredAggregates.delete(key); + return rebuildFilteredAggregate(key, filter); + } + if (state.revisionKey === usageLogRevisionKey(observed)) { + state.retainedAt = Date.now(); + return resultFrom(state, "unchanged"); + } + return appendFilteredAggregate(key, state, filter); +} + +export function usageAggregateRetainedStats(): UsageAggregateRetainedStats { + const states = [ + ...(retainedAggregate ? [retainedAggregate] : []), + ...retainedFilteredAggregates.values(), + ]; + if (states.length === 0) { + return { count: 0, bytes: 0, evictableBytes: 0, pinnedBytes: 0, oldestAt: null }; + } + let bytes = 0; + let evictableBytes = 0; + let pinnedBytes = 0; + let oldestAt: number | null = null; + for (const state of states) { + const stateBytes = state.accumulator.estimatedBytes; + bytes += stateBytes; + if (pinnedAggregates.has(state)) pinnedBytes += stateBytes; + else { + evictableBytes += stateBytes; + oldestAt = oldestAt === null ? state.retainedAt : Math.min(oldestAt, state.retainedAt); + } + } + return { + count: states.length, + bytes, + evictableBytes, + pinnedBytes, + oldestAt, + }; +} + +export function discardRetainedUsageAggregate(): number { + const candidates: Array<{ key: string | null; state: RetainedUsageAggregate }> = [ + ...(retainedAggregate && !pinnedAggregates.has(retainedAggregate) + ? [{ key: null, state: retainedAggregate }] + : []), + ...[...retainedFilteredAggregates] + .filter(([, state]) => !pinnedAggregates.has(state)) + .map(([key, state]) => ({ key, state })), + ]; + const oldest = candidates.sort((left, right) => left.state.retainedAt - right.state.retainedAt)[0]; + if (!oldest) return 0; + const released = oldest.state.accumulator.estimatedBytes; + if (oldest.key === null) retainedAggregate = null; + else retainedFilteredAggregates.delete(oldest.key); + return released; +} + +export function resetUsageAggregateCacheForTests(): void { + retainedAggregate = null; + pinnedAggregates.clear(); + baseFlight = null; + filteredFlights.clear(); + retainedFilteredAggregates.clear(); +} diff --git a/src/server/management/usage-summary-cache.ts b/src/server/management/usage-summary-cache.ts index 1b509a03da..1e6815c0a2 100644 --- a/src/server/management/usage-summary-cache.ts +++ b/src/server/management/usage-summary-cache.ts @@ -6,6 +6,8 @@ export type CachedUsageSummary = UsageSummary & { truncatedPrefixBytes: number; entriesTruncated: boolean; entriesDropped: number; + snapshotWindowStart: number | null; + snapshotWindowEnd: number | null; }; export interface UsageSummaryCacheEntry { @@ -15,6 +17,8 @@ export interface UsageSummaryCacheEntry { maxReadBytes: number; /** userCostOverlayVersion() when the summary was computed; overlay edits invalidate the entry. */ overlayVersion: number; + /** Local calendar zone used to build day/range buckets. */ + timeZone: string; expiresAt: number; /** Generation freshness: ignore size/mtime until this instant. */ freshUntil: number; diff --git a/src/types/config.ts b/src/types/config.ts index 5292586d77..06270fc172 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -348,7 +348,10 @@ export interface OcxConfig { * the guess is wrong. */ oauthOpenBrowser?: boolean; - /** Maximum usage-log bytes read for one management snapshot. */ + /** + * @deprecated Compatibility-only limit for bounded legacy usage readers. + * `GET /api/usage` always aggregates the complete ledger. + */ managementUsageMaxReadBytes?: number; providers: Record; defaultProvider: string; diff --git a/src/usage/ledger-scanner.ts b/src/usage/ledger-scanner.ts new file mode 100644 index 0000000000..2827579e06 --- /dev/null +++ b/src/usage/ledger-scanner.ts @@ -0,0 +1,448 @@ +import { createHash } from "node:crypto"; +import { closeSync, fstatSync, openSync, readSync } from "node:fs"; +import { + currentUsageLogRevision, + normalizePersistedUsageRow, + usageLogIdentityKey, + usageLogPath, + usageLogRevisionKey, + type PersistedUsageEntry, + type UsageLogRevision, +} from "./log"; + +export const USAGE_LEDGER_READ_CHUNK_BYTES = 1024 * 1024; +// Normalized writer rows carry a <=16 KiB route trace and <=500-character captured +// upstream error, so 1 MiB leaves wide headroom even for an extreme multi-attempt row. +// Hand-edited rows can still exceed it; those are reported separately instead of making +// one unterminated line an unbounded allocation. +export const USAGE_LEDGER_MAX_LINE_BYTES = 1024 * 1024; +export const USAGE_LEDGER_BOUNDARY_DIGEST_BYTES = 64 * 1024; + +export interface ScanUsageLedgerOptions { + signal?: AbortSignal; + onEntry: (entry: PersistedUsageEntry) => void; + /** Absolute LF boundary returned by a previous scan. Defaults to byte zero. */ + startAtBytes?: number; + /** Stable path/dev/ino/birthtime identity; required when startAtBytes is nonzero. */ + expectedIdentityKey?: string; + /** Trailing digest at the previous boundary; required when startAtBytes is nonzero. */ + expectedProcessedThroughDigest?: string; + /** Test seam for forcing byte boundaries; production always uses the 1 MiB default. */ + chunkBytes?: number; +} + +export interface UsageLedgerScanResult { + /** Revision whose EOF was captured when the scan opened the ledger. */ + revision: UsageLogRevision | null; + parsedRows: number; + /** LF-complete malformed/schema-invalid rows plus a non-empty bounded torn suffix. */ + invalidRows: number; + /** Rows skipped after exceeding USAGE_LEDGER_MAX_LINE_BYTES. */ + oversizedRows: number; + bytesRead: number; + /** Absolute byte offset immediately after the last handled LF. */ + processedThroughBytes: number; + /** SHA-256 over at most the last 64 KiB ending at processedThroughBytes. */ + processedThroughDigest: string; +} + +export type UsageLedgerRebuildReason = + | "identity_mismatch" + | "shrink" + | "boundary_mismatch" + | "content_changed"; + +export class UsageLedgerRebuildRequiredError extends Error { + readonly code = "usage_ledger_rebuild_required"; + + constructor(readonly reason: UsageLedgerRebuildReason) { + super(`usage ledger rebuild required: ${reason}`); + this.name = "UsageLedgerRebuildRequiredError"; + } +} + +function revisionFromStat( + path: string, + stat: ReturnType, +): UsageLogRevision { + if (!stat.isFile()) throw new Error("usage log is not a regular file"); + return { + path, + dev: Number(stat.dev), + ino: Number(stat.ino), + birthtimeMs: Number(stat.birthtimeMs), + size: Number(stat.size), + mtimeMs: Number(stat.mtimeMs), + ctimeMs: Number(stat.ctimeMs), + }; +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (!signal?.aborted) return; + throw signal.reason ?? new Error("usage ledger scan aborted"); +} + +function isMissingFileError(error: unknown): boolean { + return error !== null + && typeof error === "object" + && "code" in error + && error.code === "ENOENT"; +} + +function isJsonWhitespace(bytes: Buffer, length: number): boolean { + for (let index = 0; index < length; index += 1) { + const byte = bytes[index]; + if (byte !== 0x20 && byte !== 0x09 && byte !== 0x0d) return false; + } + return true; +} + +class RollingByteWindow { + private readonly bytes: Buffer; + private start = 0; + private length = 0; + + constructor(private readonly capacity: number) { + this.bytes = Buffer.allocUnsafe(capacity); + } + + append(source: Buffer, from = 0, to = source.byteLength): void { + const sourceLength = to - from; + if (sourceLength <= 0) return; + if (sourceLength >= this.capacity) { + source.copy(this.bytes, 0, to - this.capacity, to); + this.start = 0; + this.length = this.capacity; + return; + } + + const overflow = Math.max(0, this.length + sourceLength - this.capacity); + this.start = (this.start + overflow) % this.capacity; + this.length -= overflow; + const writeAt = (this.start + this.length) % this.capacity; + const firstLength = Math.min(sourceLength, this.capacity - writeAt); + source.copy(this.bytes, writeAt, from, from + firstLength); + if (firstLength < sourceLength) { + source.copy(this.bytes, 0, from + firstLength, to); + } + this.length += sourceLength; + } + + appendByte(byte: number): void { + if (this.length < this.capacity) { + this.bytes[(this.start + this.length) % this.capacity] = byte; + this.length += 1; + return; + } + this.bytes[this.start] = byte; + this.start = (this.start + 1) % this.capacity; + } + + appendWindow(source: RollingByteWindow): void { + if (source.length === 0) return; + const firstLength = Math.min(source.length, source.capacity - source.start); + this.append(source.bytes, source.start, source.start + firstLength); + if (firstLength < source.length) { + this.append(source.bytes, 0, source.length - firstLength); + } + } + + reset(): void { + this.start = 0; + this.length = 0; + } + + digest(): string { + const hash = createHash("sha256"); + if (this.length === 0) return hash.digest("hex"); + const firstLength = Math.min(this.length, this.capacity - this.start); + hash.update(this.bytes.subarray(this.start, this.start + firstLength)); + if (firstLength < this.length) { + hash.update(this.bytes.subarray(0, this.length - firstLength)); + } + return hash.digest("hex"); + } +} + +function rebuildRequired(reason: UsageLedgerRebuildReason): UsageLedgerRebuildRequiredError { + return new UsageLedgerRebuildRequiredError(reason); +} + +function captureRangeIntoWindow( + fd: number, + from: number, + to: number, + scratch: Buffer, + window: RollingByteWindow, + signal: AbortSignal | undefined, +): void { + for (let position = from; position < to;) { + throwIfAborted(signal); + const requested = Math.min(scratch.byteLength, to - position); + const read = readSync(fd, scratch, 0, requested, position); + if (read === 0) throw rebuildRequired("shrink"); + window.append(scratch, 0, read); + position += read; + } +} + +async function digestRangeCooperatively( + fd: number, + from: number, + to: number, + buffer: Buffer, + signal: AbortSignal | undefined, +): Promise { + const hash = createHash("sha256"); + for (let position = from; position < to;) { + throwIfAborted(signal); + const requested = Math.min(buffer.byteLength, to - position); + const read = readSync(fd, buffer, 0, requested, position); + if (read === 0) throw rebuildRequired("shrink"); + hash.update(buffer.subarray(0, read)); + position += read; + if (position < to) await new Promise(resolve => setTimeout(resolve, 0)); + } + return hash.digest("hex"); +} + +/** + * Cooperatively scans a full ledger or validated append suffix with bounded memory. + * + * The opened EOF is the snapshot boundary: bytes appended after the initial fstat are + * deliberately left for the next scan. Rows are framed as raw bytes before UTF-8 decoding, + * so a multi-byte character may safely cross any read boundary. Only LF-terminated rows are + * published; a torn final write is skipped rather than accepted prematurely. + */ +export async function scanUsageLedgerCooperatively( + options: ScanUsageLedgerOptions, +): Promise { + const chunkBytes = options.chunkBytes ?? USAGE_LEDGER_READ_CHUNK_BYTES; + if (!Number.isSafeInteger(chunkBytes) || chunkBytes <= 0 || chunkBytes > USAGE_LEDGER_READ_CHUNK_BYTES) { + throw new RangeError(`usage ledger chunk bytes must be between 1 and ${USAGE_LEDGER_READ_CHUNK_BYTES}`); + } + const startAtBytes = options.startAtBytes ?? 0; + if (!Number.isSafeInteger(startAtBytes) || startAtBytes < 0) { + throw new RangeError("usage ledger start offset must be a non-negative safe integer"); + } + if (startAtBytes > 0 + && (options.expectedIdentityKey === undefined + || options.expectedProcessedThroughDigest === undefined)) { + throw new TypeError( + "usage ledger append scan requires expectedIdentityKey and expectedProcessedThroughDigest", + ); + } + throwIfAborted(options.signal); + + const path = usageLogPath(); + let fd: number; + try { + fd = openSync(path, "r"); + } catch (error) { + if (isMissingFileError(error)) { + if (startAtBytes > 0 + || (options.expectedIdentityKey + && options.expectedIdentityKey !== usageLogIdentityKey(null))) { + throw rebuildRequired("identity_mismatch"); + } + return { + revision: null, + parsedRows: 0, + invalidRows: 0, + oversizedRows: 0, + bytesRead: 0, + processedThroughBytes: 0, + processedThroughDigest: createHash("sha256").digest("hex"), + }; + } + throw error; + } + + const decoder = new TextDecoder("utf-8", { fatal: true }); + const chunk = Buffer.allocUnsafe(chunkBytes); + const line = Buffer.allocUnsafe(USAGE_LEDGER_MAX_LINE_BYTES); + let lineLength = 0; + let droppingOversizedLine = false; + let parsedRows = 0; + let invalidRows = 0; + let oversizedRows = 0; + let bytesRead = 0; + let bytesSinceYield = 0; + let linesSinceYield = 0; + let processedThroughBytes = startAtBytes; + const capturedHash = createHash("sha256"); + const committedTail = new RollingByteWindow(USAGE_LEDGER_BOUNDARY_DIGEST_BYTES); + const pendingTail = new RollingByteWindow(USAGE_LEDGER_BOUNDARY_DIGEST_BYTES); + let pendingLineBytes = 0; + + try { + const openedRevision = revisionFromStat(path, fstatSync(fd)); + const scanEnd = openedRevision.size; + const openedIdentityKey = usageLogIdentityKey(openedRevision); + if (options.expectedIdentityKey && options.expectedIdentityKey !== openedIdentityKey) { + throw rebuildRequired("identity_mismatch"); + } + if (scanEnd < startAtBytes) throw rebuildRequired("shrink"); + if (startAtBytes > 0) { + const preceding = Buffer.allocUnsafe(1); + const read = readSync(fd, preceding, 0, 1, startAtBytes - 1); + if (read !== 1) throw rebuildRequired("shrink"); + if (preceding[0] !== 0x0a) throw rebuildRequired("boundary_mismatch"); + } + if (options.expectedProcessedThroughDigest !== undefined) { + captureRangeIntoWindow( + fd, + Math.max(0, startAtBytes - USAGE_LEDGER_BOUNDARY_DIGEST_BYTES), + startAtBytes, + line, + committedTail, + options.signal, + ); + if (committedTail.digest() !== options.expectedProcessedThroughDigest) { + throw rebuildRequired("content_changed"); + } + } + + const publishLine = (): void => { + if (lineLength === 0 || isJsonWhitespace(line, lineLength)) return; + let entry: PersistedUsageEntry | undefined; + try { + const text = decoder.decode(line.subarray(0, lineLength)); + entry = normalizePersistedUsageRow(JSON.parse(text)); + } catch { + invalidRows += 1; + return; + } + if (!entry) { + invalidRows += 1; + return; + } + options.onEntry(entry); + parsedRows += 1; + }; + + for (let position = startAtBytes; position < scanEnd;) { + throwIfAborted(options.signal); + const chunkStart = position; + const requested = Math.min(chunk.byteLength, scanEnd - position); + const read = readSync(fd, chunk, 0, requested, position); + if (read === 0) throw rebuildRequired("shrink"); + position += read; + bytesRead += read; + bytesSinceYield += read; + capturedHash.update(chunk.subarray(0, read)); + + let cursor = 0; + while (cursor < read) { + const newline = chunk.indexOf(0x0a, cursor); + const segmentEnd = newline >= 0 && newline < read ? newline : read; + const segmentLength = segmentEnd - cursor; + + if (segmentLength > 0) { + pendingTail.append(chunk, cursor, segmentEnd); + pendingLineBytes = Math.min( + USAGE_LEDGER_BOUNDARY_DIGEST_BYTES, + pendingLineBytes + segmentLength, + ); + } + + if (!droppingOversizedLine) { + if (lineLength + segmentLength > USAGE_LEDGER_MAX_LINE_BYTES) { + oversizedRows += 1; + droppingOversizedLine = true; + lineLength = 0; + } else if (segmentLength > 0) { + chunk.copy(line, lineLength, cursor, segmentEnd); + lineLength += segmentLength; + } + } + + if (newline < 0 || newline >= read) break; + linesSinceYield += 1; + processedThroughBytes = chunkStart + newline + 1; + if (pendingLineBytes + 1 >= USAGE_LEDGER_BOUNDARY_DIGEST_BYTES) { + committedTail.reset(); + } + committedTail.appendWindow(pendingTail); + committedTail.appendByte(0x0a); + pendingTail.reset(); + pendingLineBytes = 0; + if (droppingOversizedLine) { + droppingOversizedLine = false; + } else { + publishLine(); + } + lineLength = 0; + cursor = newline + 1; + } + + if (position < scanEnd + && (bytesSinceYield >= USAGE_LEDGER_READ_CHUNK_BYTES || linesSinceYield >= 1_000)) { + await new Promise(resolve => setTimeout(resolve, 0)); + bytesSinceYield = 0; + linesSinceYield = 0; + } + } + + // A non-empty suffix without LF is not a committed JSONL row, even when it happens + // to contain valid JSON. Count it as invalid and leave it out of the aggregate. + if (!droppingOversizedLine && lineLength > 0 && !isJsonWhitespace(line, lineLength)) { + invalidRows += 1; + } + + throwIfAborted(options.signal); + const endingRevision = revisionFromStat(path, fstatSync(fd)); + if (usageLogIdentityKey(endingRevision) !== openedIdentityKey) { + throw rebuildRequired("identity_mismatch"); + } + if (endingRevision.size < scanEnd) throw rebuildRequired("shrink"); + const pathRevision = currentUsageLogRevision(); + if (!pathRevision || usageLogIdentityKey(pathRevision) !== openedIdentityKey) { + throw rebuildRequired("identity_mismatch"); + } + if (pathRevision.size < scanEnd) throw rebuildRequired("shrink"); + + // A pure append changes size/mtime/ctime but leaves the captured prefix intact and + // is safe to ignore until the next scan. Re-read only that prefix when a mutation + // was observed, so a same-inode rewrite (including rewrite + growth) cannot publish + // a mixture of old and new rows after a cooperative yield. + const mutationObserved = usageLogRevisionKey(endingRevision) !== usageLogRevisionKey(openedRevision) + || usageLogRevisionKey(pathRevision) !== usageLogRevisionKey(openedRevision); + if (mutationObserved) { + const capturedDigest = capturedHash.digest("hex"); + const verifiedDigest = await digestRangeCooperatively( + fd, + startAtBytes, + scanEnd, + line, + options.signal, + ); + const verifiedFdRevision = revisionFromStat(path, fstatSync(fd)); + const verifiedPathRevision = currentUsageLogRevision(); + if (capturedDigest !== verifiedDigest) throw rebuildRequired("content_changed"); + if (!verifiedPathRevision + || usageLogIdentityKey(verifiedFdRevision) !== openedIdentityKey + || usageLogIdentityKey(verifiedPathRevision) !== openedIdentityKey) { + throw rebuildRequired("identity_mismatch"); + } + if (verifiedFdRevision.size < scanEnd || verifiedPathRevision.size < scanEnd) { + throw rebuildRequired("shrink"); + } + } + + throwIfAborted(options.signal); + const processedThroughDigest = committedTail.digest(); + + return { + revision: openedRevision, + parsedRows, + invalidRows, + oversizedRows, + bytesRead, + processedThroughBytes, + processedThroughDigest, + }; + } finally { + closeSync(fd); + } +} diff --git a/src/usage/log.ts b/src/usage/log.ts index 7e056f97b0..6a74ae7f96 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -1190,7 +1190,7 @@ export async function readUsageEntriesForManagement(): Promise; if (typeof row.requestId !== "string" || typeof row.provider !== "string") return undefined; diff --git a/src/usage/summary.ts b/src/usage/summary.ts index b37aac8533..53148331f8 100644 --- a/src/usage/summary.ts +++ b/src/usage/summary.ts @@ -15,6 +15,8 @@ export const USAGE_RANGES = ["today", "7d", "30d", "all"] as const; export type UsageRange = typeof USAGE_RANGES[number]; export const USAGE_SURFACES = ["all", "codex", "claude", "grok"] as const; export type UsageSurface = typeof USAGE_SURFACES[number]; +/** Maximum number of calendar buckets returned by the all-history chart. */ +export const MAX_USAGE_DAY_BUCKETS = 366; export interface UsageSummaryTotals { requests: number; @@ -147,7 +149,7 @@ export interface UsageSummary { } /** - * Echo of an applied provider/model projection. + * Echo of an applied API-key/provider/model projection. * * Present only on a filtered response so a consumer can distinguish "no rows * matched" from "no traffic in this window", and can tell that the totals it @@ -236,16 +238,6 @@ export function computeEntryCost(entry: PersistedUsageEntry): EntryCostInfo { const DAY_MS = 86_400_000; export const MAX_USAGE_MODEL_BREAKDOWN_ROWS = 256; -function retainedBreakdownRows( - rows: T[], - aggregateOverflow: (overflow: T[]) => T, -): T[] { - if (rows.length <= MAX_USAGE_MODEL_BREAKDOWN_ROWS) return rows; - const keep = rows.slice(0, MAX_USAGE_MODEL_BREAKDOWN_ROWS - 1); - keep.push(aggregateOverflow(rows.slice(MAX_USAGE_MODEL_BREAKDOWN_ROWS - 1))); - return keep; -} - export function parseRange(input: string | null | undefined): UsageRange { // `1d` normalises here rather than becoming a second union member: a second // member would need its own cache slot, its own grid arm and its own test @@ -287,17 +279,16 @@ export function rangeWindow(range: UsageRange, now: number): { since: number | n function localDateKey(ts: number): string { const d = new Date(ts); - const y = d.getFullYear(); + const y = String(d.getFullYear()).padStart(4, "0"); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${y}-${m}-${day}`; } -function dayCountForAllRange(entries: PersistedUsageEntry[], now: number): number { - if (entries.length === 0) return 1; - const oldest = entries.reduce((min, e) => Math.min(min, e.timestamp), entries[0].timestamp); +function dayCountForAllRange(oldest: number | null, now: number): number { + if (oldest === null) return 1; const days = Math.ceil((now - oldest) / DAY_MS) + 1; - return Math.max(1, days); + return Math.min(MAX_USAGE_DAY_BUCKETS, Math.max(1, days)); } function blankTotals(): UsageSummaryTotals { @@ -333,6 +324,7 @@ interface UsageAttribution { provider: string; model: string; resolvedModel?: string; + accountLogLabel?: string; usageStatus: UsageStatus; usage?: PersistedUsageEntry["usage"]; totalTokens?: number; @@ -365,7 +357,7 @@ function usageModelIdentity( } function usageModelKey(providerKey: string, model: string): string { - return `${providerKey}/${model}`; + return `${providerKey}\0${model}`; } function usageAttributions(entry: PersistedUsageEntry): UsageAttribution[] { @@ -374,6 +366,7 @@ function usageAttributions(entry: PersistedUsageEntry): UsageAttribution[] { requestId: entry.requestId, provider: entry.provider, ...usageModelIdentity(entry.provider, entry.model, entry.resolvedModel), + ...(entry.accountLogLabel ? { accountLogLabel: entry.accountLogLabel } : {}), usageStatus: entry.usageStatus, ...(entry.usage ? { usage: entry.usage } : {}), ...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}), @@ -383,6 +376,7 @@ function usageAttributions(entry: PersistedUsageEntry): UsageAttribution[] { requestId: entry.requestId, provider: attempt.provider, ...usageModelIdentity(attempt.provider, attempt.model), + ...(attempt.accountLogLabel ? { accountLogLabel: attempt.accountLogLabel } : {}), usageStatus: attempt.usageStatus, ...(attempt.usage ? { usage: attempt.usage } : {}), ...(attempt.totalTokens !== undefined ? { totalTokens: attempt.totalTokens } : {}), @@ -446,17 +440,6 @@ function projectedComboUsage( }; } -function foldAttributionStatuses(statuses: readonly UsageStatus[]): UsageStatus { - if (statuses.length > 0 && statuses.every(status => status === "unsupported")) { - return "unsupported"; - } - if (statuses.some(status => status === "unreported" || status === "unsupported")) { - return "unreported"; - } - if (statuses.some(status => status === "estimated")) return "estimated"; - return statuses.length > 0 ? "reported" : "unreported"; -} - function bumpStatus(totals: UsageSummaryTotals, status: UsageStatus): void { totals.requests += 1; if (isMeasuredStatus(status)) totals.measuredRequests += 1; @@ -505,500 +488,728 @@ function addEstimatedCost( totals.estimatedCostUsd += costInfo.costTotal; } -function buildDayGrid(range: UsageRange, since: number | null, now: number, entries: PersistedUsageEntry[], costMap: Map): UsageDay[] { - const window = rangeWindow(range, now); - const days = range === "all" ? dayCountForAllRange(entries, now) : window.days; - const grid = new Map(); - // Per-day model breakdown accumulator, keyed by day then provider/model, so the 7d bar chart can - // render a per-model stacked bar with a hover tooltip without a second pass over the entries. - interface DayModelAccumulator extends UsageDayModel { - cacheObserved?: boolean; +const REQUEST_REPORTED = 1 << 0; +const REQUEST_ESTIMATED = 1 << 1; +const REQUEST_UNREPORTED = 1 << 2; +const REQUEST_UNSUPPORTED = 1 << 3; +const REQUEST_PRICED = 1 << 4; +const REQUEST_UNPRICED = 1 << 5; +const REQUEST_STATUS_MASK = REQUEST_REPORTED | REQUEST_ESTIMATED | REQUEST_UNREPORTED | REQUEST_UNSUPPORTED; + +type UsagePartitionSurface = Exclude | "other"; +export type UsageAccumulatorMode = "exact" | "row-unique"; + +interface UsageRequestCounts { + requests: number; + measuredRequests: number; + reportedRequests: number; + estimatedRequests: number; + pricedRequests: number; + unpricedRequests: number; +} + +interface UsageModelOverlap { + models: ReadonlyArray; + count: number; +} + +interface UsageModelAccumulator { + provider: string; + model: string; + resolvedModel?: string; + firstSeen: number; + attemptCount: number; + dayTotalTokens: number; + summaryTotalTokens: number; + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + cacheObserved: boolean; + estimatedCostUsd?: number; + requestCounts: UsageRequestCounts; + requestFacts?: Map; +} + +interface UsageAccountAccumulator { + accountLogLabel: string; + ambiguous: boolean; + firstSeen: number; + requests: number; + requestIds?: Set; + attemptCount: number; + measuredAttempts: number; + reportedAttempts: number; + estimatedAttempts: number; + unmeteredAttempts: number; + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + reasoningOutputTokens: number; + totalTokens: number; + estimatedCostUsd?: number; + pricedAttempts: number; + unpricedAttempts: number; +} + +interface UsagePartition { + date: string; + dayStart: number; + surface: UsagePartitionSurface; + oldestTimestamp: number | null; + totals: UsageSummaryTotals; + models: Map; + providers?: Map; + accounts: Map; + modelOverlaps: Map; +} + +interface UsageDayAccumulator { + totals: UsageSummaryTotals; + models: Map; + modelOverlaps: UsageModelOverlap[]; +} + +interface NormalizedUsageFilter { + provider: string | null; + model: string | null; + apiKeyId: string | null; +} + +export interface UsageSummaryAccumulator { + add(entry: PersistedUsageEntry): void; + /** Return a mutation-independent snapshot that may continue accepting rows. */ + clone(): UsageSummaryAccumulator; + summarize( + range: UsageRange, + now: number, + surface?: UsageSurface, + ): UsageSummary & { filter?: UsageFilterEcho }; + readonly snapshotWindow: { start: number | null; end: number | null }; + /** Conservative O(1) retained-state estimate; excludes scan and summarize temporaries. */ + readonly estimatedBytes: number; +} + +function requestStatusFact(status: UsageStatus): number { + if (status === "reported") return REQUEST_REPORTED; + if (status === "estimated") return REQUEST_ESTIMATED; + if (status === "unsupported") return REQUEST_UNSUPPORTED; + return REQUEST_UNREPORTED; +} + +function statusFromRequestFacts(facts: number): UsageStatus { + const statuses = facts & REQUEST_STATUS_MASK; + if (statuses === REQUEST_UNSUPPORTED) return "unsupported"; + if ((statuses & (REQUEST_UNREPORTED | REQUEST_UNSUPPORTED)) !== 0) return "unreported"; + if ((statuses & REQUEST_ESTIMATED) !== 0) return "estimated"; + return (statuses & REQUEST_REPORTED) !== 0 ? "reported" : "unreported"; +} + +function blankRequestCounts(): UsageRequestCounts { + return { + requests: 0, + measuredRequests: 0, + reportedRequests: 0, + estimatedRequests: 0, + pricedRequests: 0, + unpricedRequests: 0, + }; +} + +function bumpRequestCounts(counts: UsageRequestCounts, facts: number, amount = 1): void { + counts.requests += amount; + const status = statusFromRequestFacts(facts); + if (isMeasuredStatus(status)) counts.measuredRequests += amount; + if (status === "reported") counts.reportedRequests += amount; + else if (status === "estimated") counts.estimatedRequests += amount; + if ((facts & REQUEST_PRICED) !== 0) counts.pricedRequests += amount; + if ((facts & REQUEST_UNPRICED) !== 0) counts.unpricedRequests += amount; +} + +function mergeRequestCounts(target: UsageRequestCounts, source: UsageRequestCounts): void { + target.requests += source.requests; + target.measuredRequests += source.measuredRequests; + target.reportedRequests += source.reportedRequests; + target.estimatedRequests += source.estimatedRequests; + target.pricedRequests += source.pricedRequests; + target.unpricedRequests += source.unpricedRequests; +} + +function mergeRequestFacts(target: Map, source: Map): void { + for (const [requestId, facts] of source) { + target.set(requestId, (target.get(requestId) ?? 0) | facts); } - const dayModels = new Map>(); - const dayModelRequests = new Map>(); - const bumpDayModel = (dayKey: string, attribution: UsageAttribution): void => { - let models = dayModels.get(dayKey); - if (!models) { models = new Map(); dayModels.set(dayKey, models); } - const providerKey = baseProviderLabel(attribution.provider); - const mKey = usageModelKey(providerKey, attribution.model); - let m = models.get(mKey); - if (!m) { - m = { - model: attribution.model, - provider: providerKey, - requests: 0, - attemptCount: 0, - totalTokens: 0, - inputTokens: 0, - outputTokens: 0, - cacheReadInputTokens: 0, - cacheCreationInputTokens: 0, - cacheHitRate: null, - }; - models.set(mKey, m); - } - const requestKey = `${dayKey}\0${mKey}`; - let requests = dayModelRequests.get(requestKey); - if (!requests) { requests = new Set(); dayModelRequests.set(requestKey, requests); } - requests.add(attribution.requestId); - m.requests = requests.size; - m.attemptCount += 1; - if (attribution.usage) { - m.inputTokens = (m.inputTokens ?? 0) + attribution.usage.inputTokens; - m.outputTokens = (m.outputTokens ?? 0) + attribution.usage.outputTokens; - const { read, creation, hasCacheTelemetry } = cacheTokensFromUsage(attribution.usage); - if (hasCacheTelemetry) m.cacheObserved = true; - if (typeof read === "number") m.cacheReadInputTokens = (m.cacheReadInputTokens ?? 0) + read; - if (typeof creation === "number") m.cacheCreationInputTokens = (m.cacheCreationInputTokens ?? 0) + creation; - } - m.totalTokens += usageDisplayTotalTokens(attribution.usage, attribution.totalTokens) ?? 0; +} + +function requestCountsFor(model: UsageModelAccumulator): UsageRequestCounts { + if (!model.requestFacts) return model.requestCounts; + const counts = blankRequestCounts(); + for (const facts of model.requestFacts.values()) bumpRequestCounts(counts, facts); + return counts; +} + +function mergeTotals(target: UsageSummaryTotals, source: UsageSummaryTotals): void { + target.requests += source.requests; + target.attemptCount += source.attemptCount; + target.measuredRequests += source.measuredRequests; + target.reportedRequests += source.reportedRequests; + target.unreportedRequests += source.unreportedRequests; + target.unsupportedRequests += source.unsupportedRequests; + target.estimatedRequests += source.estimatedRequests; + target.inputTokens += source.inputTokens; + target.outputTokens += source.outputTokens; + target.cachedInputTokens += source.cachedInputTokens; + target.cacheReadInputTokens += source.cacheReadInputTokens; + target.cacheCreationInputTokens += source.cacheCreationInputTokens; + target.reasoningOutputTokens += source.reasoningOutputTokens; + target.totalTokens += source.totalTokens; + target.estimatedCostUsd += source.estimatedCostUsd; + target.pricedRequests += source.pricedRequests; + target.unpricedRequests += source.unpricedRequests; + target.unmeteredRequests += source.unmeteredRequests; +} + +function blankModelAccumulator( + provider: string, + model: string, + resolvedModel: string | undefined, + firstSeen: number, + mode: UsageAccumulatorMode, +): UsageModelAccumulator { + return { + provider, + model, + ...(resolvedModel ? { resolvedModel } : {}), + firstSeen, + attemptCount: 0, + dayTotalTokens: 0, + summaryTotalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + cacheObserved: false, + requestCounts: blankRequestCounts(), + ...(mode === "exact" ? { requestFacts: new Map() } : {}), + }; +} + +function cloneModelAccumulator(source: UsageModelAccumulator): UsageModelAccumulator { + return { + ...source, + requestCounts: { ...source.requestCounts }, + ...(source.requestFacts ? { requestFacts: new Map(source.requestFacts) } : {}), }; - const startOfToday = startOfLocalDay(now); - for (let i = days - 1; i >= 0; i--) { - const d = new Date(startOfToday); - d.setDate(d.getDate() - i); - const key = localDateKey(d.getTime()); - grid.set(key, { date: key, requests: 0, measuredRequests: 0, reportedRequests: 0, totalTokens: 0, estimatedCostUsd: 0, models: [] }); +} + +function mergeModelAccumulator(target: UsageModelAccumulator, source: UsageModelAccumulator): void { + if (source.firstSeen < target.firstSeen) { + target.firstSeen = source.firstSeen; + target.resolvedModel = source.resolvedModel; } - for (const entry of entries) { - const key = localDateKey(entry.timestamp); - let day = grid.get(key); - if (!day) { - day = { date: key, requests: 0, measuredRequests: 0, reportedRequests: 0, totalTokens: 0, estimatedCostUsd: 0, models: [] }; - grid.set(key, day); - } - day.requests += 1; - if (isMeasuredStatus(entry.usageStatus)) day.measuredRequests += 1; - if (entry.usageStatus === "reported") day.reportedRequests += 1; - day.totalTokens += usageDisplayTotalTokens(entry.usage, entry.totalTokens) ?? 0; - for (const attribution of usageAttributions(entry)) bumpDayModel(key, attribution); - const costInfo = costMap.get(entry); - if (costInfo?.isPriced) { - if (entry.attempts?.length && costInfo.attemptEstimates) { - for (let i = 0; i < entry.attempts.length; i++) { - const attempt = entry.attempts[i]; - const attemptEst = costInfo.attemptEstimates[i]; - if (attemptEst) { - const aProviderKey = baseProviderLabel(attempt.provider); - const aIdentity = usageModelIdentity(attempt.provider, attempt.model); - const aKey = usageModelKey(aProviderKey, aIdentity.model); - const m = dayModels.get(key)?.get(aKey); - if (m) m.estimatedCostUsd = (m.estimatedCostUsd ?? 0) + attemptEst.cost.total; - } - } - } else if (costInfo.estimate) { - const providerKey = baseProviderLabel(entry.provider); - const identity = usageModelIdentity(entry.provider, entry.model, entry.resolvedModel); - const mKey = usageModelKey(providerKey, identity.model); - const m = dayModels.get(key)?.get(mKey); - if (m) m.estimatedCostUsd = (m.estimatedCostUsd ?? 0) + costInfo.estimate.cost.total; - } - day.estimatedCostUsd += costInfo.costTotal; - } + target.attemptCount += source.attemptCount; + target.dayTotalTokens += source.dayTotalTokens; + target.summaryTotalTokens += source.summaryTotalTokens; + target.inputTokens += source.inputTokens; + target.outputTokens += source.outputTokens; + target.cacheReadInputTokens += source.cacheReadInputTokens; + target.cacheCreationInputTokens += source.cacheCreationInputTokens; + target.cacheObserved ||= source.cacheObserved; + if (source.estimatedCostUsd !== undefined) { + target.estimatedCostUsd = (target.estimatedCostUsd ?? 0) + source.estimatedCostUsd; } - void since; - const out = [...grid.values()].sort((a, b) => a.date.localeCompare(b.date)); - for (const day of out) { - const models = dayModels.get(day.date); - if (models) { - for (const m of models.values()) { - m.cacheHitRate = calculateCacheHitRate(!!m.cacheObserved, m.inputTokens ?? 0, m.cacheReadInputTokens ?? 0); - } - const sorted = [...models.values()].sort((a, b) => b.requests - a.requests); - const retained = retainedBreakdownRows(sorted, overflow => { - const requests = new Set(); - let attemptCount = 0; - let totalTokens = 0; - let inputTokens = 0; - let outputTokens = 0; - let cacheReadInputTokens = 0; - let cacheCreationInputTokens = 0; - let cacheObserved = false; - let estimatedCostUsd: number | undefined; - for (const model of overflow) { - attemptCount += model.attemptCount; - totalTokens += model.totalTokens; - inputTokens += model.inputTokens ?? 0; - outputTokens += model.outputTokens ?? 0; - cacheReadInputTokens += model.cacheReadInputTokens ?? 0; - cacheCreationInputTokens += model.cacheCreationInputTokens ?? 0; - if (model.cacheObserved) cacheObserved = true; - if (model.estimatedCostUsd !== undefined) { - estimatedCostUsd = (estimatedCostUsd ?? 0) + model.estimatedCostUsd; - } - const requestKey = `${day.date}\0${usageModelKey(model.provider, model.model)}`; - for (const requestId of dayModelRequests.get(requestKey) ?? []) requests.add(requestId); - } - const cacheHitRate = calculateCacheHitRate(cacheObserved, inputTokens, cacheReadInputTokens); - return { - model: "other", - provider: "other", - requests: requests.size, - attemptCount, - totalTokens, - inputTokens, - outputTokens, - cacheReadInputTokens, - cacheCreationInputTokens, - cacheHitRate, - ...(estimatedCostUsd !== undefined ? { estimatedCostUsd } : {}), - }; - }); - for (const model of retained) delete model.cacheObserved; - day.models = retained; - } + if (target.requestFacts && source.requestFacts) mergeRequestFacts(target.requestFacts, source.requestFacts); + else mergeRequestCounts(target.requestCounts, source.requestCounts); +} + +function mergeModelMaps( + target: Map, + source: Map, +): void { + for (const [key, model] of source) { + const current = target.get(key); + if (current) mergeModelAccumulator(current, model); + else target.set(key, cloneModelAccumulator(model)); } - return out; } -function buildModels(entries: PersistedUsageEntry[], totalTokens: number, costMap: Map): UsageModel[] { - interface ModelAccumulator extends UsageModel { - cacheObserved?: boolean; +function cloneAccountAccumulator(source: UsageAccountAccumulator): UsageAccountAccumulator { + return { + ...source, + ...(source.requestIds ? { requestIds: new Set(source.requestIds) } : {}), + }; +} + +function mergeAccountAccumulator(target: UsageAccountAccumulator, source: UsageAccountAccumulator): void { + target.firstSeen = Math.min(target.firstSeen, source.firstSeen); + if (target.requestIds && source.requestIds) { + for (const requestId of source.requestIds) target.requestIds.add(requestId); + } else { + target.requests += source.requests; } - const byKey = new Map(); - const statusesByKey = new Map>(); - for (const entry of entries) { - for (const attribution of usageAttributions(entry)) { - const providerKey = baseProviderLabel(attribution.provider); - // resolvedModel is a routing detail, not a row identity. - const key = usageModelKey(providerKey, attribution.model); - let model = byKey.get(key); - if (!model) { - model = { - provider: providerKey, - model: attribution.model, - ...(attribution.resolvedModel ? { resolvedModel: attribution.resolvedModel } : {}), - requests: 0, - attemptCount: 0, - measuredRequests: 0, - reportedRequests: 0, - estimatedRequests: 0, - totalTokens: 0, - inputTokens: 0, - outputTokens: 0, - cachedInputTokens: 0, - cacheReadInputTokens: 0, - cacheCreationInputTokens: 0, - pricedRequests: 0, - unpricedRequests: 0, - priceCoverageRatio: 0, - shareRatio: 0, - }; - byKey.set(key, model); - } - model.attemptCount += 1; - let requests = statusesByKey.get(key); - if (!requests) { requests = new Map(); statusesByKey.set(key, requests); } - const statuses = requests.get(attribution.requestId) ?? []; - statuses.push(attribution.usageStatus); - requests.set(attribution.requestId, statuses); - if (attribution.usage) { - model.inputTokens += attribution.usage.inputTokens; - model.outputTokens += attribution.usage.outputTokens; - const { read, creation, hasCacheTelemetry } = cacheTokensFromUsage(attribution.usage); - if (hasCacheTelemetry) model.cacheObserved = true; - if (typeof read === "number") { - model.cachedInputTokens = (model.cachedInputTokens ?? 0) + read; - model.cacheReadInputTokens = (model.cacheReadInputTokens ?? 0) + read; - } - if (typeof creation === "number") { - model.cacheCreationInputTokens = (model.cacheCreationInputTokens ?? 0) + creation; - } - model.totalTokens += usageDisplayTotalTokens(attribution.usage, attribution.totalTokens) ?? 0; - } - } + target.attemptCount += source.attemptCount; + target.measuredAttempts += source.measuredAttempts; + target.reportedAttempts += source.reportedAttempts; + target.estimatedAttempts += source.estimatedAttempts; + target.unmeteredAttempts += source.unmeteredAttempts; + target.inputTokens += source.inputTokens; + target.outputTokens += source.outputTokens; + target.cacheReadInputTokens += source.cacheReadInputTokens; + target.cacheCreationInputTokens += source.cacheCreationInputTokens; + target.reasoningOutputTokens += source.reasoningOutputTokens; + target.totalTokens += source.totalTokens; + if (source.estimatedCostUsd !== undefined) { + target.estimatedCostUsd = (target.estimatedCostUsd ?? 0) + source.estimatedCostUsd; } - for (const [key, model] of byKey) { - const groups = statusesByKey.get(key) ?? new Map(); - model.requests = groups.size; - for (const statuses of groups.values()) { - const status = foldAttributionStatuses(statuses); - if (isMeasuredStatus(status)) model.measuredRequests += 1; - if (status === "reported") model.reportedRequests += 1; - else if (status === "estimated") model.estimatedRequests += 1; - } + target.pricedAttempts += source.pricedAttempts; + target.unpricedAttempts += source.unpricedAttempts; +} + +function usagePartitionSurface(entry: PersistedUsageEntry): UsagePartitionSurface { + if (entry.surface === undefined) return "codex"; + if (entry.surface === "claude" || entry.surface === "claude-desktop") return "claude"; + if (entry.surface === "grok") return "grok"; + return "other"; +} + +function usageSurfaceMatches(partition: UsagePartitionSurface, surface: UsageSurface): boolean { + return surface === "all" || partition === surface; +} + +const LEGACY_AMBIGUOUS_ACCOUNT_LABEL = "legacy-ambiguous"; + +function legacyCodexAccountLabel(provider: string): string | null { + if (baseProviderLabel(provider) !== "openai") return null; + const suffix = provider.match(/-(main|p[a-f0-9]{6})$/)?.[1]; + return suffix ?? LEGACY_AMBIGUOUS_ACCOUNT_LABEL; +} + +/** + * An explicitly stamped label of EITHER family is authoritative for any provider (#2699). + * The legacy fallback stays openai-only so unrelated unlabeled providers are not guessed. + */ +function accountLabelForAttribution(provider: string, explicit: unknown): string | null { + if (isCodexUsageAccountLogLabel(explicit)) return explicit; + return legacyCodexAccountLabel(provider); +} + +function filterMatchesAttribution( + filter: NormalizedUsageFilter, + provider: string, + model: string, +): boolean { + if (filter.provider !== null && baseProviderLabel(provider).toLowerCase() !== filter.provider) return false; + if (filter.model !== null && model.toLowerCase() !== filter.model) return false; + return true; +} + +function projectedEntryForFilter( + entry: PersistedUsageEntry, + filter: NormalizedUsageFilter, +): { entry: PersistedUsageEntry; comboOverlap: boolean } | null { + if (filter.apiKeyId !== null && entry.apiKeyId !== filter.apiKeyId) return null; + if (!entry.attempts?.length) { + const identity = usageModelIdentity(entry.provider, entry.model, entry.resolvedModel); + return filterMatchesAttribution(filter, entry.provider, identity.model) + ? { entry, comboOverlap: false } + : null; } - // Accumulate per-model estimated cost & price coverage by request ID - const pricedRequestsByModel = new Map>(); - const unpricedRequestsByModel = new Map>(); - for (const entry of entries) { - const costInfo = costMap.get(entry); - if (entry.attempts?.length) { - const attemptEstimates = costInfo?.attemptEstimates; - for (let i = 0; i < entry.attempts.length; i++) { - const attempt = entry.attempts[i]; - const attemptEst = attemptEstimates?.[i]; - const aProviderKey = baseProviderLabel(attempt.provider); - const aIdentity = usageModelIdentity(attempt.provider, attempt.model); - const aKey = usageModelKey(aProviderKey, aIdentity.model); - if (attemptEst) { - const m = byKey.get(aKey); - if (m) { - m.estimatedCostUsd = (m.estimatedCostUsd ?? 0) + attemptEst.cost.total; - } - let s = pricedRequestsByModel.get(aKey); - if (!s) { s = new Set(); pricedRequestsByModel.set(aKey, s); } - s.add(entry.requestId); - } else { - let s = unpricedRequestsByModel.get(aKey); - if (!s) { s = new Set(); unpricedRequestsByModel.set(aKey, s); } - s.add(entry.requestId); - } - } - } else { - const providerKey = baseProviderLabel(entry.provider); - const identity = usageModelIdentity(entry.provider, entry.model, entry.resolvedModel); - const key = usageModelKey(providerKey, identity.model); - const estimate = costInfo?.estimate; - if (estimate) { - const m = byKey.get(key); - if (m) { - m.estimatedCostUsd = (m.estimatedCostUsd ?? 0) + estimate.cost.total; - } - let s = pricedRequestsByModel.get(key); - if (!s) { s = new Set(); pricedRequestsByModel.set(key, s); } - s.add(entry.requestId); - } else { - let s = unpricedRequestsByModel.get(key); - if (!s) { s = new Set(); unpricedRequestsByModel.set(key, s); } - s.add(entry.requestId); + const attempts = entry.attempts.filter(attempt => { + const identity = usageModelIdentity(attempt.provider, attempt.model); + return filterMatchesAttribution(filter, attempt.provider, identity.model); + }); + if (attempts.length === 0) return null; + const { usage: _parentUsage, totalTokens: _parentTotalTokens, ...withoutParentUsage } = entry; + return { + entry: { ...withoutParentUsage, attempts, ...projectedComboUsage(attempts) }, + comboOverlap: entry.attempts.length > 1, + }; +} + +function overflowModelAccumulator( + models: UsageModelAccumulator[], + overlaps: readonly UsageModelOverlap[], +): UsageModelAccumulator { + const mode: UsageAccumulatorMode = models[0]?.requestFacts ? "exact" : "row-unique"; + const other = blankModelAccumulator("other", "other", undefined, models[0]?.firstSeen ?? 0, mode); + for (const model of models) mergeModelAccumulator(other, model); + if (mode === "row-unique" && overlaps.length > 0) { + const overflowKeys = new Set(models.map(model => usageModelKey(model.provider, model.model))); + for (const overlap of overlaps) { + const retained = overlap.models.filter(([modelKey]) => overflowKeys.has(modelKey)); + if (retained.length < 2) continue; + let combinedFacts = 0; + for (const [, facts] of retained) { + bumpRequestCounts(other.requestCounts, facts, -overlap.count); + combinedFacts |= facts; } + bumpRequestCounts(other.requestCounts, combinedFacts, overlap.count); } } - const models = [...byKey.values()]; - for (const [key, m] of byKey) { - m.pricedRequests = pricedRequestsByModel.get(key)?.size ?? 0; - m.unpricedRequests = unpricedRequestsByModel.get(key)?.size ?? 0; - m.shareRatio = totalTokens === 0 ? 0 : m.totalTokens / totalTokens; - m.cacheHitRate = calculateCacheHitRate(!!m.cacheObserved, m.inputTokens, m.cacheReadInputTokens ?? 0); - m.priceCoverageRatio = m.requests > 0 ? m.pricedRequests / m.requests : 0; - } - const sorted = models.sort((a, b) => b.requests - a.requests); - const retained = retainedBreakdownRows(sorted, overflow => { - const statusesByRequest = new Map(); - const overflowPricedRequests = new Set(); - const overflowUnpricedRequests = new Set(); - let cacheObserved = false; - const other: ModelAccumulator = { - provider: "other", - model: "other", - requests: 0, - attemptCount: 0, - measuredRequests: 0, - reportedRequests: 0, - estimatedRequests: 0, - totalTokens: 0, - inputTokens: 0, - outputTokens: 0, - cachedInputTokens: 0, - cacheReadInputTokens: 0, - cacheCreationInputTokens: 0, - pricedRequests: 0, - unpricedRequests: 0, - priceCoverageRatio: 0, - shareRatio: 0, + other.provider = "other"; + other.model = "other"; + delete other.resolvedModel; + return other; +} + +function retainedModelAccumulators( + models: UsageModelAccumulator[], + overlaps: readonly UsageModelOverlap[], +): UsageModelAccumulator[] { + if (models.length <= MAX_USAGE_MODEL_BREAKDOWN_ROWS) return models; + return [ + ...models.slice(0, MAX_USAGE_MODEL_BREAKDOWN_ROWS - 1), + overflowModelAccumulator(models.slice(MAX_USAGE_MODEL_BREAKDOWN_ROWS - 1), overlaps), + ]; +} + +function buildDayModels( + models: Map, + overlaps: readonly UsageModelOverlap[], +): UsageDayModel[] { + const sorted = [...models.values()].sort((a, b) => + requestCountsFor(b).requests - requestCountsFor(a).requests || a.firstSeen - b.firstSeen + ); + return retainedModelAccumulators(sorted, overlaps).map(model => ({ + model: model.model, + provider: model.provider, + requests: requestCountsFor(model).requests, + attemptCount: model.attemptCount, + totalTokens: model.dayTotalTokens, + inputTokens: model.inputTokens, + outputTokens: model.outputTokens, + cacheReadInputTokens: model.cacheReadInputTokens, + cacheCreationInputTokens: model.cacheCreationInputTokens, + cacheHitRate: calculateCacheHitRate(model.cacheObserved, model.inputTokens, model.cacheReadInputTokens), + ...(model.estimatedCostUsd !== undefined ? { estimatedCostUsd: model.estimatedCostUsd } : {}), + })); +} + +function buildUsageModels( + models: Map, + totalTokens: number, + overlaps: readonly UsageModelOverlap[], +): UsageModel[] { + const sorted = [...models.values()].sort((a, b) => + requestCountsFor(b).requests - requestCountsFor(a).requests || a.firstSeen - b.firstSeen + ); + return retainedModelAccumulators(sorted, overlaps).map(model => { + const counts = requestCountsFor(model); + const requests = counts.requests; + return { + provider: model.provider, + model: model.model, + ...(model.resolvedModel ? { resolvedModel: model.resolvedModel } : {}), + requests, + attemptCount: model.attemptCount, + measuredRequests: counts.measuredRequests, + reportedRequests: counts.reportedRequests, + estimatedRequests: counts.estimatedRequests, + totalTokens: model.summaryTotalTokens, + inputTokens: model.inputTokens, + outputTokens: model.outputTokens, + cachedInputTokens: model.cacheReadInputTokens, + cacheReadInputTokens: model.cacheReadInputTokens, + cacheCreationInputTokens: model.cacheCreationInputTokens, + cacheHitRate: calculateCacheHitRate(model.cacheObserved, model.inputTokens, model.cacheReadInputTokens), + priceCoverageRatio: requests > 0 ? counts.pricedRequests / requests : 0, + pricedRequests: counts.pricedRequests, + unpricedRequests: counts.unpricedRequests, + shareRatio: totalTokens === 0 ? 0 : model.summaryTotalTokens / totalTokens, + ...(model.estimatedCostUsd !== undefined ? { estimatedCostUsd: model.estimatedCostUsd } : {}), }; - for (const model of overflow) { - other.attemptCount += model.attemptCount; - other.totalTokens += model.totalTokens; - other.inputTokens += model.inputTokens; - other.outputTokens += model.outputTokens; - if (model.cacheObserved) cacheObserved = true; - other.cachedInputTokens = (other.cachedInputTokens ?? 0) + (model.cachedInputTokens ?? 0); - other.cacheReadInputTokens = (other.cacheReadInputTokens ?? 0) + (model.cacheReadInputTokens ?? 0); - other.cacheCreationInputTokens = (other.cacheCreationInputTokens ?? 0) + (model.cacheCreationInputTokens ?? 0); - if (model.estimatedCostUsd !== undefined) { - other.estimatedCostUsd = (other.estimatedCostUsd ?? 0) + model.estimatedCostUsd; - } - const key = usageModelKey(model.provider, model.model); - for (const [requestId, statuses] of statusesByKey.get(key) ?? []) { - const combined = statusesByRequest.get(requestId) ?? []; - combined.push(...statuses); - statusesByRequest.set(requestId, combined); - } - for (const reqId of pricedRequestsByModel.get(key) ?? []) overflowPricedRequests.add(reqId); - for (const reqId of unpricedRequestsByModel.get(key) ?? []) overflowUnpricedRequests.add(reqId); - } - other.requests = statusesByRequest.size; - other.pricedRequests = overflowPricedRequests.size; - other.unpricedRequests = overflowUnpricedRequests.size; - for (const statuses of statusesByRequest.values()) { - const status = foldAttributionStatuses(statuses); - if (isMeasuredStatus(status)) other.measuredRequests += 1; - if (status === "reported") other.reportedRequests += 1; - else if (status === "estimated") other.estimatedRequests += 1; - } - other.shareRatio = totalTokens === 0 ? 0 : other.totalTokens / totalTokens; - other.cacheHitRate = calculateCacheHitRate(cacheObserved, other.inputTokens, other.cacheReadInputTokens ?? 0); - other.priceCoverageRatio = other.requests > 0 ? other.pricedRequests / other.requests : 0; - return other; }); - for (const model of retained) delete model.cacheObserved; - return retained; } -function buildProviders(entries: PersistedUsageEntry[], totalTokens: number, costMap: Map): UsageProvider[] { - interface ProviderAccumulator extends UsageProvider { - cacheObserved?: boolean; +function buildUsageProviders( + models: Map, + totalTokens: number, +): UsageProvider[] { + const providers = new Map(); + for (const model of models.values()) { + const current = providers.get(model.provider); + if (current) mergeModelAccumulator(current, model); + else providers.set(model.provider, cloneModelAccumulator(model)); + } + return [...providers.values()] + .sort((a, b) => requestCountsFor(b).requests - requestCountsFor(a).requests || a.firstSeen - b.firstSeen) + .map(provider => { + const counts = requestCountsFor(provider); + const requests = counts.requests; + return { + provider: provider.provider, + requests, + attemptCount: provider.attemptCount, + measuredRequests: counts.measuredRequests, + reportedRequests: counts.reportedRequests, + estimatedRequests: counts.estimatedRequests, + totalTokens: provider.summaryTotalTokens, + inputTokens: provider.inputTokens, + outputTokens: provider.outputTokens, + cachedInputTokens: provider.cacheReadInputTokens, + cacheReadInputTokens: provider.cacheReadInputTokens, + cacheCreationInputTokens: provider.cacheCreationInputTokens, + cacheHitRate: calculateCacheHitRate(provider.cacheObserved, provider.inputTokens, provider.cacheReadInputTokens), + priceCoverageRatio: requests > 0 ? counts.pricedRequests / requests : 0, + pricedRequests: counts.pricedRequests, + unpricedRequests: counts.unpricedRequests, + shareRatio: totalTokens === 0 ? 0 : provider.summaryTotalTokens / totalTokens, + ...(provider.estimatedCostUsd !== undefined ? { estimatedCostUsd: provider.estimatedCostUsd } : {}), + }; + }); +} + +function buildUsageAccounts(accounts: Map): UsageAccount[] { + return [...accounts.values()] + .sort((a, b) => b.totalTokens - a.totalTokens || a.firstSeen - b.firstSeen) + .map(account => ({ + accountLogLabel: account.accountLogLabel, + ambiguous: account.ambiguous, + requests: account.requestIds?.size ?? account.requests, + attemptCount: account.attemptCount, + measuredAttempts: account.measuredAttempts, + reportedAttempts: account.reportedAttempts, + estimatedAttempts: account.estimatedAttempts, + unmeteredAttempts: account.unmeteredAttempts, + inputTokens: account.inputTokens, + outputTokens: account.outputTokens, + cacheReadInputTokens: account.cacheReadInputTokens, + cacheCreationInputTokens: account.cacheCreationInputTokens, + reasoningOutputTokens: account.reasoningOutputTokens, + totalTokens: account.totalTokens, + usageCoverageRatio: account.attemptCount === 0 ? 0 : account.measuredAttempts / account.attemptCount, + ...(account.estimatedCostUsd !== undefined ? { estimatedCostUsd: account.estimatedCostUsd } : {}), + pricedAttempts: account.pricedAttempts, + unpricedAttempts: account.unpricedAttempts, + priceCoverageRatio: account.measuredAttempts === 0 ? 0 : account.pricedAttempts / account.measuredAttempts, + })); +} + +// Retained-size estimates intentionally favor over-counting. They are updated only when +// retained structures grow, so memory-budget checks stay O(1) even on very large ledgers. +const ESTIMATED_ACCUMULATOR_BASE_BYTES = 2_048; +const ESTIMATED_PARTITION_BYTES = 1_024; +const ESTIMATED_BREAKDOWN_BYTES = 1_024; +const ESTIMATED_EXACT_REQUEST_ID_BYTES = 1_024; +const ESTIMATED_REQUEST_FACT_BYTES = 512; +const ESTIMATED_ACCOUNT_REQUEST_BYTES = 256; +const ESTIMATED_OVERLAP_BYTES = 128; +const ESTIMATED_OVERLAP_MODEL_BYTES = 256; + +class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator { + private readonly partitions = new Map(); + private readonly requestIds: Map | null; + private readonly filter: NormalizedUsageFilter | null; + private readonly mode: UsageAccumulatorMode; + private nextRequestId = 0; + private nextOrdinal = 0; + private snapshotStart: number | null = null; + private snapshotEnd: number | null = null; + private comboOverlap = false; + private estimatedRetainedBytes = ESTIMATED_ACCUMULATOR_BASE_BYTES; + + constructor(options?: { + filter?: { provider?: string | null; model?: string | null; apiKeyId?: string | null }; + mode?: UsageAccumulatorMode; + }) { + const provider = normalizeFilterValue(options?.filter?.provider); + const model = normalizeFilterValue(options?.filter?.model); + const apiKeyId = normalizeExactFilterValue(options?.filter?.apiKeyId); + this.filter = provider === null && model === null && apiKeyId === null + ? null + : { provider, model, apiKeyId }; + this.mode = options?.mode ?? "exact"; + this.requestIds = this.mode === "exact" ? new Map() : null; } - const byKey = new Map(); - const statusesByKey = new Map>(); - for (const entry of entries) { - for (const attribution of usageAttributions(entry)) { - const providerKey = baseProviderLabel(attribution.provider); - let provider = byKey.get(providerKey); - if (!provider) { - provider = { - provider: providerKey, - requests: 0, - attemptCount: 0, - measuredRequests: 0, - reportedRequests: 0, - estimatedRequests: 0, - totalTokens: 0, - inputTokens: 0, - outputTokens: 0, - cachedInputTokens: 0, - cacheReadInputTokens: 0, - cacheCreationInputTokens: 0, - pricedRequests: 0, - unpricedRequests: 0, - priceCoverageRatio: 0, - shareRatio: 0, - }; - byKey.set(providerKey, provider); + + get snapshotWindow(): { start: number | null; end: number | null } { + return { start: this.snapshotStart, end: this.snapshotEnd }; + } + + get estimatedBytes(): number { + return this.estimatedRetainedBytes; + } + + clone(): UsageSummaryAccumulator { + const cloned = new StreamingUsageSummaryAccumulator({ + ...(this.filter ? { filter: this.filter } : {}), + mode: this.mode, + }); + cloned.nextRequestId = this.nextRequestId; + cloned.nextOrdinal = this.nextOrdinal; + cloned.snapshotStart = this.snapshotStart; + cloned.snapshotEnd = this.snapshotEnd; + cloned.comboOverlap = this.comboOverlap; + cloned.estimatedRetainedBytes = this.estimatedRetainedBytes; + if (this.requestIds && cloned.requestIds) { + for (const [requestId, key] of this.requestIds) cloned.requestIds.set(requestId, key); + } + for (const [key, partition] of this.partitions) { + const models = new Map(); + for (const [modelKey, model] of partition.models) { + models.set(modelKey, cloneModelAccumulator(model)); } - provider.attemptCount += 1; - let requests = statusesByKey.get(providerKey); - if (!requests) { requests = new Map(); statusesByKey.set(providerKey, requests); } - const statuses = requests.get(attribution.requestId) ?? []; - statuses.push(attribution.usageStatus); - requests.set(attribution.requestId, statuses); - if (attribution.usage) { - provider.inputTokens = (provider.inputTokens ?? 0) + attribution.usage.inputTokens; - provider.outputTokens = (provider.outputTokens ?? 0) + attribution.usage.outputTokens; - const { read, creation, hasCacheTelemetry } = cacheTokensFromUsage(attribution.usage); - if (hasCacheTelemetry) provider.cacheObserved = true; - if (typeof read === "number") { - provider.cachedInputTokens = (provider.cachedInputTokens ?? 0) + read; - provider.cacheReadInputTokens = (provider.cacheReadInputTokens ?? 0) + read; - } - if (typeof creation === "number") { - provider.cacheCreationInputTokens = (provider.cacheCreationInputTokens ?? 0) + creation; - } - provider.totalTokens += usageDisplayTotalTokens(attribution.usage, attribution.totalTokens) ?? 0; + const providers = partition.providers + ? new Map([...partition.providers].map(([providerKey, provider]) => [providerKey, cloneModelAccumulator(provider)])) + : undefined; + const accounts = new Map(); + for (const [label, account] of partition.accounts) { + accounts.set(label, cloneAccountAccumulator(account)); } + cloned.partitions.set(key, { + ...partition, + totals: { ...partition.totals }, + models, + ...(providers ? { providers } : {}), + accounts, + modelOverlaps: new Map( + [...partition.modelOverlaps].map(([signature, overlap]) => [signature, { ...overlap }]), + ), + }); } + return cloned; + } + + private requestKey(requestId: string): number { + if (!this.requestIds) throw new Error("row-unique accumulators do not retain request ids"); + const existing = this.requestIds.get(requestId); + if (existing !== undefined) return existing; + const key = this.nextRequestId++; + this.requestIds.set(requestId, key); + this.estimatedRetainedBytes += ESTIMATED_EXACT_REQUEST_ID_BYTES + requestId.length * 2; + return key; } - for (const [key, provider] of byKey) { - const groups = statusesByKey.get(key) ?? new Map(); - provider.requests = groups.size; - for (const statuses of groups.values()) { - const status = foldAttributionStatuses(statuses); - if (isMeasuredStatus(status)) provider.measuredRequests += 1; - if (status === "reported") provider.reportedRequests += 1; - else if (status === "estimated") provider.estimatedRequests += 1; + + private partitionFor(entry: PersistedUsageEntry): UsagePartition { + const date = localDateKey(entry.timestamp); + const dayStart = startOfLocalDay(entry.timestamp); + const surface = usagePartitionSurface(entry); + const key = `${date}\0${surface}`; + let partition = this.partitions.get(key); + if (!partition) { + partition = { + date, + dayStart, + surface, + oldestTimestamp: null, + totals: blankTotals(), + models: new Map(), + ...(this.mode === "row-unique" ? { providers: new Map() } : {}), + accounts: new Map(), + modelOverlaps: new Map(), + }; + this.partitions.set(key, partition); + this.estimatedRetainedBytes += ESTIMATED_PARTITION_BYTES; } + if (Number.isFinite(entry.timestamp)) { + partition.oldestTimestamp = partition.oldestTimestamp === null + ? entry.timestamp + : Math.min(partition.oldestTimestamp, entry.timestamp); + } + return partition; } - const pricedRequestsByProvider = new Map>(); - const unpricedRequestsByProvider = new Map>(); - for (const entry of entries) { - const costInfo = costMap.get(entry); - if (entry.attempts?.length) { - const attemptEstimates = costInfo?.attemptEstimates; - for (let i = 0; i < entry.attempts.length; i++) { - const attempt = entry.attempts[i]; - const attemptEst = attemptEstimates?.[i]; - const aProviderKey = baseProviderLabel(attempt.provider); - if (attemptEst) { - const p = byKey.get(aProviderKey); - if (p) { - p.estimatedCostUsd = (p.estimatedCostUsd ?? 0) + attemptEst.cost.total; - } - let s = pricedRequestsByProvider.get(aProviderKey); - if (!s) { s = new Set(); pricedRequestsByProvider.set(aProviderKey, s); } - s.add(entry.requestId); - } else { - let s = unpricedRequestsByProvider.get(aProviderKey); - if (!s) { s = new Set(); unpricedRequestsByProvider.set(aProviderKey, s); } - s.add(entry.requestId); - } - } - } else { - const providerKey = baseProviderLabel(entry.provider); - const estimate = costInfo?.estimate; - if (estimate) { - const p = byKey.get(providerKey); - if (p) { - p.estimatedCostUsd = (p.estimatedCostUsd ?? 0) + estimate.cost.total; - } - let s = pricedRequestsByProvider.get(providerKey); - if (!s) { s = new Set(); pricedRequestsByProvider.set(providerKey, s); } - s.add(entry.requestId); - } else { - let s = unpricedRequestsByProvider.get(providerKey); - if (!s) { s = new Set(); unpricedRequestsByProvider.set(providerKey, s); } - s.add(entry.requestId); - } + + private addAttributionMetrics( + breakdown: UsageModelAccumulator, + attribution: UsageAttribution, + estimate: AttemptCostEstimate | CostEstimate | null, + ): void { + breakdown.attemptCount += 1; + if (attribution.usage) { + breakdown.inputTokens += attribution.usage.inputTokens; + breakdown.outputTokens += attribution.usage.outputTokens; + const { read, creation, hasCacheTelemetry } = cacheTokensFromUsage(attribution.usage); + breakdown.cacheObserved ||= hasCacheTelemetry; + if (typeof read === "number") breakdown.cacheReadInputTokens += read; + if (typeof creation === "number") breakdown.cacheCreationInputTokens += creation; + breakdown.summaryTotalTokens += usageDisplayTotalTokens(attribution.usage, attribution.totalTokens) ?? 0; } + breakdown.dayTotalTokens += usageDisplayTotalTokens(attribution.usage, attribution.totalTokens) ?? 0; + if (estimate) breakdown.estimatedCostUsd = (breakdown.estimatedCostUsd ?? 0) + estimate.cost.total; } - const providers = [...byKey.values()]; - for (const [key, p] of byKey) { - p.pricedRequests = pricedRequestsByProvider.get(key)?.size ?? 0; - p.unpricedRequests = unpricedRequestsByProvider.get(key)?.size ?? 0; - p.shareRatio = totalTokens === 0 ? 0 : p.totalTokens / totalTokens; - p.cacheHitRate = calculateCacheHitRate(!!p.cacheObserved, p.inputTokens ?? 0, p.cacheReadInputTokens ?? 0); - p.priceCoverageRatio = p.requests > 0 ? p.pricedRequests / p.requests : 0; + + private addModelAttribution( + partition: UsagePartition, + attribution: UsageAttribution, + estimate: AttemptCostEstimate | CostEstimate | null, + ordinal: number, + ): string { + const provider = baseProviderLabel(attribution.provider); + const key = usageModelKey(provider, attribution.model); + let model = partition.models.get(key); + if (!model) { + model = blankModelAccumulator(provider, attribution.model, attribution.resolvedModel, ordinal, this.mode); + partition.models.set(key, model); + this.estimatedRetainedBytes += ESTIMATED_BREAKDOWN_BYTES + key.length * 2; + } + this.addAttributionMetrics(model, attribution, estimate); + return key; } - const sorted = providers.sort((a, b) => b.requests - a.requests); - for (const provider of sorted) delete provider.cacheObserved; - return sorted; -} -const LEGACY_AMBIGUOUS_ACCOUNT_LABEL = "legacy-ambiguous"; + private addProviderAttribution( + partition: UsagePartition, + attribution: UsageAttribution, + estimate: AttemptCostEstimate | CostEstimate | null, + ordinal: number, + ): string { + const providerKey = baseProviderLabel(attribution.provider); + const providers = partition.providers; + if (!providers) return providerKey; + let provider = providers.get(providerKey); + if (!provider) { + provider = blankModelAccumulator(providerKey, "", undefined, ordinal, "row-unique"); + providers.set(providerKey, provider); + this.estimatedRetainedBytes += ESTIMATED_BREAKDOWN_BYTES + providerKey.length * 2; + } + this.addAttributionMetrics(provider, attribution, estimate); + return providerKey; + } -function legacyCodexAccountLabel(provider: string): string | null { - if (baseProviderLabel(provider) !== "openai") return null; - const suffix = provider.match(/-(main|p[a-f0-9]{6})$/)?.[1]; - return suffix ?? LEGACY_AMBIGUOUS_ACCOUNT_LABEL; -} + private addBreakdownRequest( + breakdown: UsageModelAccumulator, + facts: number, + requestKey: number | null, + ): void { + if (breakdown.requestFacts) { + if (requestKey === null) throw new Error("exact accumulators require request identity"); + const previous = breakdown.requestFacts.get(requestKey); + breakdown.requestFacts.set(requestKey, (previous ?? 0) | facts); + if (previous === undefined) this.estimatedRetainedBytes += ESTIMATED_REQUEST_FACT_BYTES; + return; + } + bumpRequestCounts(breakdown.requestCounts, facts); + } -/** - * An explicitly stamped label of EITHER family is authoritative for any provider (#2699). - * - * No `o`-label branch is needed here: `isCodexUsageAccountLogLabel` now accepts both families, - * and adding a second predicate call would be a no-op guarded by a comment claiming otherwise. - * - * The legacy fallback stays openai-only on purpose. It infers an account from the PROVIDER - * string, and inferring for a non-Codex row would merge unrelated accounts under one label -- - * so an unlabeled xai row is dropped from the account table rather than guessed at. - */ -function accountLabelForAttribution(provider: string, explicit: unknown): string | null { - if (isCodexUsageAccountLogLabel(explicit)) return explicit; - return legacyCodexAccountLabel(provider); -} + private addAccountRequest(account: UsageAccountAccumulator, requestKey: number | null): void { + if (account.requestIds) { + if (requestKey === null) throw new Error("exact accumulators require request identity"); + const previousSize = account.requestIds.size; + account.requestIds.add(requestKey); + if (account.requestIds.size !== previousSize) { + this.estimatedRetainedBytes += ESTIMATED_ACCOUNT_REQUEST_BYTES; + } + return; + } + account.requests += 1; + } -function buildAccounts(entries: PersistedUsageEntry[], costMap: Map): UsageAccount[] { - const byLabel = new Map(); - const requestIds = new Map>(); - - const add = (input: { - requestId: string; - provider: string; - accountLogLabel?: string; - usageStatus: UsageStatus; - usage?: PersistedUsageEntry["usage"]; - totalTokens?: number; - estimate: AttemptCostEstimate | CostEstimate | null; - }): void => { - const label = accountLabelForAttribution(input.provider, input.accountLogLabel); - if (!label) return; - let row = byLabel.get(label); - if (!row) { - row = { + private addAccountAttribution( + partition: UsagePartition, + attribution: UsageAttribution, + estimate: AttemptCostEstimate | CostEstimate | null, + ordinal: number, + ): string | null { + const label = accountLabelForAttribution(attribution.provider, attribution.accountLogLabel); + if (!label) return null; + let account = partition.accounts.get(label); + if (!account) { + account = { accountLogLabel: label, ambiguous: label === LEGACY_AMBIGUOUS_ACCOUNT_LABEL, + firstSeen: ordinal, requests: 0, + ...(this.mode === "exact" ? { requestIds: new Set() } : {}), attemptCount: 0, measuredAttempts: 0, reportedAttempts: 0, @@ -1010,77 +1221,222 @@ function buildAccounts(entries: PersistedUsageEntry[], costMap: Map(); + const providerFacts = new Map(); + const accountLabels = new Set(); + for (let index = 0; index < attributions.length; index++) { + const attribution = attributions[index]!; + const estimate = entry.attempts?.length + ? costInfo.attemptEstimates?.[index] ?? null + : costInfo.estimate; + const ordinal = this.nextOrdinal++; + const facts = requestStatusFact(attribution.usageStatus) + | (estimate ? REQUEST_PRICED : REQUEST_UNPRICED); + const modelKey = this.addModelAttribution(partition, attribution, estimate, ordinal); + modelFacts.set(modelKey, (modelFacts.get(modelKey) ?? 0) | facts); + if (this.mode === "row-unique") { + const providerKey = this.addProviderAttribution(partition, attribution, estimate, ordinal); + providerFacts.set(providerKey, (providerFacts.get(providerKey) ?? 0) | facts); + } + const accountLabel = this.addAccountAttribution(partition, attribution, estimate, ordinal); + if (accountLabel) accountLabels.add(accountLabel); + } + for (const [modelKey, facts] of modelFacts) { + this.addBreakdownRequest(partition.models.get(modelKey)!, facts, requestKey); + } + if (partition.providers) { + for (const [providerKey, facts] of providerFacts) { + this.addBreakdownRequest(partition.providers.get(providerKey)!, facts, null); + } + } + for (const label of accountLabels) { + this.addAccountRequest(partition.accounts.get(label)!, requestKey); + } + if (this.mode === "row-unique" && modelFacts.size > 1) { + const models = [...modelFacts].sort(([a], [b]) => a.localeCompare(b)); + const signature = JSON.stringify(models); + const overlap = partition.modelOverlaps.get(signature); + if (overlap) { + overlap.count += 1; + } else { + partition.modelOverlaps.set(signature, { models, count: 1 }); + this.estimatedRetainedBytes += ESTIMATED_OVERLAP_BYTES + + models.length * ESTIMATED_OVERLAP_MODEL_BYTES + + signature.length * 2; } - continue; } - add({ - requestId: entry.requestId, - provider: entry.provider, - ...(entry.accountLogLabel ? { accountLogLabel: entry.accountLogLabel } : {}), - usageStatus: entry.usageStatus, - ...(entry.usage ? { usage: entry.usage } : {}), - ...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}), - estimate: costInfo?.estimate ?? null, - }); } - for (const row of byLabel.values()) { - row.usageCoverageRatio = row.attemptCount === 0 ? 0 : row.measuredAttempts / row.attemptCount; - row.priceCoverageRatio = row.measuredAttempts === 0 ? 0 : row.pricedAttempts / row.measuredAttempts; + summarize( + range: UsageRange, + now: number, + surface: UsageSurface = "all", + ): UsageSummary & { filter?: UsageFilterEcho } { + const { since, days: fixedDays } = rangeWindow(range, now); + const totals = blankTotals(); + const models = new Map(); + const providers = new Map(); + const accounts = new Map(); + const dayAccumulators = new Map(); + const modelOverlaps: UsageModelOverlap[] = []; + let oldestTimestamp: number | null = null; + + for (const partition of this.partitions.values()) { + if (!usageSurfaceMatches(partition.surface, surface)) continue; + if (since !== null && partition.dayStart < since) continue; + mergeTotals(totals, partition.totals); + mergeModelMaps(models, partition.models); + if (partition.providers) mergeModelMaps(providers, partition.providers); + modelOverlaps.push(...partition.modelOverlaps.values()); + for (const [label, account] of partition.accounts) { + const current = accounts.get(label); + if (current) mergeAccountAccumulator(current, account); + else accounts.set(label, cloneAccountAccumulator(account)); + } + if (partition.oldestTimestamp !== null) { + oldestTimestamp = oldestTimestamp === null + ? partition.oldestTimestamp + : Math.min(oldestTimestamp, partition.oldestTimestamp); + } + let day = dayAccumulators.get(partition.date); + if (!day) { + day = { totals: blankTotals(), models: new Map(), modelOverlaps: [] }; + dayAccumulators.set(partition.date, day); + } + mergeTotals(day.totals, partition.totals); + mergeModelMaps(day.models, partition.models); + day.modelOverlaps.push(...partition.modelOverlaps.values()); + } + finalizeCoverage(totals); + + const dayCount = range === "all" ? dayCountForAllRange(oldestTimestamp, now) : fixedDays; + const startOfToday = startOfLocalDay(now); + const firstVisibleDay = new Date(startOfToday); + firstVisibleDay.setDate(firstVisibleDay.getDate() - dayCount + 1); + const firstVisibleDate = localDateKey(firstVisibleDay.getTime()); + const lastVisibleDate = localDateKey(startOfToday); + for (let offset = dayCount - 1; offset >= 0; offset--) { + const date = new Date(startOfToday); + date.setDate(date.getDate() - offset); + const key = localDateKey(date.getTime()); + if (!dayAccumulators.has(key)) { + dayAccumulators.set(key, { totals: blankTotals(), models: new Map(), modelOverlaps: [] }); + } + } + const days = [...dayAccumulators] + // All-history totals, models, providers, and accounts still cover every + // retained row. Only the chart buckets are bounded so one malformed or + // ancient timestamp cannot synthesize an enormous JSON response. + .filter(([date]) => range !== "all" + || (date >= firstVisibleDate && date <= lastVisibleDate)) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, day]): UsageDay => ({ + date, + requests: day.totals.requests, + measuredRequests: day.totals.measuredRequests, + reportedRequests: day.totals.reportedRequests, + totalTokens: day.totals.totalTokens, + estimatedCostUsd: day.totals.estimatedCostUsd, + models: buildDayModels(day.models, day.modelOverlaps), + })); + + const summary: UsageSummary = { + range, + surface, + since, + generatedAt: now, + summary: totals, + days, + models: buildUsageModels(models, totals.totalTokens, modelOverlaps), + providers: buildUsageProviders(this.mode === "row-unique" ? providers : models, totals.totalTokens), + accounts: buildUsageAccounts(accounts), + }; + if (!this.filter) return summary; + const matches = (provider: string, model: string): boolean => + filterMatchesAttribution(this.filter!, provider, model); + const retainedModels = summary.models.filter(row => matches(row.provider, row.model)); + const retainedProviders = new Set(retainedModels.map(row => row.provider)); + return { + ...summary, + days: summary.days.map(day => ({ + ...day, + models: day.models.filter(row => matches(row.provider, row.model)), + })), + models: retainedModels, + providers: summary.providers.filter(row => retainedProviders.has(row.provider)), + accounts: this.filter.provider === null && this.filter.model === null + ? summary.accounts + : [], + filter: { + provider: this.filter.provider, + model: this.filter.model, + apiKeyId: this.filter.apiKeyId, + matched: summary.summary.requests > 0, + comboOverlap: this.comboOverlap, + }, + }; } - return [...byLabel.values()].sort((a, b) => b.totalTokens - a.totalTokens); +} + +export function createUsageSummaryAccumulator(options?: { + filter?: { provider?: string | null; model?: string | null; apiKeyId?: string | null }; + mode?: UsageAccumulatorMode; +}): UsageSummaryAccumulator { + return new StreamingUsageSummaryAccumulator(options); } export function summarizeUsage( @@ -1089,40 +1445,9 @@ export function summarizeUsage( now: number, surface: UsageSurface = "all", ): UsageSummary { - const { since } = rangeWindow(range, now); - const filteredEntries = entries.filter(entry => { - if (since !== null && entry.timestamp < since) return false; - if (surface === "claude") return entry.surface === "claude" || entry.surface === "claude-desktop"; - if (surface === "grok") return entry.surface === "grok"; - // Codex = the historical unlabelled bucket. Before the grok tag existed every - // non-Claude turn landed here, and `surface !== "claude"` also swallowed - // claude-desktop — disjoint predicates fix both. - if (surface === "codex") return entry.surface === undefined; - return true; - }); - const costMap = new Map(); - for (const entry of filteredEntries) { - costMap.set(entry, computeEntryCost(entry)); - } - const totals = blankTotals(); - for (const entry of filteredEntries) { - bumpStatus(totals, entry.usageStatus); - totals.attemptCount += entry.attempts?.length ?? 1; - addTokens(totals, entry); - addEstimatedCost(totals, entry, costMap.get(entry)!); - } - finalizeCoverage(totals); - return { - range, - surface, - since, - generatedAt: now, - summary: totals, - days: buildDayGrid(range, since, now, filteredEntries, costMap), - models: buildModels(filteredEntries, totals.totalTokens, costMap), - providers: buildProviders(filteredEntries, totals.totalTokens, costMap), - accounts: buildAccounts(filteredEntries, costMap), - }; + const accumulator = createUsageSummaryAccumulator(); + for (const entry of entries) accumulator.add(entry); + return accumulator.summarize(range, now, surface); } function normalizeFilterValue(input: string | null | undefined): string | null { @@ -1138,12 +1463,9 @@ function normalizeExactFilterValue(input: string | null | undefined): string | n /** * Narrow an already-summarised window to one provider and/or model. * - * Deliberately a projection over a finished summary rather than a parameter to - * {@link summarizeUsage}. The management route caches summaries under - * `range:surface` and warms that key space as a cross-product; a filtered - * summary that reached either would be served to the next UNFILTERED caller, - * the dashboard included. Keeping the filter outside the producer makes that - * mistake unrepresentable rather than merely discouraged. + * The compatibility wrapper feeds source rows through a filter-bound streaming + * accumulator. The management route can use the same accumulator directly and + * still keep filtered results outside its unfiltered `range:surface` cache. * * Totals are recomputed from the retained rows. For combo traffic a request is * counted once per participating model, so a filtered request count can exceed @@ -1165,78 +1487,16 @@ export function projectUsageSummary( const model = normalizeFilterValue(filter.model); const apiKeyId = normalizeExactFilterValue(filter.apiKeyId); if (provider === null && model === null && apiKeyId === null) return summary; - - // Re-summarise from the entries the summary was built from, rather than - // projecting over its rows. - // - // Projecting rows looked cheaper and was wrong in three ways that only show - // up together: breakdown rows past MAX_USAGE_MODEL_BREAKDOWN_ROWS are - // collapsed into a synthetic "other" row, so a provider living only in that - // tail is unfindable and reports matched:false despite real usage; a - // provider row is a whole-provider aggregate, so a model filter kept the - // provider's OTHER models in providers[] while models[] and the totals - // excluded them, contradicting itself inside one response; and a model row - // carries a single optional cost, so priced/unpriced/unmetered counts could - // only be guessed per model rather than counted per request. - // - // Key ownership is the outer slice: no provider/model attribution or bucket - // construction may observe rows belonging to another client key. - const keyFilteredEntries = apiKeyId === null - ? entries ?? [] - : (entries ?? []).filter(entry => entry.apiKeyId === apiKeyId); - - // The entries are already in hand on every path that filters, so the honest - // computation is also the simple one. - const matches = (rowProvider: string, rowModel: string): boolean => { - if (provider !== null && baseProviderLabel(rowProvider).toLowerCase() !== provider) return false; - if (model !== null && rowModel.toLowerCase() !== model) return false; - return true; - }; - - // Narrow to matching ATTRIBUTIONS, not matching entries. - // - // Keeping a whole combo entry because one of its attempts matched drags the - // other attempts' tokens and cost into the filtered totals: a two-attempt - // combo filtered to its cheap model reported the expensive model's spend - // too. Rewriting the entry down to its matching attempts is what makes the - // filtered numbers mean what the flag says. - let comboOverlap = false; - const filtered: PersistedUsageEntry[] = []; - for (const entry of keyFilteredEntries) { - if (!entry.attempts?.length) { - const identity = usageModelIdentity(entry.provider, entry.model, entry.resolvedModel); - if (matches(entry.provider, identity.model)) filtered.push(entry); - continue; - } - const attempts = entry.attempts.filter(a => { - const identity = usageModelIdentity(a.provider, a.model); - return matches(a.provider, identity.model); - }); - if (attempts.length === 0) continue; - if (entry.attempts.length > 1) comboOverlap = true; - const { usage: _parentUsage, totalTokens: _parentTotalTokens, ...withoutParentUsage } = entry; - filtered.push({ ...withoutParentUsage, attempts, ...projectedComboUsage(attempts) }); - } - - const projected = summarizeUsage(filtered, summary.range, summary.generatedAt, summary.surface); - const matched = projected.summary.requests > 0; - const models = projected.models.filter(row => matches(row.provider, row.model)); - const retainedProviders = new Set(models.map(row => row.provider)); + const accumulator = createUsageSummaryAccumulator({ filter: { provider, model, apiKeyId } }); + for (const entry of entries ?? []) accumulator.add(entry); + const projected = accumulator.summarize(summary.range, summary.generatedAt, summary.surface); return { ...summary, summary: projected.summary, - days: projected.days.map(day => ({ ...day, models: day.models.filter(row => matches(row.provider, row.model)) })), - models, - providers: projected.providers.filter(row => retainedProviders.has(row.provider)), - // Account rows are not provider-partitioned in a way this projection could - // honestly re-derive, and unfiltered account totals sitting beside filtered - // model totals would invite exactly the wrong reading — so a provider or model - // filter drops them. - // - // An apiKeyId-only filter is different: it selects whole entries, so the account - // rows projected from those entries are exactly the accounts that key used. They - // are honest under that filter and are kept. - accounts: provider === null && model === null ? projected.accounts : [], - filter: { provider, model, apiKeyId, matched, comboOverlap }, + days: projected.days, + models: projected.models, + providers: projected.providers, + accounts: projected.accounts, + filter: projected.filter, }; } diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index ff75b15e88..725fbdcd5e 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -128,7 +128,7 @@ this document owns is which module holds which area and what invariant that area | Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | | V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. | | Logs & Debug | One sidebar entry (`/#logs`) with two tabs. Logs tab: request/runtime logs for local diagnosis. Debug tab (`/#logs/debug`; legacy `/#debug` deep links redirect there): provider + usage toggles, refresh/follow log viewer. `GET/PUT /api/debug`; `GET /api/debug/logs` and `GET /api/debug/usage-logs` (monotonic `after` cursor, legacy `since` accepted). CLI: `ocx debug provider|usage …` (both streams via running proxy API). | -| Usage | `GET /api/usage` aggregate read-only summary derived from `~/.opencodex/usage.jsonl`; measured / reported / unreported / unsupported / estimated counts, daily zero-filled grid, model and provider breakdowns. Never exposes prompts. | +| Usage | `GET /api/usage` aggregate read-only summary derived from the complete `~/.opencodex/usage.jsonl`; the ledger is streamed in fixed 1 MiB chunks, so the former read-byte and parsed-row caps cannot omit its prefix. The response includes measured / reported / unreported / unsupported / estimated counts, a daily zero-filled grid, and model and provider breakdowns. Never exposes prompts. | | System | `POST /api/system/restart` restarts the proxy in place. Local CLI/tray callers first attest the exact runtime PID and port, then send a process-scoped HMAC capability bound to that method, path, PID, and port; the capability authorizes no other management route and is invalid after replacement. The caller observes one absolute deadline and accepts success only after a different runtime PID is healthy on the same port. `GET /api/system/health` is the authenticated scalar-only identity used by shared-plane Dashboard status and restart reconnect polling; it does not widen a Remote Hub management ingress to unauthenticated `/healthz`. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision, optional `bunRuntimeSource` provenance, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + eager-relay gate decision, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. Scalar-only payload; dashboard/admin callers use the standard management gate, while `ocx doctor` may use only the exact process-scoped local-read capability. It must never move to unauthenticated `/healthz`. | | Stop | `POST /api/stop` — restore native Codex, stop any installed service, and exit the proxy. | | Diagnostics/sync | `src/server/management/config-routes.ts` — `GET /api/diagnostics/project-config` reports project-level Codex config that bypasses managed routing; `POST /api/sync` re-runs catalog/config sync. The diagnostic reports the bypass; it does not rewrite the project file. | @@ -339,6 +339,12 @@ An opt-in shadow-call rewrite persists the bounded, redacted original helper mod request content or inferring a helper subtype from timing. `src/usage/summary.ts` turns that file into the `/api/usage` shape — totals, daily zero-filled grid, model and provider breakdowns, and `measured / reported / unreported / unsupported / estimated` counts. +The management route streams the complete ledger from its beginning in fixed 1 MiB chunks on a +cold rebuild, then retains compact numeric aggregate state and resumes at the last verified LF for +ordinary appends. It does not retain the full input or a normalized object for every request, and +neither the old byte window nor the parsed-entry cap can discard an earlier prefix before range and +surface filtering. `managementUsageMaxReadBytes` remains a recognized compatibility setting for +bounded legacy readers, but it is not an accuracy limit or tuning knob for `GET /api/usage`. A Codex-surface response also includes an `accounts` breakdown keyed by the stable non-PII `accountLogLabel`; current cards join those rows to the management account DTO and show the 30-day token total, API-equivalent cost estimate, and measurement coverage. New main-pool rows use `main`, @@ -350,20 +356,35 @@ estimated` split exists for, and why coverage is reported alongside totals. The main Dashboard surfaces a 30d token / coverage summary. The in-memory `requestLog` is capped at 200 entries and is **not** the source of truth for aggregation — the JSONL on disk is. -The management API caches only the compact summary for an exact file revision and query; it never -retains normalized per-request rows after a response. The cache invalidates on any identity, size, or -timestamp change and at the next range expiry or local-day boundary. Rebuilds parse in bounded -batches and yield between them, so unrelated management requests remain serviceable even for a large -existing log. The Dashboard polls its 30-day usage summary independently once per minute, so usage -work cannot delay health/provider/settings state or run every five seconds. +The management API retains the compact accumulator plus bounded query summaries; it never retains +normalized per-request rows after a response. File identity changes, shrinkage, same-size metadata +changes, pricing-overlay changes, and local-time-zone changes force a cold rebuild. Ordinary growth +is treated as an append: the scanner verifies the previous LF and its trailing 64 KiB digest, then +folds only the suffix into a cloned accumulator and publishes it after validation. Concurrent callers +share that work. Cold rebuilds scan the whole ledger in fixed-size chunks and yield between bounded +batches, so memory stays bounded and unrelated management requests remain serviceable even for a +large existing log. The first read is proportional to ledger size; steady-state refresh work is +proportional to newly appended bytes. The Dashboard polls its 30-day usage summary independently once +per minute, so usage work cannot delay health/provider/settings state or run every five seconds. + +`usage.jsonl` is an append-only runtime ledger. A manual in-place edit earlier than the trailing +64 KiB checkpoint followed by file growth is intentionally outside the incremental detector's +contract: validating arbitrary historical rewrites on every refresh would require rereading the +whole prefix. Replace or truncate the file, or restart the proxy, after manually changing historical +rows so the next request performs a cold rebuild. + +The wire fields `historyTruncated`, `truncatedPrefixBytes`, `entriesTruncated`, and `entriesDropped` +remain in the response for compatibility with older GUI and CLI clients. A successful whole-ledger +scan reports `false`, `0`, `false`, and `0`; clients must not interpret those fields as evidence that +`managementUsageMaxReadBytes` was raised or that a bounded tail was selected. [Decision Log] - 목적과 의도: Keep dashboard and management requests responsive as `usage.jsonl` grows. -- 기존 구현 및 제약 조건: The JSONL file remains the durable source of truth and may be truncated, replaced, or hand-edited. -- 검토한 주요 대안: Retain normalized rows, maintain a second database, or cache only revision-keyed summaries and cooperatively rebuild them. -- 선택한 방식: Keep only bounded summary results, share full reads by exact file identity, yield during parsing, and poll usage separately at a slower cadence. -- 다른 대안 대신 이 방식을 선택한 이유: It bounds resident heap and avoids a second persistence format while keeping unrelated endpoints responsive. -- 장점, 단점 및 영향: Unchanged queries are cheap and memory stays bounded; a changed large log still consumes rebuild CPU, but cooperatively and at most once per observed revision/query. +- 기존 구현 및 제약 조건: The append-only JSONL file remains the durable source of truth and may be truncated or replaced. A tail-only byte/row bound kept memory finite but made historical totals incomplete on busy installations; arbitrary in-place historical edits cannot be detected without rereading the prefix. +- 검토한 주요 대안: Raise the byte/row caps, retain normalized rows, maintain a second database, or stream the complete ledger into compact accumulators and cache only revision-keyed summaries. +- 선택한 방식: Stream the complete ledger in fixed 1 MiB chunks for a cold rebuild, retain only compact aggregate state plus an LF/digest checkpoint, fold verified append suffixes atomically, share concurrent work, yield during parsing, and poll usage separately at a slower cadence. +- 다른 대안 대신 이 방식을 선택한 이유: It restores complete historical aggregation without making correctness depend on an operator-sized read limit, retaining every parsed row, or introducing a second persistence format. +- 장점, 단점 및 영향: Unchanged queries are cheap, normal refreshes read only appended bytes, and memory stays bounded. Cold starts and explicit invalidations still consume file-size-proportional IO/CPU. A same-inode historical rewrite outside the trailing checkpoint requires replacement, truncation, or restart to force that cold rebuild. For diagnosing upstream-shape / usage-extraction issues run `ocx debug usage on` (or set `OPENCODEX_USAGE_DEBUG=1` before start). The proxy then writes a rolling debug record per finalized diff --git a/tests/api-key-attribution.test.ts b/tests/api-key-attribution.test.ts index 9eb95487cd..4630e21f0f 100644 --- a/tests/api-key-attribution.test.ts +++ b/tests/api-key-attribution.test.ts @@ -1,11 +1,13 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync} from "node:fs"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../src/config"; import { startServer } from "../src/server"; import { AUTH_MATRIX } from "../src/server/auth-cors"; -import { clearApiKeyUsageCacheForTests, rollupApiKeyUsage } from "../src/server/management/api-key-usage"; +import { clearApiKeyUsageCacheForTests, readApiKeyUsageRollup, rollupApiKeyUsage } from "../src/server/management/api-key-usage"; +import { resetUsageAggregateCacheForTests } from "../src/server/management/usage-aggregate-cache"; +import * as usageLedgerScannerModule from "../src/usage/ledger-scanner"; import { normalizeUsageEntryForTest, usageLogPath, type PersistedUsageEntry } from "../src/usage/log"; import type { OcxConfig } from "../src/types"; import { removeTreeWithRetry } from "./helpers/remove-tree"; @@ -52,9 +54,11 @@ beforeEach(() => { delete process.env.OPENCODEX_API_AUTH_TOKEN; process.env.OPENCODEX_ADMIN_AUTH_TOKEN = ADMIN_TOKEN; clearApiKeyUsageCacheForTests(); + resetUsageAggregateCacheForTests(); }); afterEach(() => { + resetUsageAggregateCacheForTests(); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; if (previousDataToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; @@ -316,6 +320,67 @@ describe("attribution reaches usage.jsonl", () => { } }); + test("/api/usage seeds a complete API-key rollup beyond the former byte limit", async () => { + const now = Date.now(); + const config = remoteConfig(); + config.managementUsageMaxReadBytes = 256; + saveConfig(config); + const rows = [ + ...Array.from({ length: 20 }, (_, index) => ({ + requestId: `key-one-${index}`, + timestamp: now - index, + provider: "test", + model: "gpt-test", + status: 200, + durationMs: 1, + usageStatus: "reported", + admissionKind: "configured", + apiKeyId: "key-one", + usage: { inputTokens: 1, outputTokens: 1 }, + totalTokens: 2, + })), + { + requestId: "key-two-tail", + timestamp: now, + provider: "test", + model: "gpt-test", + status: 200, + durationMs: 1, + usageStatus: "reported", + admissionKind: "configured", + apiKeyId: "key-two", + usage: { inputTokens: 1, outputTokens: 1 }, + totalTokens: 2, + }, + ]; + writeFileSync(usageLogPath(), `${rows.map(row => JSON.stringify(row)).join("\n")}\n`); + + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; + let scans = 0; + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively").mockImplementation(async options => { + scans += 1; + return originalScan(options); + }); + const server = startServer(0); + try { + const usage = await fetch(new URL("/api/usage?range=all", server.url), { + headers: { "x-opencodex-api-key": ADMIN_TOKEN }, + }).then(response => response.json()) as Record; + expect(usage.historyTruncated).toBe(false); + expect(scans).toBe(1); + + const payload = await keysGet(server); + const keys = payload.keys as Array>; + expect((keys.find(key => key.id === "key-one")!.usage as Record).totalRequests).toBe(20); + expect((keys.find(key => key.id === "key-two")!.usage as Record).totalRequests).toBe(1); + expect(payload.historyTruncated).toBeUndefined(); + expect(scans).toBe(1); + } finally { + scanSpy.mockRestore(); + await server.stop(true); + } + }); + test("an unreadable usage snapshot degrades to zeroes, not a failed route", async () => { saveConfig(remoteConfig()); const server = startServer(0); @@ -334,6 +399,45 @@ describe("attribution reaches usage.jsonl", () => { } }); + test("an oversized usage row cannot seed a partial key rollup", async () => { + saveConfig(remoteConfig()); + const now = Date.now(); + const oversized = { + requestId: "oversized-key-one", + timestamp: now, + provider: "test", + model: "gpt-test", + status: 200, + durationMs: 1, + usageStatus: "reported", + admissionKind: "configured", + apiKeyId: "key-one", + padding: "x".repeat(usageLedgerScannerModule.USAGE_LEDGER_MAX_LINE_BYTES), + }; + const valid = { + requestId: "valid-key-two", + timestamp: now, + provider: "test", + model: "gpt-test", + status: 200, + durationMs: 1, + usageStatus: "reported", + admissionKind: "configured", + apiKeyId: "key-two", + }; + writeFileSync(usageLogPath(), `${JSON.stringify(oversized)}\n${JSON.stringify(valid)}\n`); + const server = startServer(0); + try { + const payload = await keysGet(server); + const keys = payload.keys as Array>; + expect((keys.find(key => key.id === "key-one")!.usage as Record).totalRequests).toBe(0); + expect((keys.find(key => key.id === "key-two")!.usage as Record).totalRequests).toBe(0); + expect(payload.attributionSince).toBeUndefined(); + } finally { + await server.stop(true); + } + }); + test("a long key id survives the round trip intact", async () => { const config = remoteConfig(); const longId = "k".repeat(80); @@ -455,6 +559,34 @@ describe("rollupApiKeyUsage", () => { const { attributionSince } = rollupApiKeyUsage([row({})], ["k"], now); expect(attributionSince).toBeUndefined(); }); + + test("concurrent cache misses singleflight only within the same configured-id key", async () => { + const persisted = row({ admissionKind: "configured", apiKeyId: "key-one" }); + writeFileSync(usageLogPath(), `${JSON.stringify(persisted)}\n`); + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; + let scans = 0; + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively").mockImplementation(async options => { + scans += 1; + return originalScan(options); + }); + try { + await Promise.all([ + readApiKeyUsageRollup(["key-one"], 256), + readApiKeyUsageRollup(["key-one"], 256), + ]); + expect(scans).toBe(1); + + clearApiKeyUsageCacheForTests(); + scans = 0; + await Promise.all([ + readApiKeyUsageRollup(["key-one"], 256), + readApiKeyUsageRollup(["key-two"], 256), + ]); + expect(scans).toBe(2); + } finally { + scanSpy.mockRestore(); + } + }); }); describe("durable compatibility", () => { diff --git a/tests/api-usage.test.ts b/tests/api-usage.test.ts index ed458fab9d..e379e52926 100644 --- a/tests/api-usage.test.ts +++ b/tests/api-usage.test.ts @@ -12,7 +12,9 @@ import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isol import { removeTreeWithRetry } from "./helpers/remove-tree"; import { resetUsageReadCacheForTests, setManagementUsageMaxEntriesForTests, usageReadCacheStatsForTests } from "../src/usage/log"; import * as usageLogModule from "../src/usage/log"; +import * as usageLedgerScannerModule from "../src/usage/ledger-scanner"; import { getUsageSummaryCacheEntry, resetUsageSummaryCacheForTests } from "../src/server/management/usage-summary-cache"; +import * as usageAggregateCacheModule from "../src/server/management/usage-aggregate-cache"; let testDir = ""; let previousHome: string | undefined; @@ -76,7 +78,8 @@ beforeEach(() => { isolatedCodexHome = installIsolatedCodexHome("ocx-api-usage-codex-"); testDir = mkdtempSync(join(tmpdir(), "ocx-api-usage-")); process.env.OPENCODEX_HOME = testDir; - resetUsageReadCacheForTests(); + resetUsageSummaryCacheForTests(); + usageAggregateCacheModule.resetUsageAggregateCacheForTests(); // The overlay registry is MODULE-level state that outlives a test file, and // this file asserts on `userCostOverlayVersion()` moving. A preserved // disk-only provider left behind by an earlier test — or by an earlier file in @@ -95,6 +98,7 @@ afterEach(() => { // wedged shutdown on Linux CI must not leave the 5s poll timer keeping the // isolate worker alive for later shard files (e.g. cli-restore-back). stopUserCostOverlayReconciler(); + usageAggregateCacheModule.resetUsageAggregateCacheForTests(); // Leave no overlay state for the next file, for the same reason. resetPreservedDiskOnlyProvidersForTests(); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; @@ -105,6 +109,54 @@ afterEach(() => { }); describe("GET /api/usage", () => { + test("concurrent cold requests share one base-ledger scan", async () => { + writeFixture(Date.now()); + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; + const originalGetAggregate = usageAggregateCacheModule.getUsageAggregate; + let releaseScan!: () => void; + const scanGate = new Promise(resolve => { releaseScan = resolve; }); + let scannerEntered!: () => void; + const scannerStarted = new Promise(resolve => { scannerEntered = resolve; }); + let aggregateCalls = 0; + let secondAggregateCall!: () => void; + const bothRequestsEntered = new Promise(resolve => { secondAggregateCall = resolve; }); + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively") + .mockImplementation(async options => { + scannerEntered(); + await scanGate; + return originalScan(options); + }); + const aggregateSpy = spyOn(usageAggregateCacheModule, "getUsageAggregate") + .mockImplementation(options => { + aggregateCalls += 1; + if (aggregateCalls === 2) secondAggregateCall(); + return originalGetAggregate(options); + }); + const server = startServer(0); + try { + const first = fetch(new URL("/api/usage?range=30d", server.url)); + await scannerStarted; + const second = fetch(new URL("/api/usage?range=7d", server.url)); + await bothRequestsEntered; + expect(aggregateCalls).toBe(2); + expect(scanSpy).toHaveBeenCalledTimes(1); + releaseScan(); + + const [firstBody, secondBody] = await Promise.all([ + first.then(response => response.json()), + second.then(response => response.json()), + ]); + expect(firstBody.summary.requests).toBe(3); + expect(secondBody.summary.requests).toBe(2); + expect(scanSpy).toHaveBeenCalledTimes(1); + } finally { + releaseScan(); + aggregateSpy.mockRestore(); + scanSpy.mockRestore(); + await server.stop(true); + } + }); + test("returns documented shape with summary, days, models, providers, and accounts", async () => { writeFixture(Date.now()); const server = startServer(0); @@ -129,60 +181,54 @@ describe("GET /api/usage", () => { } }); - test("usage route cache preserves truncation metadata and invalidates when configured byte limit changes", async () => { - writeFixture(Date.now()); + test("a former byte limit no longer drops history and complete metadata is cached", async () => { + const now = Date.now(); + writeFixture(now); saveConfig({ ...baseConfig(), managementUsageMaxReadBytes: 256 }); const server = startServer(0); try { const first = await fetch(new URL("/api/usage?range=all", server.url)).then(response => response.json()); const second = await fetch(new URL("/api/usage?range=all", server.url)).then(response => response.json()); - expect(first.historyTruncated).toBe(true); - expect(first.truncatedPrefixBytes).toBeGreaterThan(0); + expect(first.summary).toMatchObject({ requests: 3, totalTokens: 165 }); expect(second).toMatchObject({ - historyTruncated: first.historyTruncated, - truncatedPrefixBytes: first.truncatedPrefixBytes, - entriesTruncated: first.entriesTruncated, - entriesDropped: first.entriesDropped, + historyTruncated: false, + truncatedPrefixBytes: 0, + entriesTruncated: false, + entriesDropped: 0, + snapshotWindowStart: now - 10 * 86_400_000, + snapshotWindowEnd: now - 1 * 86_400_000, }); + expect(getUsageSummaryCacheEntry("all:all")?.summary.summary.requests).toBe(3); } finally { await server.stop(true); } }); - // #1497: on a busy installation the newest `managementUsageMaxReadBytes` can cover far less - // than the selected range, so `30d` and "Available history" summarize the same moving tail. - // The response now names the window the reader actually loaded. It describes the READ, not - // the query — usage.jsonl is appended on request completion while rows carry the request - // start time, so the oldest loaded row does not bound what the dropped prefix contains, and - // no field here may be read as a completeness claim. + // #1497: the scanner reads every complete row while retaining only aggregate + // state, so the response window now spans the complete valid ledger rather + // than a bounded tail. describe("snapshot window disclosure (#1497)", () => { - test("a truncated read reports the loaded window, and it matches the rows that survived", async () => { + test("a former tail-sized read reports the complete fixture window", async () => { const now = Date.now(); writeFixture(now); saveConfig({ ...baseConfig(), managementUsageMaxReadBytes: 256 }); const server = startServer(0); try { const body = await fetch(new URL("/api/usage?range=30d", server.url)).then(r => r.json()); - expect(body.historyTruncated).toBe(true); - expect(typeof body.snapshotWindowStart).toBe("number"); - expect(typeof body.snapshotWindowEnd).toBe("number"); - expect(body.snapshotWindowStart).toBeLessThanOrEqual(body.snapshotWindowEnd); - // The dropped prefix is the OLDEST part of the file, so a truncated read cannot still - // start at the fixture's oldest row. - expect(body.snapshotWindowStart).toBeGreaterThan(now - 10 * 86_400_000); + expect(body.historyTruncated).toBe(false); + expect(body.truncatedPrefixBytes).toBe(0); + expect(body.summary.requests).toBe(3); + expect(body.snapshotWindowStart).toBe(now - 10 * 86_400_000); + expect(body.snapshotWindowEnd).toBe(now - 1 * 86_400_000); } finally { await server.stop(true); } }); - test("the window describes the read, so range and surface filters do not move it", async () => { - // A tail small enough to truncate but large enough to retain rows the filters will - // actually discard. Retaining a single row would make every filter a no-op and the - // assertions vacuous, which is exactly what an earlier version of this test did. + test("the complete window is independent of range and surface filters", async () => { const now = Date.now(); const oldest = now - 200 * 86_400_000; const rows = [ - // Dropped by the byte limit: only here to make the read truncated. ...Array.from({ length: 40 }, (_, i) => ({ requestId: `ocx-prefix-${i}`, timestamp: oldest, @@ -194,7 +240,7 @@ describe("GET /api/usage", () => { usage: { inputTokens: 1, outputTokens: 1 }, totalTokens: 2, })), - // Retained, and deliberately outside a 30d window so the range filter discards it. + // Outside a 30d window, so only the range filter discards it. { requestId: "ocx-window-old", timestamp: now - 90 * 86_400_000, @@ -206,7 +252,7 @@ describe("GET /api/usage", () => { usage: { inputTokens: 10, outputTokens: 5 }, totalTokens: 15, }, - // Retained and inside 30d, but a Codex surface so the claude filter discards it. + // Inside 30d, but a Codex surface so the claude filter discards it. { requestId: "ocx-window-codex", timestamp: now - 2 * 86_400_000, @@ -218,7 +264,7 @@ describe("GET /api/usage", () => { usage: { inputTokens: 10, outputTokens: 5 }, totalTokens: 15, }, - // Retained, inside 30d, and a claude surface: survives every filter. + // Inside 30d and on the Claude surface. { requestId: "ocx-window-claude", timestamp: now - 1 * 86_400_000, @@ -233,7 +279,6 @@ describe("GET /api/usage", () => { }, ]; writeFileSync(join(testDir, "usage.jsonl"), `${rows.map(r => JSON.stringify(r)).join("\n")}\n`); - // Sized to keep the last three rows and drop the 40-row prefix. const tailBytes = rows.slice(-3).reduce((sum, r) => sum + Buffer.byteLength(`${JSON.stringify(r)}\n`), 0); saveConfig({ ...baseConfig(), managementUsageMaxReadBytes: tailBytes + 8 }); const server = startServer(0); @@ -242,14 +287,12 @@ describe("GET /api/usage", () => { const thirty = await fetch(new URL("/api/usage?range=30d", server.url)).then(r => r.json()); const claude = await fetch(new URL("/api/usage?range=all&surface=claude", server.url)).then(r => r.json()); - expect(all.historyTruncated).toBe(true); - // The retained set really is what the filters will cut down. - expect(all.summary.requests).toBe(3); + expect(all.historyTruncated).toBe(false); + expect(all.summary.requests).toBe(43); expect(thirty.summary.requests).toBe(2); expect(claude.summary.requests).toBe(1); - // Exact bounds, computed independently of the reader. - expect(all.snapshotWindowStart).toBe(now - 90 * 86_400_000); + expect(all.snapshotWindowStart).toBe(oldest); expect(all.snapshotWindowEnd).toBe(now - 1 * 86_400_000); for (const body of [thirty, claude]) { @@ -296,10 +339,8 @@ describe("GET /api/usage", () => { const server = startServer(0); try { const first = await fetch(new URL("/api/usage?range=all", server.url)).then(r => r.json()); + expect(getUsageSummaryCacheEntry("all:all")).toBeDefined(); const second = await fetch(new URL("/api/usage?range=all", server.url)).then(r => r.json()); - // Prove the second response is a cache hit rather than a second full read; otherwise - // this asserts nothing about the cache path. - expect(usageReadCacheStatsForTests().fullReads).toBe(1); expect(typeof first.snapshotWindowStart).toBe("number"); expect(typeof first.snapshotWindowEnd).toBe("number"); expect(second.snapshotWindowStart).toBe(first.snapshotWindowStart); @@ -312,12 +353,18 @@ describe("GET /api/usage", () => { test("reuses only a compact summary for an unchanged revision", async () => { writeFixture(Date.now()); + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; + const scanStarts: number[] = []; + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively").mockImplementation(async options => { + scanStarts.push(options.startAtBytes ?? 0); + return originalScan(options); + }); const server = startServer(0); try { const first = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); const second = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); expect(second.summary).toEqual(first.summary); - expect(usageReadCacheStatsForTests().fullReads).toBe(1); + expect(getUsageSummaryCacheEntry("30d:all")?.summary.summary).toEqual(first.summary); appendFileSync(join(testDir, "usage.jsonl"), `${JSON.stringify({ requestId: "ocx-appended", @@ -332,21 +379,20 @@ describe("GET /api/usage", () => { })}\n`); const stale = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); expect(stale.summary.requests).toBe(first.summary.requests); - expect(usageReadCacheStatsForTests().fullReads).toBe(1); const originalNow = Date.now(); const clock = spyOn(Date, "now").mockReturnValue(originalNow + 60_001); try { const changed = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); expect(changed.summary.requests).toBe(first.summary.requests + 1); - // The append is picked up by extending the retained tail, so the whole 64 MiB - // window is NOT reparsed: a second full read here is the regression this guards. - expect(usageReadCacheStatsForTests().fullReads).toBe(1); - expect(usageReadCacheStatsForTests().tailReads).toBeGreaterThan(0); + expect(scanStarts).toHaveLength(2); + expect(scanStarts[0]).toBe(0); + expect(scanStarts[1]).toBeGreaterThan(0); } finally { clock.mockRestore(); } } finally { + scanSpy.mockRestore(); await server.stop(true); } }); @@ -363,7 +409,7 @@ describe("GET /api/usage", () => { const first = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); const second = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); expect(second.summary).toEqual(first.summary); - expect(usageReadCacheStatsForTests().fullReads).toBe(1); + const cachedOverlayVersion = getUsageSummaryCacheEntry("30d:all")?.overlayVersion ?? -1; // A modelCosts save refreshes the overlay registry and bumps its version; // the cached summary must not be reused even though the usage log is unchanged. refreshUserCostOverlays({ @@ -377,9 +423,7 @@ describe("GET /api/usage", () => { } as unknown as OcxConfig); const changed = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); expect(changed.summary.requests).toBe(first.summary.requests); - // The ledger did not change, so the recompute reuses the retained tail rather - // than reparsing the window; only the summary cache is invalidated. - expect(usageReadCacheStatsForTests().fullReads).toBe(1); + expect(getUsageSummaryCacheEntry("30d:all")?.overlayVersion).toBeGreaterThan(cachedOverlayVersion); } finally { // This test installs a module-level blsc overlay; clear it even when an // assertion or shutdown fails so later tests cannot resolve @@ -389,21 +433,44 @@ describe("GET /api/usage", () => { } }); - test("usage route does not cache a summary whose overlay version changed mid-read", async () => { + test("usage route cache invalidates when the local calendar time zone changes", async () => { + const previousTimeZone = process.env.TZ; + process.env.TZ = "UTC"; + writeFixture(Date.now()); + const server = startServer(0); + try { + await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); + expect(getUsageSummaryCacheEntry("30d:all")?.timeZone).toBe("UTC"); + + process.env.TZ = "America/Los_Angeles"; + await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); + expect(getUsageSummaryCacheEntry("30d:all")?.timeZone).toBe("America/Los_Angeles"); + } finally { + if (previousTimeZone === undefined) delete process.env.TZ; + else process.env.TZ = previousTimeZone; + await server.stop(true); + } + }); + + test("usage route retries an overlay change and caches only the settled rebuild", async () => { writeFixture(Date.now()); refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); resetUsageSummaryCacheForTests(); const versionBefore = userCostOverlayVersion(); - // Deterministically bump the overlay version DURING the snapshot read, so + // Deterministically bump the overlay version DURING the ledger scan, so // the summary is computed under a version that is stale before the cache // stamp — the interleaving that previously stamped an old-price summary as // current. The spy must be installed before the first /api/usage request: // a warm request would be served from the summary cache and never reach // the read. - const originalRead = usageLogModule.readUsageSnapshotForManagement; + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; let bumped = false; - const spy = spyOn(usageLogModule, "readUsageSnapshotForManagement").mockImplementation(async (maxReadBytes?: number) => { - const snapshot = await originalRead(maxReadBytes); + let scans = 0; + const scanOverlayVersions: number[] = []; + const spy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively").mockImplementation(async options => { + scans += 1; + scanOverlayVersions.push(userCostOverlayVersion()); + const snapshot = await originalScan(options); if (!bumped) { bumped = true; refreshUserCostOverlays({ @@ -423,26 +490,24 @@ describe("GET /api/usage", () => { const raced = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); expect(bumped).toBe(true); expect(userCostOverlayVersion()).toBeGreaterThan(versionBefore); - // The mid-read change must NOT leave a cache entry: the mixed-price - // summary is served uncached so the next request recomputes. - expect(getUsageSummaryCacheEntry("30d:all")).toBeUndefined(); + // The retained rebuild detects the changed pricing input and retries the + // full scan before publishing. No mixed-version aggregate is visible; + // the one route response and its cache entry both come from the settled + // second scan. + expect(scans).toBe(2); + expect(getUsageSummaryCacheEntry("30d:all")?.overlayVersion) + .toBe(scanOverlayVersions[1]); spy.mockRestore(); - // Once the overlay is settled, the next request recomputes and caches - // under the new version. - // - // Capture the version the settled request will price under BEFORE issuing - // it. The live counter is not a stable oracle here: the server's own - // overlay reconciler refreshes the registry on its poll, so re-reading it - // after the response can observe a later version than the one the summary - // was computed with. The contract under test is "the cache is stamped with - // the version its summary was priced under", not "the counter never moves - // again" — asserting the latter made this test fail on any machine where a - // poll landed inside the request. - const settledVersion = userCostOverlayVersion(); + // The process-global overlay may move again after the response (for + // example when the config poller reloads disk). That cannot retroactively + // change the version the settled scan used; the next request must either + // reuse that exact version or rebuild under a newer one. + const nextRequestVersion = userCostOverlayVersion(); const settled = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); - expect(settled.summary.requests).toBe(raced.summary.requests); - expect(getUsageSummaryCacheEntry("30d:all")?.overlayVersion).toBeGreaterThanOrEqual(settledVersion); + expect(settled.summary).toEqual(raced.summary); + expect(getUsageSummaryCacheEntry("30d:all")?.overlayVersion) + .toBeGreaterThanOrEqual(nextRequestVersion); } finally { spy.mockRestore(); // Clear the module-level overlay and summary cache even when an @@ -556,6 +621,20 @@ describe("GET /api/usage", () => { } }); + test("a model filter remains active when the provider parameter is empty", async () => { + writeFixture(Date.now()); + const server = startServer(0); + try { + const body = await fetch(new URL("/api/usage?range=all&provider=&model=gpt-5.5", server.url)).then(res => res.json()); + expect(body.filter).toMatchObject({ provider: null, model: "gpt-5.5", matched: true }); + expect(body.summary.requests).toBe(2); + expect(body.models.every((row: { model: string }) => row.model === "gpt-5.5")).toBe(true); + expect(body.accounts).toEqual([]); + } finally { + await server.stop(true); + } + }); + test("a filter that matches nothing reports an empty window, not the unfiltered one", async () => { writeFixture(Date.now()); const server = startServer(0); @@ -576,10 +655,8 @@ describe("GET /api/usage", () => { writeFixture(Date.now()); const server = startServer(0); try { - // The cache key is `range:surface` and the warm loop writes every key on - // a miss. If the filter reached the producer, this filtered request would - // store a narrowed summary under "all:all" and the dashboard would then - // be served one provider's totals as the whole window. + // A filtered scan never writes the range:surface cache. Otherwise the + // dashboard could be served one provider's totals as the whole window. const filtered = await fetch(new URL("/api/usage?range=all&provider=no-such-provider", server.url)).then(res => res.json()); expect(filtered.summary.requests).toBe(0); @@ -686,261 +763,23 @@ describe("GET /api/usage", () => { } }); - test("missing usage.jsonl returns zeroed summary, not 500", async () => { - const server = startServer(0); - try { - const res = await fetch(new URL("/api/usage", server.url)); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.summary.requests).toBe(0); - expect(body.summary.measuredRequests).toBe(0); - expect(body.summary.totalTokens).toBe(0); - expect(body.summary.coverageRatio).toBe(0); - } finally { - await server.stop(true); - } - }); - - test("repeated appends do not reparse the retained prefix", async () => { - const now = Date.now(); - writeFixture(now); - resetUsageReadCacheForTests(); - resetUsageSummaryCacheForTests(); - const server = startServer(0); - const clock = spyOn(Date, "now"); - try { - clock.mockReturnValue(now); - await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - const afterFirst = usageReadCacheStatsForTests(); - expect(afterFirst.fullReads).toBe(1); - const baselineParsed = afterFirst.parsedLines; - expect(baselineParsed).toBeGreaterThan(0); - - // Append one row at a time, stepping past the 60s freshness window each round so - // every request is a genuine cache miss that reaches the reader. - let requests = 0; - for (let round = 1; round <= 5; round++) { - appendFileSync(join(testDir, "usage.jsonl"), `${JSON.stringify({ - requestId: `ocx-append-${round}`, - timestamp: now, - provider: "openai", - model: "gpt-5.5", - status: 200, - durationMs: 1, - usageStatus: "reported", - usage: { inputTokens: 1, outputTokens: 1 }, - totalTokens: 2, - })}\n`); - clock.mockReturnValue(now + round * 60_001); - const body = await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - requests = body.summary.requests; - } - - const afterAppends = usageReadCacheStatsForTests(); - // Each round parses only its own appended line, so growth equals the number of - // appended rows. A reparse regression would instead re-add the whole grown - // prefix every round (baselineParsed+1 ... baselineParsed+5). - expect(afterAppends.parsedLines - baselineParsed).toBe(5); - expect(afterAppends.fullReads).toBe(1); - expect(afterAppends.tailReads).toBeGreaterThanOrEqual(5); - // The rows are still correct, not merely cheap. - expect(requests).toBe(afterFirst.parsedLines + 5); - } finally { - clock.mockRestore(); - await server.stop(true); - } - }); - - test("an append burst larger than the byte window falls back to a bounded full read", async () => { + test("an oversized row fails closed instead of caching a partial aggregate", async () => { const now = Date.now(); - const maxReadBytes = 512; - const row = (id: string): string => `${JSON.stringify({ - requestId: id, + const oversized = { + requestId: "ocx-oversized", timestamp: now, provider: "openai", model: "gpt-5.5", status: 200, durationMs: 1, usageStatus: "reported", - usage: { inputTokens: 1, outputTokens: 1 }, - totalTokens: 2, - })}\n`; - const path = join(testDir, "usage.jsonl"); - writeFileSync(path, row("seed")); - - await usageLogModule.readUsageSnapshotForManagement(maxReadBytes); - const parsedBeforeBurst = usageReadCacheStatsForTests().parsedLines; - appendFileSync(path, Array.from({ length: 100 }, (_, index) => row(`burst-${index}`)).join("")); - - const snapshot = await usageLogModule.readUsageSnapshotForManagement(maxReadBytes); - const stats = usageReadCacheStatsForTests(); - expect(stats.fullReads).toBe(2); - expect(stats.tailReads).toBe(0); - expect(stats.parsedLines - parsedBeforeBurst).toBe(snapshot.entries.length); - expect(snapshot.entries.length).toBeLessThan(100); - expect(snapshot.entries.some(entry => entry.requestId === "burst-99")).toBe(true); - }); - - test("appends to an over-window ledger stay incremental and bounded", async () => { - const now = Date.now(); - writeFixture(now); - // A tiny window makes the bound reachable with a handful of rows. - saveConfig({ ...baseConfig(), managementUsageMaxReadBytes: 1024 }); - resetUsageReadCacheForTests(); - resetUsageSummaryCacheForTests(); - const server = startServer(0); - const clock = spyOn(Date, "now"); - try { - clock.mockReturnValue(now); - await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - expect(usageReadCacheStatsForTests().fullReads).toBe(1); - - // Append well past the window. This is the shape of the real 245 MB ledger, and - // the case the whole optimization exists for: a reader that refused to extend - // whenever the retained window started earlier than the current window would do a - // FULL reparse on every single append here, which is where the memory blow-up - // came from in the first place. - for (let round = 1; round <= 12; round++) { - appendFileSync(join(testDir, "usage.jsonl"), `${JSON.stringify({ - requestId: `ocx-window-${round}`, - timestamp: now, - provider: "openai", - model: "gpt-5.5", - status: 200, - durationMs: 1, - usageStatus: "reported", - usage: { inputTokens: 1, outputTokens: 1 }, - totalTokens: 2, - })}\n`); - clock.mockReturnValue(now + round * 60_001); - await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - } - - const stats = usageReadCacheStatsForTests(); - // Most rounds must be served incrementally rather than reparsed. - expect(stats.tailReads).toBeGreaterThanOrEqual(6); - // Re-anchoring still happens, so retention cannot grow with the file forever, - // but it is amortized rather than paid per append. - expect(stats.fullReads).toBeLessThan(12); - clock.mockReturnValue(now + 13 * 60_001); - const body = await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - expect(body.historyTruncated).toBe(true); - } finally { - clock.mockRestore(); - await server.stop(true); - } - }); - - test("an in-place rewrite that keeps the inode is not served from the retained tail", async () => { - const now = Date.now(); - resetUsageReadCacheForTests(); - resetUsageSummaryCacheForTests(); - // Fixed-width request ids so the rewritten rows are byte-for-byte the same length - // as the originals. A newline therefore still lands exactly at the previously - // covered offset, which defeats the record-boundary check -- only re-verifying the - // covered prefix can catch this rewrite. - const row = (id: string): string => `${JSON.stringify({ - requestId: id, - timestamp: now - 86_400_000, - provider: "openai", - model: "gpt-5.5", - status: 200, - durationMs: 1, - usageStatus: "reported", - usage: { inputTokens: 1, outputTokens: 1 }, - totalTokens: 2, - })}\n`; - writeFileSync(join(testDir, "usage.jsonl"), `${row("aaa1")}${row("aaa2")}${row("aaa3")}`); - const server = startServer(0); - const clock = spyOn(Date, "now"); - try { - clock.mockReturnValue(now); - const first = await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - expect(first.summary.requests).toBe(3); - - // Replace all three rows in place and append a fourth. The inode, device and - // birthtime are unchanged and the file only grew, so neither the identity check - // nor the shrink check sees it, and the boundary check is satisfied because the - // replacement rows have identical widths. - writeFileSync( - join(testDir, "usage.jsonl"), - `${row("bbb1")}${row("bbb2")}${row("bbb3")}${row("bbb4")}`, - ); - - clock.mockReturnValue(now + 60_001); - const after = await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - // Without the prefix check this returns the three STALE rows concatenated with - // the one newly appended row -- still 4 requests, but three of them no longer - // exist in the file. Assert on identity, not just the count. - expect(after.summary.requests).toBe(4); - expect(after.models.every((model: { model: string }) => typeof model.model === "string")).toBe(true); - // Serving this from the retained tail would have required no second full read. - expect(usageReadCacheStatsForTests().fullReads).toBe(2); - expect(usageReadCacheStatsForTests().tailReads).toBe(0); - } finally { - clock.mockRestore(); - await server.stop(true); - } - }); - - test("an in-place edit in the middle of a large prefix is not served from the retained tail", async () => { - const now = Date.now(); - resetUsageReadCacheForTests(); - resetUsageSummaryCacheForTests(); - // Large enough that a SAMPLED prefix digest would cover a vanishing fraction of the - // file. The edit below is deliberately placed away from both ends, where sampled - // probes do not reach -- the case that makes sampling unsafe for an ordinary - // fixed-width edit rather than only an adversarial one. - const row = (id: string): string => `${JSON.stringify({ - requestId: id, - timestamp: now - 86_400_000, - provider: "openai", - model: "gpt-5.5", - status: 200, - durationMs: 1, - usageStatus: "reported", - usage: { inputTokens: 1, outputTokens: 1 }, - totalTokens: 2, - })}\n`; - const rows = Array.from({ length: 4000 }, (_, index) => row(`old${String(index).padStart(6, "0")}`)); - const rowBytes = Buffer.byteLength(rows[0]!); - writeFileSync(join(testDir, "usage.jsonl"), rows.join("")); - const server = startServer(0); - const clock = spyOn(Date, "now"); - try { - clock.mockReturnValue(now); - await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - expect(usageReadCacheStatsForTests().fullReads).toBe(1); - - // Overwrite one row in the middle, byte-identical in width so the file size and - // every record boundary are unchanged, then append. - const replacement = row("new002500"); - expect(Buffer.byteLength(replacement)).toBe(rowBytes); - const handle = openSync(join(testDir, "usage.jsonl"), "r+"); - try { - writeSync(handle, Buffer.from(replacement), 0, rowBytes, 2500 * rowBytes); - } finally { - closeSync(handle); - } - appendFileSync(join(testDir, "usage.jsonl"), row("appended1")); - - clock.mockReturnValue(now + 60_001); - await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - // The mid-prefix rewrite must invalidate the retained rows: a sampled digest would - // miss it and serve old002500, which no longer exists in the file. - expect(usageReadCacheStatsForTests().fullReads).toBe(2); - expect(usageReadCacheStatsForTests().tailReads).toBe(0); - } finally { - clock.mockRestore(); - await server.stop(true); - } - }); - - test("an over-window ledger reports a stable window instead of sawtoothing", async () => { - const now = Date.now(); - const row = (id: string): string => `${JSON.stringify({ - requestId: id, - timestamp: now - 86_400_000, + usage: { inputTokens: 100, outputTokens: 50 }, + totalTokens: 150, + padding: "x".repeat(usageLedgerScannerModule.USAGE_LEDGER_MAX_LINE_BYTES), + }; + const valid = { + requestId: "ocx-valid-after-oversized", + timestamp: now, provider: "openai", model: "gpt-5.5", status: 200, @@ -948,183 +787,83 @@ describe("GET /api/usage", () => { usageStatus: "reported", usage: { inputTokens: 1, outputTokens: 1 }, totalTokens: 2, - })}\n`; - // Start above the window so every append slides it forward. - const seed = Array.from({ length: 60 }, (_, index) => row(`seed${String(index).padStart(6, "0")}`)); - writeFileSync(join(testDir, "usage.jsonl"), seed.join("")); - saveConfig({ ...baseConfig(), managementUsageMaxReadBytes: 4096 }); - resetUsageReadCacheForTests(); - resetUsageSummaryCacheForTests(); + }; + writeFileSync(join(testDir, "usage.jsonl"), `${JSON.stringify(oversized)}\n${JSON.stringify(valid)}\n`); const server = startServer(0); - const clock = spyOn(Date, "now"); try { - const counts: number[] = []; - for (let round = 1; round <= 40; round++) { - appendFileSync(join(testDir, "usage.jsonl"), row(`add${String(round).padStart(7, "0")}`)); - clock.mockReturnValue(now + round * 60_001); - const body = await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - counts.push(body.summary.requests); - } - // Retaining a window wider than maxReadBytes and then re-anchoring made visible - // history collapse by roughly half on a single poll of an append-only file, so - // dashboard totals swung between refreshes. The window is now trimmed on every - // read, so the visible count stays flat. - const min = Math.min(...counts); - const max = Math.max(...counts); - expect(max - min).toBeLessThanOrEqual(1); + const body = await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); + expect(body.error).toBe("read_failed"); + expect(body.summary.requests).toBe(0); + expect(body.historyTruncated).toBe(false); + expect(getUsageSummaryCacheEntry("all:all")).toBeUndefined(); } finally { - clock.mockRestore(); await server.stop(true); } }); - test("unparseable lines do not make the window trim lose history", async () => { - const now = Date.now(); - const row = (id: string): string => `${JSON.stringify({ - requestId: id, - timestamp: now - 86_400_000, - provider: "openai", - model: "gpt-5.5", - status: 200, - durationMs: 1, - usageStatus: "reported", - usage: { inputTokens: 1, outputTokens: 1 }, - totalTokens: 2, - })}\n`; - // Interleave lines that parse to nothing -- a torn write, a hand-edit, a pre-schema - // legacy row. Their bytes still occupy the file, so if the recorded row lengths omit - // them the trim walk under-counts the byte distance and silently drops extra rows. - const seed: string[] = []; - for (let index = 0; index < 120; index++) { - seed.push(row(`R${String(index).padStart(6, "0")}`)); - if (index % 5 === 0) seed.push("{ not json at all ~~~\n"); - } - writeFileSync(join(testDir, "usage.jsonl"), seed.join("")); - saveConfig({ ...baseConfig(), managementUsageMaxReadBytes: 4096 }); - resetUsageReadCacheForTests(); - resetUsageSummaryCacheForTests(); + test("missing usage.jsonl returns zeroed summary, not 500", async () => { const server = startServer(0); - const clock = spyOn(Date, "now"); try { - clock.mockReturnValue(now); - await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - for (let round = 1; round <= 40; round++) { - appendFileSync(join(testDir, "usage.jsonl"), row(`A${String(round).padStart(6, "0")}`)); - if (round % 5 === 0) appendFileSync(join(testDir, "usage.jsonl"), "{ torn write\n"); - clock.mockReturnValue(now + round * 60_001); - await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - } - const cached = await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - // The incremental path must have stayed engaged. Without skipped-line accounting - // the recorded lengths stop summing to the byte span, the consistency check - // rejects every reuse, and this collapses back to a full read per poll -- correct - // output, but the optimization is gone. - const stats = usageReadCacheStatsForTests(); - expect(stats.tailReads).toBeGreaterThanOrEqual(20); - - // A cold read of the same window is the ground truth. - resetUsageReadCacheForTests(); - resetUsageSummaryCacheForTests(); - clock.mockReturnValue(now + 41 * 60_001); - const fresh = await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - expect(cached.summary.requests).toBe(fresh.summary.requests); - expect(cached.truncatedPrefixBytes).toBe(fresh.truncatedPrefixBytes); + const res = await fetch(new URL("/api/usage", server.url)); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.summary.requests).toBe(0); + expect(body.summary.measuredRequests).toBe(0); + expect(body.summary.totalTokens).toBe(0); + expect(body.summary.coverageRatio).toBe(0); } finally { - clock.mockRestore(); await server.stop(true); } }); - test("the entry cap re-anchors instead of reporting a window a cold read disagrees with", async () => { - const now = Date.now(); - const row = (id: string): string => `${JSON.stringify({ - requestId: id, - timestamp: now - 86_400_000, - provider: "openai", - model: "gpt-5.5", - status: 200, - durationMs: 1, - usageStatus: "reported", - usage: { inputTokens: 1, outputTokens: 1 }, - totalTokens: 2, - })}\n`; - // Bind the ENTRY cap rather than the byte window: a generous window with a small cap - // is the only way to reach this path without a half-million-row fixture. - setManagementUsageMaxEntriesForTests(25); - writeFileSync( - join(testDir, "usage.jsonl"), - Array.from({ length: 40 }, (_, index) => row(`R${String(index).padStart(6, "0")}`)).join(""), - ); - saveConfig({ ...baseConfig(), managementUsageMaxReadBytes: 1024 * 1024 }); - resetUsageReadCacheForTests(); - resetUsageSummaryCacheForTests(); + test("one complete scan warms every unfiltered range and surface cache slot", async () => { + writeFixture(Date.now()); const server = startServer(0); - const clock = spyOn(Date, "now"); try { - clock.mockReturnValue(now); - await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - for (let round = 1; round <= 12; round++) { - appendFileSync(join(testDir, "usage.jsonl"), row(`A${String(round).padStart(6, "0")}`)); - clock.mockReturnValue(now + round * 60_001); - await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); + await fetch(new URL("/api/usage?range=7d&surface=claude", server.url)).then(res => res.json()); + for (const range of ["today", "7d", "30d", "all"]) { + for (const surface of ["all", "codex", "claude", "grok"]) { + expect(getUsageSummaryCacheEntry(`${range}:${surface}`)).toBeDefined(); + } } - const cached = await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - // A cold read applies the entry cap across the whole window and reports byte - // truncation for the window boundary alone; an incremental read cannot reconstruct - // that ordering, so it must re-anchor rather than report a disagreeing window. - // This is reachable in production: real rows average ~118 bytes, so 500,000 of them - // fit inside the 64 MiB window and both truncations can apply at once. - expect(usageReadCacheStatsForTests().fullReads).toBeGreaterThan(1); - - resetUsageReadCacheForTests(); - resetUsageSummaryCacheForTests(); - clock.mockReturnValue(now + 13 * 60_001); - const fresh = await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - expect(cached.summary.requests).toBe(fresh.summary.requests); - expect(cached.truncatedPrefixBytes).toBe(fresh.truncatedPrefixBytes); + const aggregateStats = usageAggregateCacheModule.usageAggregateRetainedStats(); + expect(aggregateStats).toMatchObject({ count: 1, pinnedBytes: 0 }); + expect(aggregateStats.bytes).toBeGreaterThan(0); + const memory = await fetch(new URL("/api/system/memory", server.url)).then(res => res.json()); + expect(memory.appOwnedBytes.stores.usage_snapshot).toMatchObject({ + count: 1, + bytes: aggregateStats.bytes, + }); } finally { - setManagementUsageMaxEntriesForTests(null); - clock.mockRestore(); await server.stop(true); } }); - test("a CRLF ledger still uses the incremental path", async () => { + test("large daily token totals stay exact beyond 32-bit counters", async () => { const now = Date.now(); - const row = (id: string): string => `${JSON.stringify({ - requestId: id, - timestamp: now - 86_400_000, + const perDayTokens = 4_000_000_000; + const rows = Array.from({ length: 30 }, (_, index) => ({ + requestId: `ocx-large-${index}`, + timestamp: now - index * 86_400_000, provider: "openai", model: "gpt-5.5", status: 200, durationMs: 1, usageStatus: "reported", - usage: { inputTokens: 1, outputTokens: 1 }, - totalTokens: 2, - })}\r\n`; - writeFileSync( - join(testDir, "usage.jsonl"), - Array.from({ length: 20 }, (_, index) => row(`C${String(index).padStart(6, "0")}`)).join(""), - ); - resetUsageReadCacheForTests(); - resetUsageSummaryCacheForTests(); + usage: { inputTokens: perDayTokens, outputTokens: 0 }, + totalTokens: perDayTokens, + })); + writeFileSync(join(testDir, "usage.jsonl"), `${rows.map(row => JSON.stringify(row)).join("\n")}\n`); const server = startServer(0); - const clock = spyOn(Date, "now"); try { - clock.mockReturnValue(now); - await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - for (let round = 1; round <= 5; round++) { - appendFileSync(join(testDir, "usage.jsonl"), row(`D${String(round).padStart(6, "0")}`)); - clock.mockReturnValue(now + round * 60_001); - await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - } - // A CRLF line owes two separator bytes. Counting one leaves the recorded lengths - // short of the real span, the accounting self-check rejects every reuse, and the - // reader silently falls back to a full parse on every poll. - expect(usageReadCacheStatsForTests().tailReads).toBeGreaterThanOrEqual(5); - expect(usageReadCacheStatsForTests().fullReads).toBe(1); + const body = await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); + const expectedTokens = 120_000_000_000; + expect(body.summary).toMatchObject({ requests: 30, totalTokens: expectedTokens }); + expect(body.models[0].totalTokens).toBe(expectedTokens); + expect(body.providers[0].totalTokens).toBe(expectedTokens); + expect(body.days.reduce((sum: number, day: { totalTokens: number }) => sum + day.totalTokens, 0)).toBe(expectedTokens); + expect(body.historyTruncated).toBe(false); } finally { - clock.mockRestore(); await server.stop(true); } }); diff --git a/tests/memory-watchdog.test.ts b/tests/memory-watchdog.test.ts index 2e75b4cc51..edc9cc757a 100644 --- a/tests/memory-watchdog.test.ts +++ b/tests/memory-watchdog.test.ts @@ -19,6 +19,7 @@ import { } from "../src/lib/app-owned-memory"; import { registerDefaultAppOwnedMemoryStores } from "../src/lib/app-owned-memory-stores"; import { appendDebugLogLine, resetDebugLogBufferForTests } from "../src/lib/debug-log-buffer"; +import { resetUsageAggregateCacheForTests } from "../src/server/management/usage-aggregate-cache"; function config(): OcxConfig { return { @@ -39,6 +40,7 @@ afterEach(() => { getActiveMemoryWatchdog()?.stop(); resetAppOwnedMemoryForTests(); resetDebugLogBufferForTests(); + resetUsageAggregateCacheForTests(); }); function sampleAt(at: number, rssMb: number, externalMb = 1, arrayBuffersMb = 1): MemorySampleBase { diff --git a/tests/settings-stream-mode.test.ts b/tests/settings-stream-mode.test.ts index 293c0a7871..6e78651aa0 100644 --- a/tests/settings-stream-mode.test.ts +++ b/tests/settings-stream-mode.test.ts @@ -29,6 +29,7 @@ import { setUsageSummaryCacheEntry, usageSummaryRetainedStoreSnapshot, } from "../src/server/management/usage-summary-cache"; +import { resetUsageAggregateCacheForTests } from "../src/server/management/usage-aggregate-cache"; import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; import { startupHealthFixture } from "./helpers/startup-health"; import { removeTreeWithRetry } from "./helpers/remove-tree"; @@ -80,6 +81,7 @@ function getSettings(config: OcxConfig): Promise { beforeEach(() => { resetAppOwnedMemoryForTests(); resetUsageSummaryCacheForTests(); + resetUsageAggregateCacheForTests(); invalidateStartupHealthCache(); TEST_DIR = mkdtempSync(join(tmpdir(), "ocx-settings-stream-")); process.env.OPENCODEX_HOME = TEST_DIR; @@ -88,6 +90,7 @@ beforeEach(() => { afterEach(() => { resetAppOwnedMemoryForTests(); resetUsageSummaryCacheForTests(); + resetUsageAggregateCacheForTests(); invalidateStartupHealthCache(); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; @@ -240,6 +243,7 @@ describe("usage summary retained-store accounting", () => { identityKey: "slow-read", maxReadBytes: 64 * 1024 * 1024, overlayVersion: 0, + timeZone: seed!.timeZone, expiresAt: Date.now() + 60_000, freshUntil: Date.now() + 60_000, lastSeenSize: 0, diff --git a/tests/usage-aggregate-cache.test.ts b/tests/usage-aggregate-cache.test.ts new file mode 100644 index 0000000000..b55f008dfd --- /dev/null +++ b/tests/usage-aggregate-cache.test.ts @@ -0,0 +1,301 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES, + configureAppOwnedMemoryBudget, + enforceAppOwnedMemoryBudget, + registerRetainedStore, + resetAppOwnedMemoryForTests, +} from "../src/lib/app-owned-memory"; +import { APP_OWNED_RETAINED_STORE_REGISTRATIONS } from "../src/lib/app-owned-memory-stores"; +import { + getFilteredUsageAggregate, + getUsageAggregate, + resetUsageAggregateCacheForTests, + usageAggregateRetainedStats, + type UsageAggregateResult, +} from "../src/server/management/usage-aggregate-cache"; +import type { OcxConfig } from "../src/types/config"; +import { resetUsageReadCacheForTests, type PersistedUsageEntry } from "../src/usage/log"; +import * as usageLedgerScannerModule from "../src/usage/ledger-scanner"; +import { refreshUserCostOverlays } from "../src/usage/user-cost-overlays"; + +const NOW = Date.parse("2026-09-01T10:00:00.000Z"); + +let testDir = ""; +let previousHome: string | undefined; + +function entry(requestId: string): PersistedUsageEntry { + return { + requestId, + timestamp: NOW - 1_000, + provider: "openai", + model: "gpt-5.5", + status: 200, + durationMs: 1, + usageStatus: "reported", + usage: { inputTokens: 1, outputTokens: 1 }, + totalTokens: 2, + }; +} + +function line(requestId: string): string { + return `${JSON.stringify(entry(requestId))}\n`; +} + +function requests(result: UsageAggregateResult): number { + return result.accumulator.summarize("all", NOW).summary.requests; +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-usage-aggregate-")); + process.env.OPENCODEX_HOME = testDir; + resetUsageAggregateCacheForTests(); + resetUsageReadCacheForTests(); + resetAppOwnedMemoryForTests(); + refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); +}); + +afterEach(() => { + resetUsageAggregateCacheForTests(); + resetUsageReadCacheForTests(); + resetAppOwnedMemoryForTests(); + refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testDir) rmSync(testDir, { recursive: true, force: true }); +}); + +describe("retained usage aggregate cache", () => { + test("settled filtered callers reuse a bounded retained aggregate", async () => { + writeFileSync(join(testDir, "usage.jsonl"), `${line("one")}${line("two")}`); + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; + let scans = 0; + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively") + .mockImplementation(async options => { + scans += 1; + return originalScan(options); + }); + try { + const [first, concurrent] = await Promise.all([ + getFilteredUsageAggregate({ provider: " OpenAI " }), + getFilteredUsageAggregate({ provider: "openai" }), + ]); + const retained = await getFilteredUsageAggregate({ provider: "OPENAI" }); + const different = await getFilteredUsageAggregate({ provider: "anthropic" }); + + expect(scans).toBe(2); + expect(requests(first)).toBe(2); + expect(first.accumulator).toBe(concurrent.accumulator); + expect(retained.update).toBe("unchanged"); + expect(retained.accumulator).toBe(first.accumulator); + expect(requests(different)).toBe(0); + expect(usageAggregateRetainedStats().count).toBe(2); + } finally { + scanSpy.mockRestore(); + } + }); + + test("filtered retention invalidates when pricing inputs change", async () => { + writeFileSync(join(testDir, "usage.jsonl"), line("one")); + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; + let scans = 0; + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively") + .mockImplementation(async options => { + scans += 1; + return originalScan(options); + }); + try { + const first = await getFilteredUsageAggregate({ provider: "openai" }); + refreshUserCostOverlays({ + providers: { + openai: { + modelCosts: { + "gpt-5.5": { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0.2 }, + }, + }, + }, + } as unknown as OcxConfig); + const refreshed = await getFilteredUsageAggregate({ provider: "openai" }); + + expect(scans).toBe(2); + expect(refreshed.update).toBe("rebuild"); + expect(refreshed.accumulator).not.toBe(first.accumulator); + expect(usageAggregateRetainedStats().count).toBe(1); + } finally { + scanSpy.mockRestore(); + } + }); + + test("filtered retention incrementally folds an ordinary append", async () => { + writeFileSync(join(testDir, "usage.jsonl"), line("one")); + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; + const scanStarts: number[] = []; + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively") + .mockImplementation(async options => { + scanStarts.push(options.startAtBytes ?? 0); + return originalScan(options); + }); + try { + const first = await getFilteredUsageAggregate({ provider: "openai" }); + appendFileSync(join(testDir, "usage.jsonl"), line("two")); + const appended = await getFilteredUsageAggregate({ provider: "openai" }); + + expect(requests(first)).toBe(1); + expect(appended.update).toBe("append"); + expect(requests(appended)).toBe(2); + expect(scanStarts).toHaveLength(2); + expect(scanStarts[0]).toBe(0); + expect(scanStarts[1]).toBeGreaterThan(0); + } finally { + scanSpy.mockRestore(); + } + }); + + test("a missing ledger is retained as an unchanged empty aggregate", async () => { + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; + let scans = 0; + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively") + .mockImplementation(async options => { + scans += 1; + return originalScan(options); + }); + try { + const first = await getUsageAggregate({ now: NOW }); + const second = await getUsageAggregate({ now: NOW }); + expect(scans).toBe(1); + expect(requests(first)).toBe(0); + expect(second.update).toBe("unchanged"); + expect(second.accumulator).toBe(first.accumulator); + } finally { + scanSpy.mockRestore(); + } + }); + + test("concurrent cold callers share one full base scan", async () => { + writeFileSync(join(testDir, "usage.jsonl"), `${line("one")}${line("two")}`); + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; + const scanStarts: number[] = []; + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively") + .mockImplementation(async options => { + scanStarts.push(options.startAtBytes ?? 0); + return originalScan(options); + }); + try { + const [first, second] = await Promise.all([ + getUsageAggregate({ now: NOW }), + getUsageAggregate({ now: NOW }), + ]); + expect(scanStarts).toEqual([0]); + expect(requests(first)).toBe(2); + expect(requests(second)).toBe(2); + expect(first.accumulator).toBe(second.accumulator); + } finally { + scanSpy.mockRestore(); + } + }); + + test("a shrink discards the checkpoint and performs a full rebuild", async () => { + writeFileSync(join(testDir, "usage.jsonl"), `${line("one")}${line("two")}${line("three")}`); + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; + const scanStarts: number[] = []; + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively") + .mockImplementation(async options => { + scanStarts.push(options.startAtBytes ?? 0); + return originalScan(options); + }); + try { + const rebuilt = await getUsageAggregate({ now: NOW }); + expect(requests(rebuilt)).toBe(3); + + appendFileSync(join(testDir, "usage.jsonl"), line("four")); + const appended = await getUsageAggregate({ now: NOW }); + expect(appended.update).toBe("append"); + expect(requests(appended)).toBe(4); + + writeFileSync(join(testDir, "usage.jsonl"), line("new")); + const afterShrink = await getUsageAggregate({ now: NOW }); + expect(afterShrink.update).toBe("rebuild"); + expect(requests(afterShrink)).toBe(1); + expect(scanStarts).toHaveLength(3); + expect(scanStarts[0]).toBe(0); + expect(scanStarts[1]).toBeGreaterThan(0); + expect(scanStarts[2]).toBe(0); + } finally { + scanSpy.mockRestore(); + } + }); + + test("app-owned eviction makes the next caller perform a full rebuild", async () => { + writeFileSync(join(testDir, "usage.jsonl"), line("one")); + const usageStore = APP_OWNED_RETAINED_STORE_REGISTRATIONS + .find(registration => registration.id === "usage_snapshot"); + if (!usageStore) throw new Error("usage_snapshot retained-store registration is missing"); + registerRetainedStore(usageStore); + + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; + const scanStarts: number[] = []; + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively") + .mockImplementation(async options => { + scanStarts.push(options.startAtBytes ?? 0); + return originalScan(options); + }); + try { + await getUsageAggregate({ now: NOW }); + expect(usageAggregateRetainedStats().count).toBe(1); + + configureAppOwnedMemoryBudget(0); + enforceAppOwnedMemoryBudget(); + expect(usageAggregateRetainedStats().count).toBe(0); + + configureAppOwnedMemoryBudget(DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES); + const rebuilt = await getUsageAggregate({ now: NOW }); + expect(rebuilt.update).toBe("rebuild"); + expect(requests(rebuilt)).toBe(1); + expect(scanStarts).toEqual([0, 0]); + } finally { + scanSpy.mockRestore(); + } + }); + + test("an oversized append result never publishes its partially-fed candidate", async () => { + writeFileSync(join(testDir, "usage.jsonl"), line("one")); + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; + let forceOversizedAppend = false; + const scanStarts: number[] = []; + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively") + .mockImplementation(async options => { + const start = options.startAtBytes ?? 0; + scanStarts.push(start); + const result = await originalScan(options); + return forceOversizedAppend && start > 0 + ? { ...result, oversizedRows: result.oversizedRows + 1 } + : result; + }); + try { + const original = await getUsageAggregate({ now: NOW }); + expect(requests(original)).toBe(1); + + appendFileSync(join(testDir, "usage.jsonl"), line("two")); + forceOversizedAppend = true; + await expect(getUsageAggregate({ now: NOW })).rejects.toThrow("oversized row"); + expect(requests(original)).toBe(1); + expect(usageAggregateRetainedStats().count).toBe(0); + + forceOversizedAppend = false; + const rebuilt = await getUsageAggregate({ now: NOW }); + expect(rebuilt.update).toBe("rebuild"); + expect(requests(rebuilt)).toBe(2); + expect(scanStarts).toHaveLength(3); + expect(scanStarts[0]).toBe(0); + expect(scanStarts[1]).toBeGreaterThan(0); + expect(scanStarts[2]).toBe(0); + } finally { + scanSpy.mockRestore(); + } + }); +}); diff --git a/tests/usage-ledger-scanner.test.ts b/tests/usage-ledger-scanner.test.ts new file mode 100644 index 0000000000..81021a2bec --- /dev/null +++ b/tests/usage-ledger-scanner.test.ts @@ -0,0 +1,498 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { appendFileSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + scanUsageLedgerCooperatively, + USAGE_LEDGER_BOUNDARY_DIGEST_BYTES, + USAGE_LEDGER_MAX_LINE_BYTES, + UsageLedgerRebuildRequiredError, +} from "../src/usage/ledger-scanner"; +import { usageLogIdentityKey, usageLogPath, type PersistedUsageEntry } from "../src/usage/log"; + +let testDir = ""; +let previousHome: string | undefined; + +function entry(requestId: string, overrides: Partial = {}): PersistedUsageEntry { + return { + requestId, + timestamp: 1, + provider: "openai", + model: "gpt-5.5", + status: 200, + durationMs: 1, + usageStatus: "reported", + usage: { inputTokens: 1, outputTokens: 1 }, + totalTokens: 2, + ...overrides, + }; +} + +function line(requestId: string, overrides: Partial = {}): string { + return `${JSON.stringify(entry(requestId, overrides))}\n`; +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-usage-ledger-scan-")); + process.env.OPENCODEX_HOME = testDir; +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testDir) rmSync(testDir, { recursive: true, force: true }); +}); + +describe("usage ledger cooperative scanner", () => { + test("a missing ledger is a complete empty snapshot", async () => { + const entries: PersistedUsageEntry[] = []; + const result = await scanUsageLedgerCooperatively({ onEntry: value => entries.push(value) }); + + expect(result).toMatchObject({ + revision: null, + parsedRows: 0, + invalidRows: 0, + oversizedRows: 0, + bytesRead: 0, + processedThroughBytes: 0, + }); + expect(result.processedThroughDigest).toHaveLength(64); + expect(entries).toEqual([]); + }); + + test("frames UTF-8 and CRLF rows before decoding even at one-byte read boundaries", async () => { + const contents = [ + JSON.stringify(entry("요청-🙂", { provider: "공급자", model: "모델-한글" })), + JSON.stringify(entry("request-two", { provider: "anthropic", model: "claude-fable-5" })), + ].join("\r\n") + "\r\n"; + writeFileSync(usageLogPath(), contents); + + const entries: PersistedUsageEntry[] = []; + const result = await scanUsageLedgerCooperatively({ + chunkBytes: 1, + onEntry: value => entries.push(value), + }); + + expect(entries.map(value => [value.requestId, value.provider, value.model])).toEqual([ + ["요청-🙂", "공급자", "모델-한글"], + ["request-two", "anthropic", "claude-fable-5"], + ]); + expect(result).toMatchObject({ + parsedRows: 2, + invalidRows: 0, + oversizedRows: 0, + bytesRead: Buffer.byteLength(contents), + }); + expect(result.revision?.size).toBe(Buffer.byteLength(contents)); + expect(result.processedThroughBytes).toBe(Buffer.byteLength(contents)); + }); + + test("the checkpoint digest tracks the last 64 KiB after the rolling window wraps", async () => { + const contents = Array.from({ length: 1_000 }, (_, index) => line(`digest-${index}`)).join(""); + const bytes = Buffer.from(contents); + expect(bytes.byteLength).toBeGreaterThan(USAGE_LEDGER_BOUNDARY_DIGEST_BYTES); + writeFileSync(usageLogPath(), bytes); + + const result = await scanUsageLedgerCooperatively({ onEntry: () => {} }); + const expected = createHash("sha256") + .update(bytes.subarray(bytes.byteLength - USAGE_LEDGER_BOUNDARY_DIGEST_BYTES)) + .digest("hex"); + + expect(result.processedThroughDigest).toBe(expected); + }); + + test("yields while scanning a large ledger and visits every row once", async () => { + const rows = Array.from({ length: 2_100 }, (_, index) => line(`row-${index}`)); + writeFileSync(usageLogPath(), rows.join("")); + let timerRan = false; + setTimeout(() => { timerRan = true; }, 0); + let totalTokens = 0; + + const result = await scanUsageLedgerCooperatively({ + chunkBytes: 128, + onEntry: value => { totalTokens += value.totalTokens ?? 0; }, + }); + + expect(timerRan).toBe(true); + expect(result.parsedRows).toBe(2_100); + expect(result.invalidRows).toBe(0); + expect(totalTokens).toBe(4_200); + }); + + test("skips malformed, invalid UTF-8, oversized, and torn final rows with bounded recovery", async () => { + const exactlyAtLimit = Buffer.concat([ + Buffer.alloc(USAGE_LEDGER_MAX_LINE_BYTES, 0x20), + Buffer.from("\n"), + ]); + const oversized = Buffer.from(`${"x".repeat(USAGE_LEDGER_MAX_LINE_BYTES + 1)}\n`); + const torn = Buffer.from(JSON.stringify(entry("valid-json-without-lf"))); + const contents = Buffer.concat([ + Buffer.from(line("valid")), + Buffer.from("{not-json}\n"), + Buffer.from(`${JSON.stringify({ requestId: "missing-provider" })}\n`), + Buffer.from([0xff, 0x0a]), + Buffer.from("\r\n"), + exactlyAtLimit, + oversized, + Buffer.from(line("after-oversized")), + torn, + ]); + writeFileSync(usageLogPath(), contents); + const ids: string[] = []; + + const result = await scanUsageLedgerCooperatively({ + onEntry: value => ids.push(value.requestId), + }); + + expect(ids).toEqual(["valid", "after-oversized"]); + expect(result).toMatchObject({ + parsedRows: 2, + invalidRows: 4, + oversizedRows: 1, + bytesRead: contents.byteLength, + processedThroughBytes: contents.byteLength - torn.byteLength, + }); + }); + + test("the line ceiling leaves headroom for an extreme writer-shaped attempt row", async () => { + const attempts = Array.from({ length: 1_000 }, (_, index) => ({ + ordinal: index + 1, + provider: "openai", + model: "gpt-5.5", + adapter: "openai-responses", + status: 200, + durationMs: 1, + sendCount: 1, + recoveryKinds: [], + usageStatus: "reported" as const, + usage: { inputTokens: 1, outputTokens: 1 }, + totalTokens: 2, + })); + const contents = line("many-attempts", { attempts }); + expect(Buffer.byteLength(contents)).toBeLessThan(USAGE_LEDGER_MAX_LINE_BYTES); + writeFileSync(usageLogPath(), contents); + + const ids: string[] = []; + const result = await scanUsageLedgerCooperatively({ onEntry: value => ids.push(value.requestId) }); + + expect(ids).toEqual(["many-attempts"]); + expect(result).toMatchObject({ parsedRows: 1, invalidRows: 0, oversizedRows: 0 }); + expect(result.processedThroughDigest).toBe( + createHash("sha256") + .update(Buffer.from(contents).subarray(-USAGE_LEDGER_BOUNDARY_DIGEST_BYTES)) + .digest("hex"), + ); + }); + + test("uses the opened EOF and leaves a concurrent append for the next scan", async () => { + const initial = Array.from({ length: 1_500 }, (_, index) => line(`initial-${index}`)).join(""); + writeFileSync(usageLogPath(), initial); + const firstIds: string[] = []; + const firstScan = scanUsageLedgerCooperatively({ + chunkBytes: 128, + onEntry: value => firstIds.push(value.requestId), + }); + queueMicrotask(() => appendFileSync(usageLogPath(), line("appended"))); + + const first = await firstScan; + expect(first.revision?.size).toBe(Buffer.byteLength(initial)); + expect(first.bytesRead).toBe(Buffer.byteLength(initial)); + expect(first.processedThroughBytes).toBe(Buffer.byteLength(initial)); + expect(firstIds).toHaveLength(1_500); + expect(firstIds).not.toContain("appended"); + + const secondIds: string[] = []; + const second = await scanUsageLedgerCooperatively({ onEntry: value => secondIds.push(value.requestId) }); + expect(second.parsedRows).toBe(1_501); + expect(secondIds.at(-1)).toBe("appended"); + }); + + test("continuous pure appends during verification do not invalidate the captured prefix", async () => { + const rows = Array.from({ length: 15_000 }, (_, index) => line(`stable-${index}`)); + const initial = rows.join(""); + expect(Buffer.byteLength(initial)).toBeGreaterThan(2 * 1024 * 1024); + writeFileSync(usageLogPath(), initial); + let callbacks = 0; + const scan = scanUsageLedgerCooperatively({ onEntry: () => { callbacks += 1; } }); + let appendIndex = 0; + const interval = setInterval(() => { + appendFileSync(usageLogPath(), line(`concurrent-${appendIndex++}`)); + }, 0); + + try { + const result = await scan; + expect(result.parsedRows).toBe(15_000); + expect(callbacks).toBe(15_000); + expect(result.revision?.size).toBe(Buffer.byteLength(initial)); + } finally { + clearInterval(interval); + } + expect(appendIndex).toBeGreaterThan(0); + }); + + test("an append scan visits only bytes after the previous LF checkpoint", async () => { + const initial = `${line("first")}${line("second")}`; + writeFileSync(usageLogPath(), initial); + const initialIds: string[] = []; + const first = await scanUsageLedgerCooperatively({ onEntry: value => initialIds.push(value.requestId) }); + expect(initialIds).toEqual(["first", "second"]); + + const appended = `${line("third")}${line("fourth")}`; + appendFileSync(usageLogPath(), appended); + const appendedIds: string[] = []; + const second = await scanUsageLedgerCooperatively({ + startAtBytes: first.processedThroughBytes, + expectedIdentityKey: usageLogIdentityKey(first.revision), + expectedProcessedThroughDigest: first.processedThroughDigest, + onEntry: value => appendedIds.push(value.requestId), + }); + + expect(appendedIds).toEqual(["third", "fourth"]); + expect(second.bytesRead).toBe(Buffer.byteLength(appended)); + expect(second.processedThroughBytes).toBe(Buffer.byteLength(initial + appended)); + }); + + test("a torn EOF keeps the checkpoint behind it and is counted once after completion", async () => { + const committed = line("committed"); + const completedRow = Buffer.from(JSON.stringify(entry("완성-🙂"))); + const splitAt = completedRow.indexOf(Buffer.from("🙂")) + 2; + writeFileSync(usageLogPath(), Buffer.concat([ + Buffer.from(committed), + completedRow.subarray(0, splitAt), + ])); + const firstIds: string[] = []; + const first = await scanUsageLedgerCooperatively({ + chunkBytes: 3, + onEntry: value => firstIds.push(value.requestId), + }); + expect(firstIds).toEqual(["committed"]); + expect(first.invalidRows).toBe(1); + expect(first.processedThroughBytes).toBe(Buffer.byteLength(committed)); + expect(first.processedThroughDigest).toBe( + createHash("sha256").update(committed).digest("hex"), + ); + + appendFileSync(usageLogPath(), Buffer.concat([ + completedRow.subarray(splitAt), + Buffer.from("\n"), + ])); + const completedIds: string[] = []; + const second = await scanUsageLedgerCooperatively({ + chunkBytes: 2, + startAtBytes: first.processedThroughBytes, + expectedIdentityKey: usageLogIdentityKey(first.revision), + expectedProcessedThroughDigest: first.processedThroughDigest, + onEntry: value => completedIds.push(value.requestId), + }); + expect(completedIds).toEqual(["완성-🙂"]); + expect(second.invalidRows).toBe(0); + + const afterIds: string[] = []; + const third = await scanUsageLedgerCooperatively({ + startAtBytes: second.processedThroughBytes, + expectedIdentityKey: usageLogIdentityKey(second.revision), + expectedProcessedThroughDigest: second.processedThroughDigest, + onEntry: value => afterIds.push(value.requestId), + }); + expect(afterIds).toEqual([]); + expect(third.bytesRead).toBe(0); + }); + + test("incremental preconditions fail with explicit rebuild-required reasons", async () => { + const contents = `${line("first")}${line("second")}`; + writeFileSync(usageLogPath(), contents); + const full = await scanUsageLedgerCooperatively({ onEntry: () => {} }); + + const wrongIdentity = scanUsageLedgerCooperatively({ + startAtBytes: full.processedThroughBytes, + expectedIdentityKey: "not-the-ledger", + expectedProcessedThroughDigest: full.processedThroughDigest, + onEntry: () => {}, + }); + await expect(wrongIdentity).rejects.toMatchObject({ + code: "usage_ledger_rebuild_required", + reason: "identity_mismatch", + }); + + const middleOfRow = scanUsageLedgerCooperatively({ + startAtBytes: 2, + expectedIdentityKey: usageLogIdentityKey(full.revision), + expectedProcessedThroughDigest: full.processedThroughDigest, + onEntry: () => {}, + }); + await expect(middleOfRow).rejects.toMatchObject({ + code: "usage_ledger_rebuild_required", + reason: "boundary_mismatch", + }); + + writeFileSync(usageLogPath(), line("short")); + const shrink = scanUsageLedgerCooperatively({ + startAtBytes: full.processedThroughBytes, + expectedIdentityKey: usageLogIdentityKey(full.revision), + expectedProcessedThroughDigest: full.processedThroughDigest, + onEntry: () => {}, + }); + await expect(shrink).rejects.toBeInstanceOf(UsageLedgerRebuildRequiredError); + await expect(shrink).rejects.toMatchObject({ reason: "shrink" }); + }); + + test("a nonzero checkpoint requires both its identity and trailing digest", async () => { + writeFileSync(usageLogPath(), line("checkpoint")); + const full = await scanUsageLedgerCooperatively({ onEntry: () => {} }); + + await expect(scanUsageLedgerCooperatively({ + startAtBytes: full.processedThroughBytes, + expectedProcessedThroughDigest: full.processedThroughDigest, + onEntry: () => {}, + })).rejects.toBeInstanceOf(TypeError); + await expect(scanUsageLedgerCooperatively({ + startAtBytes: full.processedThroughBytes, + expectedIdentityKey: usageLogIdentityKey(full.revision), + onEntry: () => {}, + })).rejects.toBeInstanceOf(TypeError); + }); + + test("a boundary digest rejects a same-identity rewrite before the append offset", async () => { + const original = `${line("aaaa")}${line("bbbb")}`; + writeFileSync(usageLogPath(), original); + const full = await scanUsageLedgerCooperatively({ onEntry: () => {} }); + const rewritten = original.replace("aaaa", "zzzz"); + expect(Buffer.byteLength(rewritten)).toBe(Buffer.byteLength(original)); + writeFileSync(usageLogPath(), rewritten); + + const scan = scanUsageLedgerCooperatively({ + startAtBytes: full.processedThroughBytes, + expectedIdentityKey: usageLogIdentityKey(full.revision), + expectedProcessedThroughDigest: full.processedThroughDigest, + onEntry: () => {}, + }); + await expect(scan).rejects.toMatchObject({ + code: "usage_ledger_rebuild_required", + reason: "content_changed", + }); + }); + + test("the returned checkpoint digest stays paired with bytes captured by the scan", async () => { + const original = line("old-checkpoint"); + const rewritten = line("new-checkpoint"); + expect(Buffer.byteLength(rewritten)).toBe(Buffer.byteLength(original)); + writeFileSync(usageLogPath(), original); + let abortChecks = 0; + const rewriteAfterVerification = { + get aborted() { + abortChecks += 1; + if (abortChecks === 4) writeFileSync(usageLogPath(), rewritten); + return false; + }, + } as AbortSignal; + + const ids: string[] = []; + const result = await scanUsageLedgerCooperatively({ + signal: rewriteAfterVerification, + onEntry: value => ids.push(value.requestId), + }); + const originalDigest = createHash("sha256").update(original).digest("hex"); + expect(abortChecks).toBeGreaterThanOrEqual(4); + expect(ids).toEqual(["old-checkpoint"]); + expect(result.processedThroughDigest).toBe(originalDigest); + + await expect(scanUsageLedgerCooperatively({ + startAtBytes: result.processedThroughBytes, + expectedIdentityKey: usageLogIdentityKey(result.revision), + expectedProcessedThroughDigest: result.processedThroughDigest, + onEntry: () => {}, + })).rejects.toMatchObject({ + code: "usage_ledger_rebuild_required", + reason: "content_changed", + }); + }); + + test("rejects a shrink while the scanner is yielded", async () => { + writeFileSync( + usageLogPath(), + Array.from({ length: 1_500 }, (_, index) => line(`old-${index}`)).join(""), + ); + const scan = scanUsageLedgerCooperatively({ chunkBytes: 128, onEntry: () => {} }); + queueMicrotask(() => writeFileSync(usageLogPath(), line("replacement"))); + + await expect(scan).rejects.toMatchObject({ + code: "usage_ledger_rebuild_required", + reason: "shrink", + }); + }); + + test("rejects when the path is replaced while the original descriptor stays readable", async () => { + writeFileSync( + usageLogPath(), + Array.from({ length: 1_500 }, (_, index) => line(`old-${index}`)).join(""), + ); + const scan = scanUsageLedgerCooperatively({ chunkBytes: 128, onEntry: () => {} }); + queueMicrotask(() => { + renameSync(usageLogPath(), `${usageLogPath()}.old`); + writeFileSync(usageLogPath(), line("replacement")); + }); + + await expect(scan).rejects.toMatchObject({ + code: "usage_ledger_rebuild_required", + reason: "identity_mismatch", + }); + }); + + test("rejects a same-inode rewrite plus growth instead of publishing a mixed snapshot", async () => { + writeFileSync( + usageLogPath(), + Array.from({ length: 1_500 }, (_, index) => line(`old-${String(index).padStart(4, "0")}`)).join(""), + ); + const scan = scanUsageLedgerCooperatively({ chunkBytes: 128, onEntry: () => {} }); + queueMicrotask(() => { + writeFileSync( + usageLogPath(), + Array.from({ length: 1_600 }, (_, index) => line(`new-${String(index).padStart(4, "0")}`)).join(""), + ); + }); + + await expect(scan).rejects.toMatchObject({ + code: "usage_ledger_rebuild_required", + reason: "content_changed", + }); + }); + + test("honors an existing abort and an abort delivered at a cooperative yield", async () => { + const beforeStart = new AbortController(); + const beforeStartReason = new Error("stop-before-start"); + beforeStart.abort(beforeStartReason); + await expect(scanUsageLedgerCooperatively({ + signal: beforeStart.signal, + onEntry: () => {}, + })).rejects.toBe(beforeStartReason); + + writeFileSync( + usageLogPath(), + Array.from({ length: 1_500 }, (_, index) => line(`abort-${index}`)).join(""), + ); + const duringScan = new AbortController(); + const duringScanReason = new Error("stop-during-scan"); + let callbacks = 0; + const scan = scanUsageLedgerCooperatively({ + signal: duringScan.signal, + chunkBytes: 128, + onEntry: () => { callbacks += 1; }, + }); + queueMicrotask(() => duringScan.abort(duringScanReason)); + + await expect(scan).rejects.toBe(duringScanReason); + expect(callbacks).toBeGreaterThan(0); + expect(callbacks).toBeLessThan(1_500); + }); + + test("propagates accumulator failures instead of misclassifying them as invalid rows", async () => { + writeFileSync(usageLogPath(), line("callback-error")); + const sentinel = new Error("accumulator failed"); + + await expect(scanUsageLedgerCooperatively({ + onEntry: () => { throw sentinel; }, + })).rejects.toBe(sentinel); + }); +}); diff --git a/tests/usage-summary.test.ts b/tests/usage-summary.test.ts index 8917e05f6a..17db7ce4a4 100644 --- a/tests/usage-summary.test.ts +++ b/tests/usage-summary.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "bun:test"; import { MAX_USAGE_MODEL_BREAKDOWN_ROWS, + MAX_USAGE_DAY_BUCKETS, USAGE_RANGES, USAGE_SURFACES, + createUsageSummaryAccumulator, parseRange, parseUsageSurface, rangeWindow, @@ -890,6 +892,24 @@ describe("summarizeUsage", () => { expect(month.summary.totalTokens).toBe(4); }); + test("range filtering compares numeric day boundaries for years before 1000", () => { + const ancient = Date.UTC(999, 0, 1, 12, 0, 0); + const entries: PersistedUsageEntry[] = [ + entry({ ts: FIXED_NOW - 1, requestId: "current", usageStatus: "reported", usage: { inputTokens: 1, outputTokens: 1 }, totalTokens: 2 }), + entry({ ts: ancient, requestId: "ancient", usageStatus: "reported", usage: { inputTokens: 10, outputTokens: 10 }, totalTokens: 20 }), + ]; + + const month = summarizeUsage(entries, "30d", FIXED_NOW); + expect(month.summary.requests).toBe(1); + expect(month.summary.totalTokens).toBe(2); + expect(month.models.every(model => model.totalTokens !== 20)).toBe(true); + + const all = summarizeUsage(entries, "all", FIXED_NOW); + expect(all.summary.requests).toBe(2); + expect(all.summary.totalTokens).toBe(22); + expect(all.days).toHaveLength(MAX_USAGE_DAY_BUCKETS); + }); + test("coverageRatio stays in [0,1] and handles empty input", () => { expect(summarizeUsage([], "30d", FIXED_NOW).summary.coverageRatio).toBe(0); const onlyMissing = summarizeUsage([entry({ ts: FIXED_NOW - 1, usageStatus: "unreported" })], "30d", FIXED_NOW); @@ -1522,3 +1542,294 @@ describe("summarizeUsage", () => { }); }); + +describe("UsageSummaryAccumulator modes", () => { + const at = Date.UTC(2026, 5, 28, 10, 0, 0); + + test("exact mode preserves cross-partition request identity", () => { + const accumulator = createUsageSummaryAccumulator(); + accumulator.add(entry({ + ts: at - 3_600_000, + requestId: "duplicate-request", + accountLogLabel: "pabcdef", + usageStatus: "reported", + usage: { inputTokens: 10, outputTokens: 2 }, + })); + accumulator.add(entry({ + ts: at, + requestId: "duplicate-request", + surface: "claude", + accountLogLabel: "pabcdef", + usageStatus: "reported", + usage: { inputTokens: 20, outputTokens: 3 }, + })); + + const summary = accumulator.summarize("all", at); + expect(summary.summary.requests).toBe(2); + expect(summary.days.find(day => day.requests > 0)).toMatchObject({ + requests: 2, + totalTokens: 35, + models: [{ requests: 1, attemptCount: 2, totalTokens: 35 }], + }); + expect(summary.models[0]).toMatchObject({ requests: 1, attemptCount: 2, totalTokens: 35 }); + expect(summary.providers[0]).toMatchObject({ requests: 1, attemptCount: 2, totalTokens: 35 }); + expect(summary.accounts[0]).toMatchObject({ requests: 1, attemptCount: 2, totalTokens: 35 }); + }); + + test("row-unique mode matches exact mode for unique ledger rows", () => { + const rows = [ + entry({ + ts: at - 86_400_000, + requestId: "unique-1", + accountLogLabel: "pabcdef", + usageStatus: "reported", + usage: { inputTokens: 100, outputTokens: 10 }, + }), + entry({ + ts: at, + requestId: "unique-2", + surface: "claude", + provider: "combo", + model: "combo/native", + usageStatus: "reported", + usage: { inputTokens: 70, outputTokens: 7 }, + totalTokens: 77, + attempts: [ + { + ordinal: 1, + provider: "openai", + model: "gpt-5.5", + adapter: "openai-responses", + status: 200, + durationMs: 10, + sendCount: 1, + recoveryKinds: [], + accountLogLabel: "pabcdef", + usageStatus: "reported", + usage: { inputTokens: 50, outputTokens: 5 }, + totalTokens: 55, + }, + { + ordinal: 2, + provider: "unpriced-provider", + model: "unpriced-model", + adapter: "openai-responses", + status: 200, + durationMs: 20, + sendCount: 1, + recoveryKinds: [], + accountLogLabel: "p123abc", + usageStatus: "estimated", + usage: { inputTokens: 20, outputTokens: 2 }, + totalTokens: 22, + }, + ], + }), + ]; + const exact = createUsageSummaryAccumulator(); + const compact = createUsageSummaryAccumulator({ mode: "row-unique" }); + for (const row of rows) { + exact.add(row); + compact.add(row); + } + + expect(compact.summarize("all", at)).toEqual(exact.summarize("all", at)); + }); + + test("row-unique mode counts a same-model/provider/account retry once", () => { + const accumulator = createUsageSummaryAccumulator({ mode: "row-unique" }); + accumulator.add(entry({ + ts: at, + requestId: "same-dimension-retry", + provider: "combo", + model: "combo/native", + usageStatus: "reported", + usage: { inputTokens: 30, outputTokens: 3 }, + totalTokens: 33, + attempts: [1, 2].map(ordinal => ({ + ordinal, + provider: "openai", + model: "gpt-5.5", + adapter: "openai-responses", + status: 200, + durationMs: 10, + sendCount: 1, + recoveryKinds: [], + accountLogLabel: "pabcdef" as const, + usageStatus: "reported" as const, + usage: { inputTokens: 15, outputTokens: ordinal }, + })), + })); + + const summary = accumulator.summarize("30d", at); + expect(summary.models[0]).toMatchObject({ requests: 1, attemptCount: 2 }); + expect(summary.providers[0]).toMatchObject({ requests: 1, attemptCount: 2 }); + expect(summary.accounts[0]).toMatchObject({ requests: 1, attemptCount: 2 }); + }); + + test("row-unique overflow folds a multi-model request only once", () => { + const rows = Array.from({ length: MAX_USAGE_MODEL_BREAKDOWN_ROWS - 1 }, (_, index) => entry({ + ts: at + index, + requestId: `overflow-head-${index}`, + provider: "head-provider", + model: `head-model-${String(index).padStart(3, "0")}`, + usageStatus: "reported", + usage: { inputTokens: 1, outputTokens: 1 }, + })); + rows.push(entry({ + ts: at + MAX_USAGE_MODEL_BREAKDOWN_ROWS, + requestId: "overflow-combo", + provider: "combo", + model: "combo/native", + usageStatus: "reported", + attempts: [ + { + ordinal: 1, + provider: "openai", + model: "gpt-5.5", + adapter: "openai-responses", + status: 200, + durationMs: 10, + sendCount: 1, + recoveryKinds: [], + usageStatus: "reported", + usage: { inputTokens: 10, outputTokens: 1 }, + }, + { + ordinal: 2, + provider: "unpriced-provider", + model: "tail-unpriced", + adapter: "openai-responses", + status: 200, + durationMs: 10, + sendCount: 1, + recoveryKinds: [], + usageStatus: "unreported", + }, + ], + })); + const exact = createUsageSummaryAccumulator(); + const compact = createUsageSummaryAccumulator({ mode: "row-unique" }); + for (const row of rows) { + exact.add(row); + compact.add(row); + } + + const exactSummary = exact.summarize("30d", at + MAX_USAGE_MODEL_BREAKDOWN_ROWS); + const compactSummary = compact.summarize("30d", at + MAX_USAGE_MODEL_BREAKDOWN_ROWS); + expect(compactSummary).toEqual(exactSummary); + const other = compactSummary.models.find(model => model.model === "other"); + expect(other).toMatchObject({ + requests: 1, + attemptCount: 2, + measuredRequests: 0, + reportedRequests: 0, + pricedRequests: 1, + unpricedRequests: 1, + }); + const dayOther = compactSummary.days.find(day => day.requests > 0)?.models + .find(model => model.model === "other"); + expect(dayOther).toMatchObject({ requests: 1, attemptCount: 2 }); + }); + + test("filtered compact overflow preserves projection compatibility", () => { + const accumulator = createUsageSummaryAccumulator({ + mode: "row-unique", + filter: { provider: "rare-provider" }, + }); + const rows: PersistedUsageEntry[] = []; + for (let index = 0; index < MAX_USAGE_MODEL_BREAKDOWN_ROWS + 1; index++) { + const row = entry({ + ts: at + index, + requestId: `filtered-overflow-${index}`, + provider: "rare-provider", + model: `rare-model-${index}`, + usageStatus: "reported", + usage: { inputTokens: 1, outputTokens: 1 }, + }); + rows.push(row); + accumulator.add(row); + } + + const summary = accumulator.summarize("30d", at + MAX_USAGE_MODEL_BREAKDOWN_ROWS); + expect(summary.models).toHaveLength(MAX_USAGE_MODEL_BREAKDOWN_ROWS - 1); + expect(summary.models.some(model => model.model === "other")).toBe(false); + expect(summary.days.find(day => day.requests > 0)?.models.some(model => model.model === "other")).toBe(false); + + const base = summarizeUsage(rows, "30d", at + MAX_USAGE_MODEL_BREAKDOWN_ROWS); + expect(summary).toEqual(projectUsageSummary(base, { provider: "rare-provider" }, rows)); + }); + + test("clone mutations do not affect the source", () => { + const source = createUsageSummaryAccumulator({ mode: "row-unique" }); + source.add(entry({ ts: at, requestId: "clone-source" })); + const before = source.summarize("30d", at); + const cloned = source.clone(); + cloned.add(entry({ ts: at + 1, requestId: "clone-only" })); + + expect(source.summarize("30d", at)).toEqual(before); + expect(cloned.summarize("30d", at).summary.requests).toBe(2); + expect(cloned.estimatedBytes).toBeGreaterThanOrEqual(source.estimatedBytes); + }); + + test("estimatedBytes stays constant for ordinary compact rows in existing dimensions", () => { + const compact = createUsageSummaryAccumulator({ mode: "row-unique" }); + const exact = createUsageSummaryAccumulator(); + const first = entry({ + ts: at, + requestId: "estimate-1", + accountLogLabel: "pabcdef", + usageStatus: "reported", + usage: { inputTokens: 1, outputTokens: 1 }, + }); + const second = entry({ ...first, ts: at + 1, requestId: "estimate-2" }); + compact.add(first); + exact.add(first); + const compactAfterFirst = compact.estimatedBytes; + compact.add(second); + exact.add(second); + + expect(compact.estimatedBytes).toBe(compactAfterFirst); + expect(exact.estimatedBytes).toBeGreaterThan(compact.estimatedBytes); + }); + + test("estimatedBytes aggregates repeated multi-model overlap signatures", () => { + const compact = createUsageSummaryAccumulator({ mode: "row-unique" }); + const combo = (index: number): PersistedUsageEntry => entry({ + ts: at + index, + requestId: `repeated-overlap-${index}`, + provider: "combo", + model: "combo/native", + usageStatus: "reported", + attempts: [ + { + ordinal: 1, + provider: "unpriced-a", + model: "model-a", + adapter: "openai-responses", + status: 200, + durationMs: 10, + sendCount: 1, + recoveryKinds: [], + usageStatus: "reported", + }, + { + ordinal: 2, + provider: "unpriced-b", + model: "model-b", + adapter: "openai-responses", + status: 200, + durationMs: 10, + sendCount: 1, + recoveryKinds: [], + usageStatus: "reported", + }, + ], + }); + compact.add(combo(0)); + const firstSignatureBytes = compact.estimatedBytes; + for (let index = 1; index <= 100; index++) compact.add(combo(index)); + + expect(compact.estimatedBytes).toBe(firstSignatureBytes); + }); +});