Skip to content

Commit 14db99a

Browse files
authored
feat(codex): account for fast mode pricing (#996)
Add a Codex speed option that defaults to reading CODEX_HOME config.toml and treats service_tier priority or legacy fast as fast mode. This lets reports estimate costs correctly even though Codex token_count logs do not include the active service tier. Thread the resolved speed into Codex pricing, using model-specific LiteLLM fast multipliers when present and a Codex 2x fallback when pricing data has not caught up. Document the auto, fast, and standard options in the package README and Codex guides.
1 parent b5ec88b commit 14db99a

11 files changed

Lines changed: 326 additions & 27 deletions

File tree

apps/codex/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,19 @@ npx @ccusage/codex@latest monthly --json
7171

7272
# Session-level detailed report
7373
npx @ccusage/codex@latest session
74+
75+
# Override Codex fast-mode pricing detection
76+
npx @ccusage/codex@latest daily --speed fast
77+
npx @ccusage/codex@latest daily --speed standard
7478
```
7579

7680
Useful environment variables:
7781

7882
- `CODEX_HOME` – override the root directory that contains Codex session folders
7983
- `LOG_LEVEL` – control log verbosity (0 silent … 5 trace)
8084

85+
Speed pricing defaults to `--speed auto`, which reads `${CODEX_HOME:-~/.codex}/config.toml` and applies fast pricing when `service_tier = "priority"` or legacy `service_tier = "fast"` is configured. Fast mode uses the model-specific LiteLLM multiplier when available and otherwise falls back to 2x pricing. Use `--speed fast` or `--speed standard` when the session logs do not reflect the speed tier you want to price.
86+
8187
ℹ️ The CLI now relies on the model metadata recorded in each `turn_context`. Sessions emitted during early September 2025 that lack this metadata are skipped to avoid mispricing. Newer builds of the Codex CLI restore the model field, and aliases such as `gpt-5-codex` automatically resolve to the correct LiteLLM pricing entry.
8288
📦 For legacy JSONL files that never emitted `turn_context` metadata, the CLI falls back to treating the tokens as `gpt-5` so that usage still appears in reports (pricing is therefore approximate for those sessions). In JSON output you will also see `"isFallback": true` on those model entries.
8389

apps/codex/src/_shared-args.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ export const sharedArgs = {
3131
default: false,
3232
negatable: true,
3333
},
34+
speed: {
35+
type: 'string',
36+
description:
37+
'Cost speed tier: auto reads Codex config.toml service_tier; use standard or fast to override',
38+
default: 'auto',
39+
},
3440
compact: {
3541
type: 'boolean',
3642
description: 'Force compact table layout for narrow terminals',

apps/codex/src/codex-config.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { readFile } from 'node:fs/promises';
2+
import path from 'node:path';
3+
import process from 'node:process';
4+
import { Result } from '@praha/byethrow';
5+
import { CODEX_HOME_ENV, DEFAULT_CODEX_DIR } from './_consts.ts';
6+
import { logger } from './logger.ts';
7+
8+
export type CodexSpeed = 'standard' | 'fast';
9+
export type CodexSpeedOption = 'auto' | CodexSpeed;
10+
11+
type ParsedConfig = {
12+
profile?: string;
13+
serviceTier?: string;
14+
profiles: Map<string, { serviceTier?: string }>;
15+
};
16+
17+
function codexHome(): string {
18+
const value = process.env[CODEX_HOME_ENV]?.trim();
19+
return value == null || value === '' ? DEFAULT_CODEX_DIR : path.resolve(value);
20+
}
21+
22+
export function codexConfigPath(): string {
23+
return path.join(codexHome(), 'config.toml');
24+
}
25+
26+
function parseStringValue(value: string): string | undefined {
27+
const trimmed = value.trim();
28+
const quoted = /^"([^"]*)"|'([^']*)'/.exec(trimmed);
29+
if (quoted != null) {
30+
return quoted[1] ?? quoted[2];
31+
}
32+
33+
const bare = /^([^\s#]+)/.exec(trimmed);
34+
return bare?.[1];
35+
}
36+
37+
function profileNameFromSection(section: string): string | undefined {
38+
const match = /^profiles\.(?:"([^"]+)"|'([^']+)'|([\w-]+))$/.exec(section);
39+
return match?.[1] ?? match?.[2] ?? match?.[3];
40+
}
41+
42+
export function parseCodexConfig(content: string): ParsedConfig {
43+
const parsed: ParsedConfig = { profiles: new Map() };
44+
let section = '';
45+
46+
for (const rawLine of content.split(/\r?\n/)) {
47+
const line = rawLine.trim();
48+
if (line === '' || line.startsWith('#')) {
49+
continue;
50+
}
51+
52+
const sectionMatch = /^\[([^\]]+)\]$/.exec(line);
53+
if (sectionMatch != null) {
54+
section = sectionMatch[1]!.trim();
55+
continue;
56+
}
57+
58+
const assignmentIndex = line.indexOf('=');
59+
if (assignmentIndex === -1) {
60+
continue;
61+
}
62+
63+
const key = line.slice(0, assignmentIndex).trim();
64+
if (!/^[\w-]+$/.test(key)) {
65+
continue;
66+
}
67+
68+
const value = parseStringValue(line.slice(assignmentIndex + 1));
69+
if (value == null) {
70+
continue;
71+
}
72+
73+
if (section === '') {
74+
if (key === 'profile') {
75+
parsed.profile = value;
76+
} else if (key === 'service_tier') {
77+
parsed.serviceTier = value;
78+
}
79+
continue;
80+
}
81+
82+
if (key !== 'service_tier') {
83+
continue;
84+
}
85+
86+
const profileName = profileNameFromSection(section);
87+
if (profileName == null) {
88+
continue;
89+
}
90+
91+
const profile = parsed.profiles.get(profileName) ?? {};
92+
profile.serviceTier = value;
93+
parsed.profiles.set(profileName, profile);
94+
}
95+
96+
return parsed;
97+
}
98+
99+
function isFastServiceTier(serviceTier: string | undefined): boolean {
100+
const normalized = serviceTier?.trim().toLowerCase();
101+
return normalized === 'fast' || normalized === 'priority';
102+
}
103+
104+
export function speedFromCodexConfig(content: string): CodexSpeed {
105+
const parsed = parseCodexConfig(content);
106+
const profileServiceTier =
107+
parsed.profile == null ? undefined : parsed.profiles.get(parsed.profile)?.serviceTier;
108+
return isFastServiceTier(profileServiceTier ?? parsed.serviceTier) ? 'fast' : 'standard';
109+
}
110+
111+
export function normalizeSpeedOption(value: unknown): CodexSpeedOption {
112+
if (value == null || value === '') {
113+
return 'auto';
114+
}
115+
if (value === 'auto' || value === 'standard' || value === 'fast') {
116+
return value;
117+
}
118+
throw new Error('Invalid --speed value. Use auto, standard, or fast.');
119+
}
120+
121+
export async function resolveCodexSpeed(option: CodexSpeedOption): Promise<CodexSpeed> {
122+
if (option !== 'auto') {
123+
return option;
124+
}
125+
126+
const configPath = codexConfigPath();
127+
const configResult = await Result.try({
128+
try: readFile(configPath, 'utf8'),
129+
catch: (error) => error,
130+
});
131+
132+
if (Result.isFailure(configResult)) {
133+
logger.debug('Codex config not found or unreadable; using standard pricing', {
134+
configPath,
135+
error: configResult.error,
136+
});
137+
return 'standard';
138+
}
139+
140+
return speedFromCodexConfig(configResult.value);
141+
}
142+
143+
if (import.meta.vitest != null) {
144+
describe('Codex config speed resolution', () => {
145+
it('detects top-level priority service tier as fast', () => {
146+
expect(speedFromCodexConfig('service_tier = "priority"')).toBe('fast');
147+
});
148+
149+
it('detects legacy top-level fast service tier as fast', () => {
150+
expect(speedFromCodexConfig('service_tier = "fast"')).toBe('fast');
151+
});
152+
153+
it('uses the active profile service tier over the top-level service tier', () => {
154+
const content = [
155+
'profile = "work"',
156+
'service_tier = "priority"',
157+
'',
158+
'[profiles.work]',
159+
'service_tier = "flex"',
160+
].join('\n');
161+
162+
expect(speedFromCodexConfig(content)).toBe('standard');
163+
});
164+
165+
it('defaults to standard when no fast tier is configured', () => {
166+
expect(speedFromCodexConfig('model = "gpt-5.3-codex"')).toBe('standard');
167+
});
168+
169+
it('validates CLI speed options', () => {
170+
expect(normalizeSpeedOption(undefined)).toBe('auto');
171+
expect(normalizeSpeedOption('fast')).toBe('fast');
172+
expect(() => normalizeSpeedOption('slow')).toThrow('Invalid --speed value');
173+
});
174+
});
175+
}

apps/codex/src/commands/daily.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ import {
77
formatNumber,
88
ResponsiveTable,
99
} from '@ccusage/terminal/table';
10+
import { Result } from '@praha/byethrow';
1011
import { define } from 'gunshi';
1112
import pc from 'picocolors';
1213
import { DEFAULT_TIMEZONE } from '../_consts.ts';
1314
import { sharedArgs } from '../_shared-args.ts';
15+
import { normalizeSpeedOption, resolveCodexSpeed } from '../codex-config.ts';
1416
import { formatModelsList, splitUsageTokens } from '../command-utils.ts';
1517
import { buildDailyReport } from '../daily-report.ts';
1618
import { loadTokenUsageEvents } from '../data-loader.ts';
@@ -41,6 +43,16 @@ export const dailyCommand = define({
4143
process.exit(1);
4244
}
4345

46+
const speedOptionResult = Result.try({
47+
try: () => normalizeSpeedOption(ctx.values.speed),
48+
catch: (error) => error,
49+
})();
50+
if (Result.isFailure(speedOptionResult)) {
51+
logger.error(String(speedOptionResult.error));
52+
process.exit(1);
53+
}
54+
const speed = await resolveCodexSpeed(speedOptionResult.value);
55+
4456
const { events, missingDirectories } = await loadTokenUsageEvents();
4557

4658
for (const missing of missingDirectories) {
@@ -54,6 +66,7 @@ export const dailyCommand = define({
5466

5567
const pricingSource = new CodexPricingSource({
5668
offline: ctx.values.offline,
69+
speed,
5770
});
5871
try {
5972
const rows = await buildDailyReport(events, {

apps/codex/src/commands/monthly.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ import {
77
formatNumber,
88
ResponsiveTable,
99
} from '@ccusage/terminal/table';
10+
import { Result } from '@praha/byethrow';
1011
import { define } from 'gunshi';
1112
import pc from 'picocolors';
1213
import { DEFAULT_TIMEZONE } from '../_consts.ts';
1314
import { sharedArgs } from '../_shared-args.ts';
15+
import { normalizeSpeedOption, resolveCodexSpeed } from '../codex-config.ts';
1416
import { formatModelsList, splitUsageTokens } from '../command-utils.ts';
1517
import { loadTokenUsageEvents } from '../data-loader.ts';
1618
import { normalizeFilterDate } from '../date-utils.ts';
@@ -41,6 +43,16 @@ export const monthlyCommand = define({
4143
process.exit(1);
4244
}
4345

46+
const speedOptionResult = Result.try({
47+
try: () => normalizeSpeedOption(ctx.values.speed),
48+
catch: (error) => error,
49+
})();
50+
if (Result.isFailure(speedOptionResult)) {
51+
logger.error(String(speedOptionResult.error));
52+
process.exit(1);
53+
}
54+
const speed = await resolveCodexSpeed(speedOptionResult.value);
55+
4456
const { events, missingDirectories } = await loadTokenUsageEvents();
4557

4658
for (const missing of missingDirectories) {
@@ -56,6 +68,7 @@ export const monthlyCommand = define({
5668

5769
const pricingSource = new CodexPricingSource({
5870
offline: ctx.values.offline,
71+
speed,
5972
});
6073
try {
6174
const rows = await buildMonthlyReport(events, {

apps/codex/src/commands/session.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ import {
77
formatNumber,
88
ResponsiveTable,
99
} from '@ccusage/terminal/table';
10+
import { Result } from '@praha/byethrow';
1011
import { define } from 'gunshi';
1112
import pc from 'picocolors';
1213
import { DEFAULT_TIMEZONE } from '../_consts.ts';
1314
import { sharedArgs } from '../_shared-args.ts';
15+
import { normalizeSpeedOption, resolveCodexSpeed } from '../codex-config.ts';
1416
import { formatModelsList, splitUsageTokens } from '../command-utils.ts';
1517
import { loadTokenUsageEvents } from '../data-loader.ts';
1618
import {
@@ -46,6 +48,16 @@ export const sessionCommand = define({
4648
process.exit(1);
4749
}
4850

51+
const speedOptionResult = Result.try({
52+
try: () => normalizeSpeedOption(ctx.values.speed),
53+
catch: (error) => error,
54+
})();
55+
if (Result.isFailure(speedOptionResult)) {
56+
logger.error(String(speedOptionResult.error));
57+
process.exit(1);
58+
}
59+
const speed = await resolveCodexSpeed(speedOptionResult.value);
60+
4961
const { events, missingDirectories } = await loadTokenUsageEvents();
5062

5163
for (const missing of missingDirectories) {
@@ -61,6 +73,7 @@ export const sessionCommand = define({
6173

6274
const pricingSource = new CodexPricingSource({
6375
offline: ctx.values.offline,
76+
speed,
6477
});
6578
try {
6679
const rows = await buildSessionReport(events, {

0 commit comments

Comments
 (0)