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
5 changes: 3 additions & 2 deletions src/codex-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ import type { ParsedProviderCall } from './providers/types.js'
// v5: also attribute CLI-wrapped MCP calls (`mcp-cli call server tool`) that
// Codex logs as a plain exec_command (issue #478 follow-up). Force a re-parse
// so sessions cached under v4 pick up the CLI-MCP attribution.
// v6: rich-session-capture — per-call locAdded/locRemoved/editFailed from
// v6/v7: rich-session-capture — per-call locAdded/locRemoved/editFailed from
// patch_apply_end. Sessions cached under v5 lack these fields; re-parse to add.
const CODEX_CACHE_VERSION = 7
// v8: persist native MCP timing and compact invocation attribution.
const CODEX_CACHE_VERSION = 8
const CACHE_FILE = 'codex-results.json'

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

Large diffs are not rendered by default.

16 changes: 14 additions & 2 deletions 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 @@ -439,6 +442,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 @@ -448,6 +452,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 @@ -459,7 +464,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 @@ -468,6 +473,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 @@ -476,6 +484,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 @@ -487,6 +496,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
106 changes: 106 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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 { 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 +47,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 +70,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 +1861,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 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
14 changes: 14 additions & 0 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1730,6 +1730,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 @@ -2389,6 +2394,9 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall {
...(call.locAdded ? { locAdded: call.locAdded } : {}),
...(call.locRemoved ? { locRemoved: call.locRemoved } : {}),
...(call.editFailed ? { editFailed: call.editFailed } : {}),
activeDurationMs: call.activeDurationMs,
activeGeneratedTokens: call.activeGeneratedTokens,
toolWaitMs: call.toolWaitMs,
}
}

Expand Down Expand Up @@ -2425,6 +2433,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 @@ -2537,6 +2548,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