From edfa8078627da54f49bd9e8eefe4e5dc4a0054ec Mon Sep 17 00:00:00 2001 From: moose-lab Date: Thu, 18 Jun 2026 12:01:33 +0800 Subject: [PATCH] perf: cache Codex cost session scans --- src/core/__tests__/cost-tracker.test.ts | 118 +++++++++++++++++++++++- src/core/cost-tracker.ts | 103 ++++++++++++++++++++- 2 files changed, 217 insertions(+), 4 deletions(-) diff --git a/src/core/__tests__/cost-tracker.test.ts b/src/core/__tests__/cost-tracker.test.ts index 08412bc..5ea6404 100644 --- a/src/core/__tests__/cost-tracker.test.ts +++ b/src/core/__tests__/cost-tracker.test.ts @@ -1,10 +1,12 @@ -import { mkdtemp, writeFile, rm } from "fs/promises"; +import { appendFile, mkdir, mkdtemp, rm, writeFile } from "fs/promises"; import { join } from "path"; import { tmpdir } from "os"; import { test } from "node:test"; import assert from "node:assert/strict"; +import * as costTracker from "../cost-tracker"; import { aggregateCostReport, + buildCostReport, computeCodexApiCostUSD, computeCodexSubscriptionCredits, scanCodexSession, @@ -31,6 +33,34 @@ async function withCodexJsonl(lines: unknown[], run: (filePath: string) => Promi } } +async function withCostDirs(run: (dirs: { claudeDir: string; codexDir: string; filePath: string }) => Promise) { + const dir = await mkdtemp(join(tmpdir(), "devlog-cost-cache-")); + const claudeDir = join(dir, "claude-projects"); + const codexDir = join(dir, "codex-sessions"); + const filePath = join(codexDir, "rollout-test.jsonl"); + + await mkdir(claudeDir, { recursive: true }); + await mkdir(codexDir, { recursive: true }); + await writeFile( + filePath, + [ + JSON.stringify({ + timestamp: "2026-05-16T01:00:00.000Z", + type: "turn_context", + payload: { cwd: "/Users/moose/Moose/DevLog", model: "gpt-5.1-codex" }, + }), + JSON.stringify(tokenCount("2026-05-16T01:01:00.000Z", 1000, 0, 0, 0, 1000, 2, 8)), + ].join("\n"), + "utf-8" + ); + + try { + await run({ claudeDir, codexDir, filePath }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + test("computeCodexApiCostUSD prices input, cached input, and output tokens", () => { assert.equal(computeCodexApiCostUSD("gpt-5.1-codex", TOKENS), 11.375); }); @@ -39,6 +69,92 @@ test("computeCodexSubscriptionCredits uses the Codex token-based rate card", () assert.equal(computeCodexSubscriptionCredits("gpt-5.3-codex", TOKENS), 398.125); }); +test("buildCostReport reuses unchanged Codex session scans", async () => { + await withCostDirs(async ({ claudeDir, codexDir }) => { + const tools = costTracker as typeof costTracker & { + clearCostTrackerCache?: () => void; + getCostTrackerCacheStats?: () => { hits: number; misses: number; entries: number }; + }; + + assert.equal(typeof tools.clearCostTrackerCache, "function"); + assert.equal(typeof tools.getCostTrackerCacheStats, "function"); + tools.clearCostTrackerCache(); + + const first = await buildCostReport({ + claudeProjectsDir: claudeDir, + codexSessionsDir: codexDir, + now: new Date("2026-05-16T12:00:00.000Z"), + }); + assert.equal(first.totals.allTime.inputTokens, 1000); + assert.deepEqual(tools.getCostTrackerCacheStats(), { hits: 0, misses: 1, entries: 1 }); + + const second = await buildCostReport({ + claudeProjectsDir: claudeDir, + codexSessionsDir: codexDir, + now: new Date("2026-05-16T12:00:00.000Z"), + }); + assert.equal(second.totals.allTime.inputTokens, 1000); + assert.deepEqual(tools.getCostTrackerCacheStats(), { hits: 1, misses: 1, entries: 1 }); + }); +}); + +test("changed Codex session files are rescanned", async () => { + await withCostDirs(async ({ claudeDir, codexDir, filePath }) => { + const tools = costTracker as typeof costTracker & { + clearCostTrackerCache: () => void; + getCostTrackerCacheStats: () => { hits: number; misses: number; entries: number }; + }; + tools.clearCostTrackerCache(); + + await buildCostReport({ + claudeProjectsDir: claudeDir, + codexSessionsDir: codexDir, + now: new Date("2026-05-16T12:00:00.000Z"), + }); + await appendFile( + filePath, + `\n${JSON.stringify(tokenCount("2026-05-16T01:02:00.000Z", 2000, 0, 0, 0, 2000, 3, 9))}`, + "utf-8" + ); + + const report = await buildCostReport({ + claudeProjectsDir: claudeDir, + codexSessionsDir: codexDir, + now: new Date("2026-05-16T12:00:00.000Z"), + }); + + assert.equal(report.totals.allTime.inputTokens, 2000); + assert.deepEqual(tools.getCostTrackerCacheStats(), { hits: 0, misses: 2, entries: 1 }); + }); +}); + +test("Codex cost cache prunes deleted session files", async () => { + await withCostDirs(async ({ claudeDir, codexDir, filePath }) => { + const tools = costTracker as typeof costTracker & { + clearCostTrackerCache: () => void; + getCostTrackerCacheStats: () => { hits: number; misses: number; entries: number }; + }; + tools.clearCostTrackerCache(); + + await buildCostReport({ + claudeProjectsDir: claudeDir, + codexSessionsDir: codexDir, + now: new Date("2026-05-16T12:00:00.000Z"), + }); + assert.deepEqual(tools.getCostTrackerCacheStats(), { hits: 0, misses: 1, entries: 1 }); + + await rm(filePath); + const report = await buildCostReport({ + claudeProjectsDir: claudeDir, + codexSessionsDir: codexDir, + now: new Date("2026-05-16T12:00:00.000Z"), + }); + + assert.equal(report.totals.allTime.usageEvents, 0); + assert.deepEqual(tools.getCostTrackerCacheStats(), { hits: 0, misses: 1, entries: 0 }); + }); +}); + test("scanCodexSession emits token deltas and skips duplicate total snapshots", async () => { await withCodexJsonl( [ diff --git a/src/core/cost-tracker.ts b/src/core/cost-tracker.ts index d25fa55..6d54899 100644 --- a/src/core/cost-tracker.ts +++ b/src/core/cost-tracker.ts @@ -88,6 +88,15 @@ interface UsageRecord { subscriptionCredits: number; } +interface CachedCodexScan { + mtimeMs: number; + size: number; + parsed: { + records: UsageRecord[]; + quota: CostQuotaWindow[]; + }; +} + interface CodexRateLimitWindow { used_percent?: number; window_minutes?: number; @@ -129,6 +138,11 @@ const CODEX_CREDIT_RATES: Record(); +let codexCacheHits = 0; +let codexCacheMisses = 0; + export function getCodexSessionsDir(): string { return join(homedir(), ".codex", "sessions"); } @@ -141,6 +155,20 @@ export function emptyCostTotals(): CostPeriodTotals { return { ...EMPTY_TOTALS }; } +export function getCostTrackerCacheStats(): { hits: number; misses: number; entries: number } { + return { + hits: codexCacheHits, + misses: codexCacheMisses, + entries: codexScanCache.size, + }; +} + +export function clearCostTrackerCache(): void { + codexScanCache.clear(); + codexCacheHits = 0; + codexCacheMisses = 0; +} + export function addCostTotals(target: CostPeriodTotals, source: CostPeriodTotals | UsageRecord): void { const tokens = "tokens" in source ? source.tokens : source; target.usageEvents += "usageEvents" in source ? source.usageEvents : 1; @@ -180,10 +208,22 @@ export async function buildCostReport(options: { } } - const codexFiles = await listCodexSessionFiles(codexSessionsDir, codexSessionsDir === getCodexSessionsDir()); + const includeArchived = codexSessionsDir === getCodexSessionsDir(); + const codexFiles = await listCodexSessionFiles(codexSessionsDir, includeArchived); + const seenCodexFiles = new Set(); const quota: CostQuotaWindow[] = []; - for (const filePath of codexFiles) { - const parsed = await scanCodexSession(filePath); + + const parsedCodexSessions = await mapWithConcurrency( + codexFiles, + CODEX_SCAN_CONCURRENCY, + (filePath) => scanCodexSessionCached(filePath, seenCodexFiles) + ); + pruneCodexScanCache( + [codexSessionsDir, ...(includeArchived ? [getCodexArchivedSessionsDir()] : [])], + seenCodexFiles + ); + + for (const parsed of parsedCodexSessions) { records.push(...parsed.records); quota.push(...parsed.quota); } @@ -321,6 +361,63 @@ export async function scanCodexSession(filePath: string): Promise<{ records: Usa return { records, quota }; } +async function scanCodexSessionCached( + filePath: string, + seenFiles: Set +): Promise<{ records: UsageRecord[]; quota: CostQuotaWindow[] } | null> { + try { + const fileStat = await stat(filePath); + seenFiles.add(filePath); + + const cached = codexScanCache.get(filePath); + if (cached && cached.mtimeMs === fileStat.mtimeMs && cached.size === fileStat.size) { + codexCacheHits++; + return cached.parsed; + } + + codexCacheMisses++; + const parsed = await scanCodexSession(filePath); + codexScanCache.set(filePath, { + mtimeMs: fileStat.mtimeMs, + size: fileStat.size, + parsed, + }); + return parsed; + } catch { + return null; + } +} + +function pruneCodexScanCache(roots: string[], seen: Set): void { + const prefixes = roots.map((root) => (root.endsWith("/") ? root : `${root}/`)); + for (const key of codexScanCache.keys()) { + if (prefixes.some((prefix) => key.startsWith(prefix)) && !seen.has(key)) { + codexScanCache.delete(key); + } + } +} + +async function mapWithConcurrency( + items: T[], + limit: number, + fn: (item: T) => Promise +): Promise { + const results: (R | null)[] = new Array(items.length).fill(null); + let next = 0; + const workers = Array.from( + { length: Math.max(1, Math.min(limit, items.length)) }, + async () => { + for (;;) { + const index = next++; + if (index >= items.length) break; + results[index] = await fn(items[index]); + } + } + ); + await Promise.all(workers); + return results.filter((item): item is R => item !== null); +} + async function listCodexSessionFiles(root: string, includeArchived: boolean): Promise { const out: string[] = [];