Skip to content

Commit 21349b5

Browse files
committed
Release cdxusage 0.1.2 audit fixes
1 parent be06b30 commit 21349b5

13 files changed

Lines changed: 307 additions & 58 deletions

README.md

Lines changed: 17 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,8 @@ Default pricing mode is `auto`.
8888

8989
`cdxusage` reads the resolved Codex home's `config.toml`. If it sees
9090
`service_tier = "priority"` or legacy `service_tier = "fast"`, it applies
91-
OpenAI priority prices to the Codex models that currently expose priority rates
92-
in Codex usage:
93-
94-
```text
95-
gpt-5.4,gpt-5.4-mini,gpt-5.5
96-
```
91+
OpenAI priority prices to all OpenAI models that currently expose official
92+
priority rates.
9793

9894
Force standard pricing:
9995

@@ -157,36 +153,27 @@ Intentional differences from `@ccusage/codex`:
157153

158154
## Benchmarks
159155

160-
Measured on the same local Codex archive and bounded with `nice`, `ionice`, and
161-
`timeout`.
162-
163-
Controlled largest-file fixture:
164-
165-
| Tool | Scenario | Time | RSS |
166-
| --- | --- | ---: | ---: |
167-
| `@ccusage/codex@18.0.11` | largest JSONL | `2.59s` | `823972KB` |
168-
| `cdxusage` | same file, cold | `0.66s` | `200888KB` |
169-
| `cdxusage` | same file, warm | `0.07s` | `60996KB` |
156+
Run the local benchmark helper against your own Codex archive:
170157

171-
Improvements:
172-
173-
- cold: 74.5% faster, 75.6% less RSS
174-
- warm: 97.3% faster, 92.6% less RSS
158+
```bash
159+
npm run benchmark -- --since 2026-05-01 --timeout 25
160+
```
175161

176-
Token totals and `costUSD` matched upstream exactly on this fixture.
162+
It runs `cdxusage` cold, `cdxusage` warm, and
163+
`npx -y @ccusage/codex@latest` with the same date filter. On Linux it also
164+
captures max RSS through `/usr/bin/time -v`.
177165

178-
Real filtered profile:
166+
Recent local sanity check on a large Codex archive:
179167

180168
| Tool | Scenario | Time | RSS | Result |
181169
| --- | --- | ---: | ---: | --- |
182-
| `@ccusage/codex@18.0.11` | `--since 2026-05-01`, timeout 120s | `120.01s` | n/a | timed out |
183-
| `cdxusage` | same filter, cold | `4.35s` | `201528KB` | complete |
184-
| `cdxusage` | same filter, warm | `0.21s` | `92204KB` | complete |
185-
186-
Compared with upstream's bounded timeout, real-profile cold output is at least
187-
96.4% faster and warm output is at least 99.8% faster. Upstream did not
188-
complete in the timeout window, so real-profile upstream RSS is not comparable;
189-
the completed controlled fixture above is the RSS comparison.
170+
| `@ccusage/codex@18.0.11` | `--since 2026-05-01`, timeout 25s | `>25.01s` | n/a | timed out |
171+
| `cdxusage` | same filter, cold | `3.63s` | `173784KB` | complete |
172+
| `cdxusage` | same filter, warm | `0.24s` | `96856KB` | complete |
173+
174+
Compared with upstream's bounded timeout, this run was at least 85.5% faster
175+
cold and 99.0% faster warm. Upstream did not complete in the timeout window, so
176+
upstream RSS is not comparable for this real-profile check.
190177

191178
Live pricing status from a fresh check: OpenAI official + bundled fallback,
192179
`modelCount: 63`. Non-OpenAI routes, if present in local Codex logs, are left

docs/compatibility.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -137,10 +137,11 @@ length without storing every token event in memory or cache.
137137

138138
Default pricing is `auto`. It reads the resolved Codex home `config.toml` and
139139
treats `service_tier = "priority"` or legacy `"fast"` as priority pricing.
140-
Codex logs do not expose per-request service tier, so auto mode prices only
141-
`gpt-5.4,gpt-5.4-mini,gpt-5.5` as priority by default. Use `--no-priority` or
142-
`--speed standard` to force non-priority pricing. Use `--priority-models all`
143-
or a comma-separated list to override the priority scope.
140+
Codex logs do not expose per-request service tier, so auto mode prices all
141+
OpenAI models with official priority rates as priority when the resolved config
142+
sets priority/fast mode. Use `--no-priority` or `--speed standard` to force
143+
non-priority pricing. Use `--priority-models all` or a comma-separated list to
144+
override the priority scope.
144145

145146
Non-OpenAI model routes are not estimated. They appear in
146147
`pricing.missingModels` when `--include-stats` is used.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "cdxusage",
3-
"version": "0.1.1",
3+
"version": "0.1.2",
44
"type": "module",
55
"description": "Fast Codex usage, token, and estimated cost CLI.",
66
"bin": {
@@ -11,13 +11,14 @@
1111
},
1212
"scripts": {
1313
"start": "node ./bin/cdxusage.mjs",
14-
"check": "node --check ./bin/cdxusage.mjs && node --check ./src/cli.mjs && node --check ./src/codex-home.mjs && node --check ./src/engine.mjs && node --check ./src/format.mjs && node --check ./src/pricing.mjs && node --check ./src/table.mjs && node --check ./scripts/build-portable.mjs && node --check ./test/smoke.mjs",
14+
"check": "node --check ./bin/cdxusage.mjs && node --check ./src/cli.mjs && node --check ./src/codex-home.mjs && node --check ./src/engine.mjs && node --check ./src/format.mjs && node --check ./src/pricing.mjs && node --check ./src/table.mjs && node --check ./scripts/build-portable.mjs && node --check ./scripts/benchmark-local.mjs && node --check ./test/smoke.mjs",
1515
"lint": "npm run check",
1616
"typecheck": "npm run check",
1717
"test": "node ./test/codex-home.test.mjs && node ./test/engine.test.mjs && node ./test/cli.test.mjs && node ./test/pricing.test.mjs",
1818
"smoke": "node ./test/smoke.mjs",
1919
"portable:build": "node ./scripts/build-portable.mjs",
20-
"portable:smoke": "npm run portable:build && sh ./portable/cdxusage --version && node -e \"const fs=require('fs'); for (const file of ['portable/cdxusage.cmd','portable/cdxusage.ps1','portable/src/codex-home.mjs']) if (!fs.existsSync(file)) process.exit(1)\" && node ./test/smoke.mjs --portable"
20+
"portable:smoke": "npm run portable:build && sh ./portable/cdxusage --version && node -e \"const fs=require('fs'); for (const file of ['portable/LICENSE','portable/cdxusage.cmd','portable/cdxusage.ps1','portable/src/codex-home.mjs']) if (!fs.existsSync(file)) process.exit(1)\" && node ./test/smoke.mjs --portable",
21+
"benchmark": "node ./scripts/benchmark-local.mjs"
2122
},
2223
"files": [
2324
"bin",

scripts/benchmark-local.mjs

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
#!/usr/bin/env node
2+
import { spawn } from 'node:child_process';
3+
import { mkdtemp, rm } from 'node:fs/promises';
4+
import { tmpdir } from 'node:os';
5+
import path from 'node:path';
6+
import { fileURLToPath } from 'node:url';
7+
8+
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
9+
const repoRoot = path.resolve(scriptDir, '..');
10+
const args = parseArgs(process.argv.slice(2));
11+
const timeoutSeconds = Number(args.timeout ?? 25);
12+
const since = args.since ?? '2026-05-01';
13+
const workDir = await mkdtemp(path.join(tmpdir(), 'cdxusage-bench-'));
14+
15+
try {
16+
const cacheFile = path.join(workDir, 'index.json');
17+
const pricingCacheFile = path.join(workDir, 'pricing.json');
18+
const commonCdxArgs = [
19+
path.join(repoRoot, 'bin/cdxusage.mjs'),
20+
'monthly',
21+
'--offline',
22+
'--json',
23+
'--since',
24+
since,
25+
'--cache-file',
26+
cacheFile,
27+
'--pricing-cache-file',
28+
pricingCacheFile,
29+
];
30+
const rows = [];
31+
rows.push(await measure('cdxusage cold', process.execPath, [...commonCdxArgs, '--clear-cache'], timeoutSeconds));
32+
rows.push(await measure('cdxusage warm', process.execPath, commonCdxArgs, timeoutSeconds));
33+
rows.push(await measure('upstream latest', 'npx', ['-y', '@ccusage/codex@latest', 'monthly', '--since', since, '--json'], timeoutSeconds));
34+
printRows(rows);
35+
} finally {
36+
await rm(workDir, { recursive: true, force: true });
37+
}
38+
39+
function parseArgs(argv) {
40+
const out = {};
41+
for (let index = 0; index < argv.length; index += 1) {
42+
const token = argv[index];
43+
const [key, inlineValue] = token.split('=', 2);
44+
if (key === '--since' || key === '--timeout') {
45+
out[key.slice(2)] = inlineValue ?? argv[++index];
46+
}
47+
}
48+
return out;
49+
}
50+
51+
async function measure(label, command, commandArgs, timeoutSeconds) {
52+
const start = process.hrtime.bigint();
53+
const hasTime = process.platform !== 'win32';
54+
const finalCommand = hasTime ? '/usr/bin/time' : command;
55+
const finalArgs = hasTime
56+
? ['-v', 'timeout', `${timeoutSeconds}s`, command, ...commandArgs]
57+
: commandArgs;
58+
const result = await run(finalCommand, finalArgs);
59+
const wallSeconds = Number(process.hrtime.bigint() - start) / 1_000_000_000;
60+
const maxRssKb = parseMaxRss(result.stderr);
61+
return {
62+
label,
63+
status: result.status,
64+
timedOut: result.status === 124,
65+
wallSeconds,
66+
maxRssKb,
67+
};
68+
}
69+
70+
function run(command, args) {
71+
return new Promise((resolve) => {
72+
const child = spawn(command, args, { cwd: repoRoot, stdio: ['ignore', 'ignore', 'pipe'] });
73+
let stderr = '';
74+
child.stderr.setEncoding('utf8');
75+
child.stderr.on('data', (chunk) => {
76+
stderr += chunk;
77+
});
78+
child.on('close', (status) => resolve({ status, stderr }));
79+
});
80+
}
81+
82+
function parseMaxRss(stderr) {
83+
const match = stderr.match(/Maximum resident set size \(kbytes\):\s*(\d+)/);
84+
return match ? Number(match[1]) : null;
85+
}
86+
87+
function printRows(rows) {
88+
console.log('| Tool | Time | RSS | Result |');
89+
console.log('| --- | ---: | ---: | --- |');
90+
for (const row of rows) {
91+
const time = row.timedOut ? `>${row.wallSeconds.toFixed(2)}s` : `${row.wallSeconds.toFixed(2)}s`;
92+
const rss = row.maxRssKb == null ? 'n/a' : `${row.maxRssKb}KB`;
93+
const result = row.timedOut ? `timed out (${row.status})` : row.status === 0 ? 'complete' : `exit ${row.status}`;
94+
console.log(`| ${row.label} | ${time} | ${rss} | ${result} |`);
95+
}
96+
}

src/cli.mjs

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,29 @@ import { collectUsage, defaultCacheFile, normalizeDate, safeTimeZone } from './e
55
import { DEFAULT_LOCALE, DEFAULT_TIMEZONE, formatDisplayDate, formatDisplayMonth, toPublicModels, toPublicUsage } from './format.mjs';
66
import { renderReportTable } from './table.mjs';
77

8-
export const VERSION = '0.1.1';
8+
export const VERSION = '0.1.2';
99

1010
const COMMANDS = new Set(['daily', 'monthly', 'session', 'sessions']);
11-
const DEFAULT_AUTO_PRIORITY_MODELS = Object.freeze(['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.5']);
11+
const DEFAULT_AUTO_PRIORITY_MODELS = null;
12+
const LONG_OPTIONS_WITH_VALUES = new Set([
13+
'--since',
14+
'--until',
15+
'--timezone',
16+
'--locale',
17+
'--codex-home',
18+
'--sessions-dir',
19+
'--cache-file',
20+
'--pricing-cache-file',
21+
'--pricing-ttl-hours',
22+
'--pricing-fetch-timeout-ms',
23+
'--max-cache-bytes',
24+
'--discovery',
25+
'--discovery-mode',
26+
'--speed',
27+
'--priority-models',
28+
'--sort',
29+
'--order',
30+
]);
1231

1332
export async function main(argv = process.argv.slice(2), io = { stdout: process.stdout, stderr: process.stderr }) {
1433
let args;
@@ -117,7 +136,12 @@ export function parseArgs(argv) {
117136
args.commandSpecified = true;
118137
}
119138
while (rest.length > 0) {
120-
const token = rest.shift();
139+
let token = rest.shift();
140+
const expanded = splitLongOptionValue(token);
141+
if (expanded) {
142+
token = expanded.option;
143+
rest.unshift(expanded.value);
144+
}
121145
switch (token) {
122146
case '-h':
123147
case '--help':
@@ -480,6 +504,21 @@ function requireValue(option, rest) {
480504
return value;
481505
}
482506

507+
function splitLongOptionValue(token) {
508+
if (!token?.startsWith('--')) {
509+
return null;
510+
}
511+
const separatorIndex = token.indexOf('=');
512+
if (separatorIndex < 0) {
513+
return null;
514+
}
515+
const option = token.slice(0, separatorIndex);
516+
if (!LONG_OPTIONS_WITH_VALUES.has(option)) {
517+
return null;
518+
}
519+
return { option, value: token.slice(separatorIndex + 1) };
520+
}
521+
483522
function helpText(command) {
484523
if (!command) {
485524
return topLevelHelp();

src/engine.mjs

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ const TURN_NEEDLE = Buffer.from('turn_context');
1515
const MODEL_RE = /"model"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/;
1616
const FIND_FIELD_SEPARATOR = '\x1f';
1717
const FIND_RECORD_SEPARATOR = '\x1e';
18-
const PRUNE_SAFETY_MS = 48 * 60 * 60 * 1000;
1918
const DEFAULT_MAX_CACHE_BYTES = 64 * 1024 * 1024;
2019

2120
export function defaultCacheFile() {
@@ -30,7 +29,8 @@ export async function collectUsage(options = {}) {
3029
const timezone = safeTimeZone(options.timezone);
3130
const since = normalizeDate(options.since);
3231
const until = normalizeDate(options.until);
33-
const pruneBeforeMs = since ? Date.parse(`${since}T00:00:00.000Z`) - PRUNE_SAFETY_MS : undefined;
32+
const pruneBeforeDate = since ? addDaysToDateKey(since, -2) : undefined;
33+
const pruneAfterDate = until ? addDaysToDateKey(until, 2) : undefined;
3434
const cacheFile = options.cacheFile ?? defaultCacheFile();
3535
const useCache = options.useCache !== false;
3636
const saveCache = useCache && options.saveCache !== false;
@@ -69,17 +69,17 @@ export async function collectUsage(options = {}) {
6969
stats.filesSeen += 1;
7070
stats.bytesSeen += st.size;
7171

72-
if (pruneBeforeMs !== undefined && st.mtimeMs < pruneBeforeMs) {
73-
stats.filesSkippedByMtime += 1;
74-
stats.bytesSkippedByMtime += st.size;
72+
const sessionInfo = makeSessionInfo(file, sessionsDir);
73+
if (shouldSkipBySessionPathDate(sessionInfo.directory, pruneBeforeDate, pruneAfterDate)) {
74+
stats.filesSkippedByPathDate += 1;
75+
stats.bytesSkippedByPathDate += st.size;
7576
const oldEntry = useCache ? cache.files[file] : undefined;
7677
if (oldEntry && sameFile(st, oldEntry)) {
7778
nextFiles[file] = oldEntry;
7879
}
7980
continue;
8081
}
8182

82-
const sessionInfo = makeSessionInfo(file, sessionsDir);
8383
const oldEntry = useCache ? cache.files[file] : undefined;
8484
let entry;
8585

@@ -604,7 +604,7 @@ function buildReport(aggregate, stats, nextFiles) {
604604
addUsage(totals, day);
605605
}
606606

607-
const filesEligibleForCache = stats.filesSeen - stats.filesSkippedByMtime;
607+
const filesEligibleForCache = stats.filesSeen - stats.filesSkippedByMtime - stats.filesSkippedByPathDate;
608608
return {
609609
daily,
610610
monthly,
@@ -776,6 +776,7 @@ function createStats(cache, timezone, billingThresholds = DEFAULT_BILLING_THRESH
776776
billingThresholds,
777777
filesSeen: 0,
778778
filesSkippedByMtime: 0,
779+
filesSkippedByPathDate: 0,
779780
filesFromCache: 0,
780781
filesScannedFull: 0,
781782
filesScannedTail: 0,
@@ -787,6 +788,7 @@ function createStats(cache, timezone, billingThresholds = DEFAULT_BILLING_THRESH
787788
bytesSeen: 0,
788789
bytesRead: 0,
789790
bytesSkippedByMtime: 0,
791+
bytesSkippedByPathDate: 0,
790792
bytesSkippedByCache: 0,
791793
bytesSkippedByTailCache: 0,
792794
cacheEntriesLoaded: Object.keys(cache.files).length,
@@ -867,9 +869,45 @@ export function isWithinRange(dateKey, since, until) {
867869
return true;
868870
}
869871

872+
function shouldSkipBySessionPathDate(directory, pruneBeforeDate, pruneAfterDate) {
873+
if (!pruneBeforeDate && !pruneAfterDate) {
874+
return false;
875+
}
876+
const dateKey = sessionPathDateKey(directory);
877+
if (!dateKey) {
878+
return false;
879+
}
880+
if (pruneBeforeDate && dateKey < pruneBeforeDate) {
881+
return true;
882+
}
883+
if (pruneAfterDate && dateKey > pruneAfterDate) {
884+
return true;
885+
}
886+
return false;
887+
}
888+
889+
function sessionPathDateKey(directory) {
890+
const match = String(directory).match(/(?:^|\/)(\d{4})\/(\d{2})\/(\d{2})(?:\/|$)/);
891+
if (!match) {
892+
return null;
893+
}
894+
const dateKey = `${match[1]}-${match[2]}-${match[3]}`;
895+
try {
896+
return normalizeDate(dateKey);
897+
} catch {
898+
return null;
899+
}
900+
}
901+
902+
function addDaysToDateKey(dateKey, days) {
903+
const [year, month, day] = dateKey.split('-').map(Number);
904+
const date = new Date(Date.UTC(year, month - 1, day + days));
905+
return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')}`;
906+
}
907+
870908
export function safeTimeZone(timezone) {
871909
if (timezone == null || String(timezone).trim() === '') {
872-
return Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
910+
return 'UTC';
873911
}
874912
try {
875913
Intl.DateTimeFormat('en-US', { timeZone: timezone });

0 commit comments

Comments
 (0)