diff --git a/src/providers/codex.ts b/src/providers/codex.ts index 8b7b46da..7191c6fe 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -554,12 +554,19 @@ async function discoverSessionsInDir(codexDir: string): Promise return sources } +// The model fields come off an unchecked JSON.parse cast, so a third-party +// rollout can carry a non-string `model`. It flows straight into calculateCost, +// which calls `.replace()` on it, so pick the first genuine string and never let +// a number/object/array through. +function firstModelString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === 'string' && value) return value + } + return undefined +} + function resolveModel(info: CodexEntry['payload'], sessionModel?: string): string { - return info?.model - ?? info?.info?.model - ?? info?.info?.model_name - ?? sessionModel - ?? 'gpt-5' + return firstModelString(info?.model, info?.info?.model, info?.info?.model_name, sessionModel) ?? 'gpt-5' } function createParser(source: SessionSource, seenKeys: Set): SessionParser { @@ -640,13 +647,17 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars if (typeof rawSessionCwd === 'string' && rawSessionCwd) sessionCwd = rawSessionCwd forkedFromId = entry.payload?.forked_from_id ?? '' if (forkedFromId && entry.timestamp) { - forkCutoff = new Date(new Date(entry.timestamp).getTime() + 5000).toISOString() + // An unparseable timestamp (a garbage string, or a non-string from + // the unchecked JSON.parse cast) makes `new Date(NaN).toISOString()` + // throw RangeError, which would sink this whole session to zero. + const forkBaseMs = new Date(entry.timestamp).getTime() + if (Number.isFinite(forkBaseMs)) forkCutoff = new Date(forkBaseMs + 5000).toISOString() } - sessionModel = entry.payload?.model ?? sessionModel + if (typeof entry.payload?.model === 'string') sessionModel = entry.payload.model continue } - if (entry.type === 'turn_context' && entry.payload?.model) { + if (entry.type === 'turn_context' && typeof entry.payload?.model === 'string') { sessionModel = entry.payload.model continue } diff --git a/tests/providers/codex.test.ts b/tests/providers/codex.test.ts index 1307d4fe..05f38e06 100644 --- a/tests/providers/codex.test.ts +++ b/tests/providers/codex.test.ts @@ -404,6 +404,54 @@ describe('codex provider - session discovery', () => { } }) + it('counts a forked rollout whose timestamp is unparseable instead of throwing it to zero', async () => { + // A forked session with a garbage (or non-string) timestamp used to make the + // fork-cutoff `new Date(NaN).toISOString()` throw RangeError, sinking the + // whole session's usage to zero. Same unchecked-JSON.parse class as cwd. + await writeSession(tmpDir, '2026-04-14', 'rollout-forked-badts.jsonl', [ + JSON.stringify({ + type: 'session_meta', + timestamp: 'not-a-real-timestamp', + payload: { cwd: '/Users/test/fork', session_id: 'sess-fork', model: 'gpt-5.5', originator: 't3code_desktop', forked_from_id: 'parent-1' }, + }), + tokenCount({ timestamp: '2026-04-14T10:01:00Z', last: { input: 100, output: 50 }, total: { total: 150 } }), + ]) + + const provider = createCodexProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(1) + + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(sessions[0]!, new Set()).parse()) calls.push(call) + expect(calls.length).toBeGreaterThan(0) + }) + + it('counts a rollout with a non-string model via the fallback instead of throwing', async () => { + // A non-string `model` used to ride sessionModel into calculateCost, which + // calls `.replace()` on it -> "model.replace is not a function" -> the whole + // session reads zero. It should fall back to a real model and be counted. + await writeSession(tmpDir, '2026-04-14', 'rollout-badmodel.jsonl', [ + JSON.stringify({ + type: 'session_meta', + timestamp: '2026-04-14T10:00:00Z', + payload: { cwd: '/Users/test/m', session_id: 'sess-badmodel', model: { name: 'gpt-5.5' }, originator: 't3code_desktop' }, + }), + tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }), + ]) + + const provider = createCodexProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(1) + + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(sessions[0]!, new Set()).parse()) calls.push(call) + expect(calls.length).toBeGreaterThan(0) + for (const call of calls) { + expect(typeof call.model).toBe('string') + expect(Number.isFinite(call.costUSD)).toBe(true) + } + }) + it('accepts session_meta lines larger than 16 KB (Codex CLI 0.128+)', async () => { // Codex CLI 0.128+ embeds the full base_instructions / system prompt in the // first session_meta line, often pushing it past 20 KB. Regression guard