Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 117 additions & 1 deletion src/core/__tests__/cost-tracker.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<void>) {
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);
});
Expand All @@ -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(
[
Expand Down
103 changes: 100 additions & 3 deletions src/core/cost-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -129,6 +138,11 @@ const CODEX_CREDIT_RATES: Record<string, { input: number; cachedInput: number; o
"gpt-5-codex": { input: 43.75, cachedInput: 4.375, output: 350 },
};

const CODEX_SCAN_CONCURRENCY = 8;
const codexScanCache = new Map<string, CachedCodexScan>();
let codexCacheHits = 0;
let codexCacheMisses = 0;

export function getCodexSessionsDir(): string {
return join(homedir(), ".codex", "sessions");
}
Expand All @@ -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;
Expand Down Expand Up @@ -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<string>();
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);
}
Expand Down Expand Up @@ -321,6 +361,63 @@ export async function scanCodexSession(filePath: string): Promise<{ records: Usa
return { records, quota };
}

async function scanCodexSessionCached(
filePath: string,
seenFiles: Set<string>
): 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<string>): 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<T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R | null>
): Promise<R[]> {
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<string[]> {
const out: string[] = [];

Expand Down
Loading