Skip to content
Open
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
13 changes: 12 additions & 1 deletion packages/cli/src/codex-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,18 @@ import type { ParsedProviderCall } from './providers/types.js'
// decodes only the new bytes instead of re-streaming the whole file.
// This is lossless: Codex rollout files are durable (never auto-deleted), so the
// one-time re-derive on first run under v8 rebuilds byte-identical data.
const CODEX_CACHE_VERSION = 8
// v9: tool-excluded active timing — per-call activeDurationMs /
// activeGeneratedTokens / toolWaitMs (a6bf81f). Cached calls lack the fields;
// bump once so unchanged sessions re-decode and pick them up.
// v10: the threaded decoder state now carries the task-timing window
// (taskResultStart / taskGeneratedTokens / taskToolIntervals / taskStartedAt /
// openToolStarts), so a task whose task_complete lands in an appended region
// attributes the three timing fields to the calls emitted in the earlier
// region — a parse that ended mid-task no longer strands them unattributed.
// v9 states lack the window; bump once so unchanged sessions re-decode with it.
// (The session-cache PROVIDER_PARSE_VERSIONS marker is bumped in lockstep so
// cached turns re-derive too.)
const CODEX_CACHE_VERSION = 10
const CACHE_FILE = 'codex-results.json'

type FileFingerprint = { mtimeMs: number; sizeBytes: number }
Expand Down
521 changes: 521 additions & 0 deletions packages/cli/src/codex-throughput.ts

Large diffs are not rendered by default.

27 changes: 24 additions & 3 deletions packages/cli/src/daily-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,28 @@ import { homedir } from 'os'
import { join } from 'path'
import type { DateRange, ProjectSummary } from './types.js'

// Bumped to 15: per-project daily rollups. Days and provider slices now carry
// Bumped to 17: dedup-key hygiene (#931, this PR). The codebuff, zerostack,
// pi/omp and grok decoders now thread a FINGERPRINT of the source path into
// their dedup keys (and lingtai-tui normalizes the model component) instead of
// the raw path / raw ledger text, because dedupKey ships on the observation
// envelope. Unchanged files are served from the session cache, whose dedup
// sets are seeded from the CACHED keys — so a warm cache built by the pre-fix
// binary keeps the raw-path keys, the same records re-ingest under the new
// key shape, totals go inconsistent, and the raw path stays on disk forever.
// The per-provider parse versions (session-cache.ts PROVIDER_PARSE_VERSIONS)
// force the session-cache re-parse that drops those keys; this bump forces the
// daily cache to re-derive every day whose sources survive instead of serving
// the pre-fix rollups. Raising MIN_SUPPORTED_VERSION to 17 makes a v16 file
// load as an old-version file rather than the trusted current cache.
//
// v16 is SKIPPED: main already spent it on the codex structural-discovery fix
// (eece4cf, #873/#626). A user who has ever run a main build owns a v16 cache
// containing only the codex fix; claiming 16 here too would load that file as
// CURRENT and COMPLETE, so the invalidation would never fire — the exact
// failure this bump exists to prevent. Claiming 17 instead sends that v16 file
// through the old-version adoption/re-derive path.
//
// v15: per-project daily rollups. Days and provider slices now carry
// a `projects` breakdown (cost/calls/savings/sessions per project) so project
// history outlives the session files, like models and categories already do.
// This bump is the first to ride the v14 carry-forward: the old cache is
Expand Down Expand Up @@ -57,8 +78,8 @@ import type { DateRange, ProjectSummary } from './types.js'
// that older binaries skipped. v8 added local-model savings to the daily
// rollup; the `savingsConfigHash` field is invalidated separately when the
// user changes their `localModelSavings` mapping.
export const DAILY_CACHE_VERSION = 15
const MIN_SUPPORTED_VERSION = 15
export const DAILY_CACHE_VERSION = 17
const MIN_SUPPORTED_VERSION = 17
// Version-suffixed so different binaries each own a distinct file and never
// clobber an incompatible schema. Bumping the version mints a fresh filename;
// adoptOlderDailyCaches then unions days out of every previous file (including
Expand Down
16 changes: 14 additions & 2 deletions packages/cli/src/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ export function showEmptyState(projectCount: number, scrollableHistory: boolean,
return historyProjectCount === 0 && !historyLoading
}

const MIN_WIDE = 90
// The By Model panel now carries six numeric columns. Keep panels stacked until
// each half has enough room for those columns instead of truncating Tok/s at
// ordinary 100–120 column terminals.
const MIN_WIDE = 130
const ORANGE = '#FF8C42'
const DIM = '#555555'
const GOLD = '#FFD700'
Expand Down Expand Up @@ -440,6 +443,7 @@ const MODEL_COL_COST = 8
const MODEL_COL_CACHE = 7
const MODEL_COL_CALLS = 7
const MODEL_COL_ONESHOT = 7
const MODEL_COL_TPS = 7
const MODEL_NAME_WIDTH = 14
const MIN_EDIT_TURNS_FOR_RATE = 5

Expand All @@ -449,6 +453,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
const modelTotals = aggregateModelTotals(projects)
const modelEfficiency = aggregateModelEfficiency(projects)
const anyEstimated = Object.values(modelTotals).some(d => d.estimatedCostUSD > 0)
const anyActiveTiming = Object.values(modelTotals).some(d => d.activeDurationMs > 0 && d.activeGeneratedTokens > 0)
const sorted = Object.entries(modelTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD)
const maxCost = sorted[0]?.[1]?.costUSD ?? 0
const unpriced = findUnpricedModels(Object.entries(modelTotals).map(([model, d]) => ({
Expand All @@ -460,7 +465,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:

return (
<Panel title="By Model" color={PANEL_COLORS.model} width={pw}>
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + MODEL_NAME_WIDTH)}{'cost'.padStart(MODEL_COL_COST)}{'cache'.padStart(MODEL_COL_CACHE)}{'calls'.padStart(MODEL_COL_CALLS)}{'1-shot'.padStart(MODEL_COL_ONESHOT)}</Text>
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + MODEL_NAME_WIDTH)}{'cost'.padStart(MODEL_COL_COST)}{'cache'.padStart(MODEL_COL_CACHE)}{'calls'.padStart(MODEL_COL_CALLS)}{'1-shot'.padStart(MODEL_COL_ONESHOT)}{'Tok/s'.padStart(MODEL_COL_TPS)}</Text>
{sorted.map(([model, data], i) => {
const totalInput = data.freshInput + data.cacheRead + data.cacheWrite
const cacheHit = totalInput > 0 ? (data.cacheRead / totalInput) * 100 : 0
Expand All @@ -469,6 +474,9 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
const oneShotLabel = efficiency && efficiency.editTurns >= MIN_EDIT_TURNS_FOR_RATE && efficiency.oneShotRate !== null
? `${efficiency.oneShotRate.toFixed(1)}%`
: '-'
const tpsLabel = data.activeDurationMs > 0 && data.activeGeneratedTokens > 0
? (data.activeGeneratedTokens / (data.activeDurationMs / 1000)).toFixed(1)
: '-'
return (
<Text key={`${model}-${i}`} wrap="truncate-end">
<HBar value={data.costUSD} max={maxCost} width={bw} />
Expand All @@ -477,6 +485,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
<Text>{cacheLabel.padStart(MODEL_COL_CACHE)}</Text>
<Text>{String(data.calls).padStart(MODEL_COL_CALLS)}</Text>
<Text>{oneShotLabel.padStart(MODEL_COL_ONESHOT)}</Text>
<Text>{tpsLabel.padStart(MODEL_COL_TPS)}</Text>
</Text>
)
})}
Expand All @@ -488,6 +497,9 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
{anyEstimated && (
<Text dimColor wrap="truncate-end">~ estimated cost (priced from estimated tokens)</Text>
)}
{anyActiveTiming && (
<Text dimColor wrap="truncate-end">~ Tok/s: generated tokens / active time; tool wait excluded</Text>
)}
</Panel>
)
}
Expand Down
107 changes: 106 additions & 1 deletion packages/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { installMenubarApp } from './menubar-installer.js'
import { exportCsv, exportJson, type PeriodExport } from './export.js'
import { findUnpricedModels, loadPricing, setModelAliases, setPriceOverrides, setLocalModelSavings, setProxyPaths, normalizeProxyPath } from './models.js'
import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache, setInteractiveScanUI } from './parser.js'
import { allProviderNames, getAllProviders } from './providers/index.js'
import { allProviderNames, getAllProviders, getProvider } from './providers/index.js'
import { convertCost, formatCost } from './currency.js'
import { renderStatusBar } from './format.js'
import { toDateString } from './daily-cache.js'
Expand Down Expand Up @@ -46,6 +46,7 @@ import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const { version } = require('../package.json')
import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js'
import { CodexThroughputReader, newestCodexSession, renderCodexThroughput } from './codex-throughput.js'

// A downstream reader that closes the pipe early (`| head`, quitting `less`, or
// a missing command) makes stdout writes fail with EPIPE. Exit cleanly rather
Expand All @@ -68,6 +69,22 @@ function parseInteger(value: string): number {
return parseInt(value, 10)
}

function parseCodexTpsLimit(value: string): number {
const parsed = Number(value)
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1 || parsed > 10000) {
throw new Error('limit must be an integer from 1 to 10000')
}
return parsed
}

function parseCodexTpsWatch(value: string): number {
const parsed = Number(value)
if (!Number.isFinite(parsed) || parsed < 0 || (parsed > 0 && parsed < 1) || parsed > 3600) {
throw new Error('watch must be 0 or at least 1 second (up to 3600 seconds)')
}
return parsed
}

type PriceOverrideConfig = NonNullable<CodeburnConfig['priceOverrides']>[string]

type PriceOverrideOptions = {
Expand Down Expand Up @@ -1843,6 +1860,94 @@ program
await runContextCommand(session, opts)
})

program
.command('codex-tps [session]')
.description('Retrospective Codex generated-tokens/sec estimate from rollout checkpoints (not live decode speed)')
.option('--json', 'JSON output')
.option('--limit <n>', 'Number of recent checkpoints to scan', parseCodexTpsLimit, 10)
.option('--watch <seconds>', 'Refresh continuously while Codex writes checkpoints', parseCodexTpsWatch, 0)
.action(async (session: string | undefined, opts: { json?: boolean; limit: number; watch: number }) => {
const intervalMs = Math.max(0, opts.watch) * 1000
if (opts.json && intervalMs > 0) {
process.stderr.write('codeburn codex-tps: --json cannot be combined with --watch; use text watch output or one-shot JSON.\n')
process.exitCode = 2
return
}
const provider = await getProvider('codex')
if (!provider) {
process.stderr.write('codeburn codex-tps: Codex provider is unavailable.\n')
process.exitCode = 1
return
}
let cachedPath: string | undefined = session
let throughputReader: CodexThroughputReader | undefined
let lastFileState: { size: number; mtimeMs: number } | undefined
let lastDiscoveryMs = 0
let refreshInFlight = false
const render = async (): Promise<void> => {
if (refreshInFlight) return
refreshInFlight = true
try {
let filePath = session ?? cachedPath
// Keep an idle watcher on its chosen rollout. A full active+archive
// discovery can be hundreds of milliseconds on large histories, so
// only re-scan slowly to notice rotation; disappearance still triggers
// an immediate discovery on the next tick.
if (!session && (!filePath || Date.now() - lastDiscoveryMs >= 60_000)) {
lastDiscoveryMs = Date.now()
filePath = await newestCodexSession(await provider.discoverSessions())
}
if (!filePath) {
process.stderr.write('codeburn codex-tps: no Codex rollout sessions found.\n')
if (intervalMs === 0) process.exitCode = 1
return
}
const previousPath = cachedPath
cachedPath = filePath
if (previousPath !== filePath || !throughputReader) throughputReader = new CodexThroughputReader()
const fileInfo = await import('node:fs/promises').then(fs => fs.stat(filePath)).catch(() => null)
if (!fileInfo) {
process.stderr.write(`codeburn codex-tps: session file not found: ${filePath}\n`)
if (intervalMs === 0) process.exitCode = 1
if (!session) cachedPath = undefined
return
}
if (intervalMs > 0 && lastFileState && fileInfo.size === lastFileState.size && fileInfo.mtimeMs === lastFileState.mtimeMs) return
lastFileState = { size: fileInfo.size, mtimeMs: fileInfo.mtimeMs }
const points = await throughputReader!.update(filePath, opts.limit, intervalMs === 0)
if (opts.json) {
process.stdout.write(JSON.stringify({ session: filePath, points, live: intervalMs > 0 }, null, 2) + '\n')
} else {
if (intervalMs > 0) process.stdout.write('\x1b[2J\x1b[H')
process.stdout.write(renderCodexThroughput(points, filePath) + (intervalMs > 0 ? '\nWatching for new Codex checkpoints... (Ctrl-C to stop)\n' : '\n'))
}
} finally {
refreshInFlight = false
}
}
try {
await render()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
process.stderr.write(`codeburn codex-tps: refresh failed: ${message}\n`)
if (intervalMs === 0) {
process.exitCode = 1
return
}
}
if (intervalMs > 0) {
await new Promise<void>((resolve) => {
const timer = setInterval(() => {
void render().catch(error => {
const message = error instanceof Error ? error.message : String(error)
process.stderr.write(`codeburn codex-tps: refresh failed: ${message}\n`)
})
}, intervalMs)
process.once('SIGINT', () => { clearInterval(timer); resolve() })
})
}
})

program
.command('compare')
.description('Compare two AI models side-by-side')
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/model-breakdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export interface ModelTotals {
freshInput: number
cacheRead: number
cacheWrite: number
activeDurationMs: number
activeGeneratedTokens: number
}

/// Aggregate per-model usage across every session, keyed by the friendly display
Expand All @@ -24,13 +26,16 @@ export function aggregateModelTotals(projects: ProjectSummary[]): Record<string,
const name = getShortModelName(model)
const totals = (modelTotals[name] ??= {
calls: 0, costUSD: 0, estimatedCostUSD: 0, freshInput: 0, cacheRead: 0, cacheWrite: 0,
activeDurationMs: 0, activeGeneratedTokens: 0,
})
totals.calls += data.calls
totals.costUSD += data.costUSD
totals.estimatedCostUSD += data.estimatedCostUSD ?? 0
totals.freshInput += data.tokens.inputTokens
totals.cacheRead += data.tokens.cacheReadInputTokens
totals.cacheWrite += data.tokens.cacheCreationInputTokens
totals.activeDurationMs += data.activeDurationMs ?? 0
totals.activeGeneratedTokens += data.activeGeneratedTokens ?? 0
}
}
}
Expand Down
20 changes: 19 additions & 1 deletion packages/cli/src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,11 @@ function buildSessionSummary(
modelBreakdown[modelKey].tokens.cacheReadInputTokens += call.usage.cacheReadInputTokens
modelBreakdown[modelKey].tokens.cacheCreationInputTokens += call.usage.cacheCreationInputTokens
modelBreakdown[modelKey].tokens.reasoningTokens += call.usage.reasoningTokens
if (call.activeDurationMs !== undefined) {
modelBreakdown[modelKey].activeDurationMs = (modelBreakdown[modelKey].activeDurationMs ?? 0) + call.activeDurationMs
modelBreakdown[modelKey].activeGeneratedTokens = (modelBreakdown[modelKey].activeGeneratedTokens ?? 0) + (call.activeGeneratedTokens ?? call.usage.outputTokens + call.usage.reasoningTokens)
modelBreakdown[modelKey].toolWaitMs = (modelBreakdown[modelKey].toolWaitMs ?? 0) + (call.toolWaitMs ?? 0)
}

for (const tool of extractCoreTools(call.tools)) {
toolBreakdown[tool] = toolBreakdown[tool] ?? { calls: 0 }
Expand Down Expand Up @@ -1017,7 +1022,7 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall {
webSearchRequests: call.webSearchRequests,
cacheCreationOneHourTokens: 0,
},
costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity' || call.provider === 'devin' || call.provider === 'vercel-gateway' || call.provider === 'hermes' || call.provider === 'kiro' || call.provider === 'codewhale' || call.provider === 'quickdesk') ? call.costUSD : undefined,
costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity' || call.provider === 'devin' || call.provider === 'vercel-gateway' || call.provider === 'hermes' || call.provider === 'kiro' || call.provider === 'codewhale' || call.provider === 'quickdesk' || call.provider === 'cline-cli') ? call.costUSD : undefined,
isEstimated: call.costIsEstimated || undefined,
speed: call.speed,
timestamp: call.timestamp,
Expand All @@ -1033,6 +1038,13 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall {
...(call.locAdded ? { locAdded: call.locAdded } : {}),
...(call.locRemoved ? { locRemoved: call.locRemoved } : {}),
...(call.editFailed ? { editFailed: call.editFailed } : {}),
// Tool-excluded active throughput (Codex only). Mirrors apiCallToCachedCall
// below: written unconditionally (undefined keys cost nothing once
// JSON.stringify drops them) so a codex call keeps its timing across the
// session-cache round trip — the dashboard Tok/s column aggregates these.
activeDurationMs: call.activeDurationMs,
activeGeneratedTokens: call.activeGeneratedTokens,
toolWaitMs: call.toolWaitMs,
}
}

Expand Down Expand Up @@ -1069,6 +1081,9 @@ function apiCallToCachedCall(call: ParsedApiCall): CachedCall {
...(call.interrupted ? { interrupted: true } : {}),
...(call.userModified ? { userModified: true } : {}),
...(call.toolErrors ? { toolErrors: call.toolErrors } : {}),
activeDurationMs: call.activeDurationMs,
activeGeneratedTokens: call.activeGeneratedTokens,
toolWaitMs: call.toolWaitMs,
}
}

Expand Down Expand Up @@ -1181,6 +1196,9 @@ function cachedCallToApiCall(call: CachedCall): ParsedApiCall {
deduplicationKey: call.deduplicationKey,
cacheCreationOneHourTokens: u.cacheCreationOneHourTokens || undefined,
toolSequence: call.toolSequence,
activeDurationMs: call.activeDurationMs,
activeGeneratedTokens: call.activeGeneratedTokens,
toolWaitMs: call.toolWaitMs,
})
}

Expand Down
Loading
Loading