diff --git a/README.md b/README.md index 2159e1da..9d1d8176 100644 --- a/README.md +++ b/README.md @@ -487,6 +487,7 @@ Sync sends token counts, costs, models, and projects, never prompts or code. Thi | `codeburn models --format markdown` | Emit a paste-friendly markdown table | | `codeburn models --task feature` | Filter to feature-development work | | `codeburn models --provider claude` | Filter to a single provider | +| `codeburn models --unpriced` | List models counted at $0 because pricing is unknown; JSON preserves exact raw IDs for `model-alias` | Left/right arrow keys switch between Today, 7 Days, 30 Days, Month, 6 Months, and Lifetime (use `--from` / `--to` for an exact historical window). Up/down scroll the full dashboard one line, Page Up/Page Down move one screen, and Home/End jump to either end. The main Daily Activity panel shows at least 10 dates from scrollable full history: use `j`/`k` to move one day, Shift+Space/Space to page, and `g`/`G` to jump to either end. Panels flow in the same order across three columns at maximum width, two at medium width, and one when narrow. In the three-column layout, all panels widen equally by one character for every three additional terminal columns until the dashboard reaches the lesser of 256 characters or the widest renderable source row. Press `q` to quit, `1` `2` `3` `4` `5` `6` as period shortcuts, `c` to open model comparison, or `o` to open optimize. Today, 7 Days, and concrete-day views refresh in place at most once per minute by default (`--refresh 0` to disable) without changing the active view or scroll position. The heavier aggregate views remain static between deliberate navigation changes. The dashboard also shows average cost per session and the five most expensive sessions across all projects. diff --git a/src/dashboard.tsx b/src/dashboard.tsx index b66b41cf..d58dfd53 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -652,8 +652,10 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: ) })} {unpriced.length > 0 && ( - - {`! ${unpriced.length} model${unpriced.length === 1 ? '' : 's'} unpriced at $0, fix: codeburn model-alias (${unpriced.slice(0, 2).map(u => u.model).join(', ')}${unpriced.length > 2 ? ', ...' : ''})`} + + {pw <= 44 + ? 'codeburn models --unpriced' + : `! ${unpriced.length} unpriced: codeburn models --unpriced`} )} {anyEstimated && ( diff --git a/src/main.ts b/src/main.ts index d201920b..9c7d0355 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,7 +2,7 @@ import { isAbsolute } from 'path' import { Command, Option } from 'commander' 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 { findUnpricedModels, loadPricing, sanitizeModelForDisplay, 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' @@ -2075,6 +2075,7 @@ program .option('--by-agent', 'One row per (provider, model, agent) instead of one row per (provider, model). Claude subagent transcripts only; other providers and main sessions bucket under "main"') .option('--top ', 'Show only the top N rows', (v: string) => parseInt(v, 10)) .option('--min-cost ', 'Hide rows below this cost threshold', (v: string) => parseFloat(v)) + .option('--unpriced', 'Show only models with usage that currently price at $0') .option('--no-totals', 'Suppress the footer totals row') .option('--format ', 'Output format: table, markdown, json, csv', 'table') .action(async (opts) => { @@ -2099,27 +2100,50 @@ program } const projects = await parseAllSessions(range, opts.provider) - const rows = await aggregateModels(projects, { + const topN = typeof opts.top === 'number' && Number.isFinite(opts.top) ? opts.top : undefined + const minCost = typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) + ? opts.minCost + : opts.unpriced ? undefined : 0.01 + let rows = await aggregateModels(projects, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, taskFilter: opts.task, - topN: typeof opts.top === 'number' && Number.isFinite(opts.top) ? opts.top : undefined, - minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : 0.01, + topN: opts.unpriced ? undefined : topN, + minCost, }) + if (opts.unpriced) { + rows = rows + .filter(row => findUnpricedModels([{ + model: row.model, + calls: row.calls, + cost: row.costUSD, + tokens: row.totalTokens, + }]).length > 0) + .sort((a, b) => (b.totalTokens - a.totalTokens) || (b.calls - a.calls) + || (a.provider < b.provider ? -1 : a.provider > b.provider ? 1 : 0) + || (a.model < b.model ? -1 : a.model > b.model ? 1 : 0)) + if (topN !== undefined) rows = rows.slice(0, topN) + } const fmt = (opts.format ?? 'table').toLowerCase() if (rows.length === 0 && (fmt === 'table' || fmt === 'markdown')) { - process.stdout.write('No model usage found for the selected period.\n') + process.stdout.write(opts.unpriced + ? 'No unpriced models found for the selected period.\n' + : 'No model usage found for the selected period.\n') return } + const renderRows = opts.unpriced && fmt !== 'json' + ? rows.map(row => ({ ...row, modelDisplayName: sanitizeModelForDisplay(row.model) })) + : rows if (fmt === 'json') { process.stdout.write(renderJson(rows) + '\n') } else if (fmt === 'csv') { - process.stdout.write(renderCsv(rows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent }) + '\n') + process.stdout.write(renderCsv(renderRows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent }) + '\n') } else if (fmt === 'markdown' || fmt === 'md') { - process.stdout.write(renderMarkdown(rows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, showTotals: opts.totals !== false }) + '\n') + process.stdout.write(renderMarkdown(renderRows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, showTotals: opts.totals !== false }) + '\n') } else if (fmt === 'table') { - process.stdout.write(renderTable(rows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, showTotals: opts.totals !== false }) + '\n') + process.stdout.write(renderTable(renderRows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, showTotals: opts.totals !== false }) + '\n') + if (opts.unpriced) process.stdout.write('Fix: codeburn model-alias "" \n') } else { process.stderr.write(`codeburn: unknown --format "${opts.format}". Choose table, markdown, json, or csv.\n`) process.exit(1) diff --git a/src/models.ts b/src/models.ts index f1d9ad0b..52b5eed7 100644 --- a/src/models.ts +++ b/src/models.ts @@ -795,6 +795,11 @@ function shouldWarnAboutUnknownModel(name: string): boolean { return true } +/** Render provider-supplied model IDs without terminal control characters. */ +export function sanitizeModelForDisplay(model: string): string { + return model.replace(/[\x00-\x1F\x7F-\x9F]/g, '?').slice(0, 200) +} + export function calculateCost( model: string, inputTokens: number, @@ -812,7 +817,7 @@ export function calculateCost( // Strip control characters and cap length: model names come from JSONL // payloads written by external tools, so a hostile or corrupt file // could embed terminal escape sequences here. - const safeName = model.replace(/[\x00-\x1F\x7F-\x9F]/g, '?').slice(0, 200) + const safeName = sanitizeModelForDisplay(model) const aliasHint = `Map it with: codeburn model-alias "${safeName}" , or track local-model savings with: codeburn model-savings "${safeName}" ` process.stderr.write( `codeburn: no pricing data for model "${safeName}" — costs for this model will show $0. ` + diff --git a/tests/cli-models-unpriced.test.ts b/tests/cli-models-unpriced.test.ts new file mode 100644 index 00000000..397e1e0e --- /dev/null +++ b/tests/cli-models-unpriced.test.ts @@ -0,0 +1,149 @@ +import { spawnSync } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { describe, expect, it } from 'vitest' + +function runCli(args: string[], home: string, locale?: string) { + return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + CLAUDE_CONFIG_DIR: join(home, '.claude'), + CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'), + TZ: 'UTC', + ...(locale ? { LANG: locale, LC_ALL: locale } : {}), + }, + encoding: 'utf-8', + timeout: 30_000, + }) +} + +function userLine(timestamp: string): string { + return JSON.stringify({ + type: 'user', sessionId: 'unpriced-969', timestamp, cwd: '/tmp/unpriced-969', + message: { role: 'user', content: 'inspect pricing coverage' }, + }) +} + +function assistantLine(model: string, timestamp: string, messageId: string, input: number): string { + return JSON.stringify({ + type: 'assistant', sessionId: 'unpriced-969', timestamp, cwd: '/tmp/unpriced-969', + message: { + id: messageId, type: 'message', role: 'assistant', model, + content: [{ type: 'text', text: 'done' }], + usage: { + input_tokens: input, output_tokens: 100, + cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + }, + }, + }) +} + +async function withFixture(lines: string[], run: (home: string) => void): Promise { + const home = await mkdtemp(join(tmpdir(), 'codeburn-models-unpriced-')) + try { + const projectDir = join(home, '.claude', 'projects', 'unpriced-969') + await mkdir(projectDir, { recursive: true }) + await writeFile(join(projectDir, 'session.jsonl'), `${lines.join('\n')}\n`) + run(home) + } finally { + await rm(home, { recursive: true, force: true }) + } +} + +const range = ['--from', '2026-05-20', '--to', '2026-05-20', '--provider', 'claude'] + +describe('codeburn models --unpriced public CLI', () => { + it('filters before --top and returns the largest unpriced raw ID deterministically', async () => { + await withFixture([ + userLine('2026-05-20T10:00:00.000Z'), + assistantLine('acme/unknown-small-969', '2026-05-20T10:01:00.000Z', 'small', 1_000), + assistantLine('claude-opus-4-6', '2026-05-20T10:02:00.000Z', 'priced', 20_000), + assistantLine('acme/unknown-large-969', '2026-05-20T10:03:00.000Z', 'large', 9_000), + ], home => { + const result = runCli(['models', '--unpriced', '--top', '1', '--format', 'json', ...range], home) + expect(result.status, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toEqual([ + expect.objectContaining({ model: 'acme/unknown-large-969', totalTokens: 9_100 }), + ]) + }) + }) + + it('orders tied unpriced rows identically across host locales', async () => { + await withFixture([ + userLine('2026-05-20T10:00:00.000Z'), + assistantLine('acme/z-unknown-969', '2026-05-20T10:01:00.000Z', 'z-model', 1_000), + assistantLine('acme/ä-unknown-969', '2026-05-20T10:02:00.000Z', 'a-umlaut-model', 1_000), + ], home => { + const args = ['models', '--unpriced', '--format', 'json', ...range] + const english = runCli(args, home, 'en_US.UTF-8') + const swedish = runCli(args, home, 'sv_SE.UTF-8') + expect(english.status, english.stderr).toBe(0) + expect(swedish.status, swedish.stderr).toBe(0) + const models = (stdout: string) => (JSON.parse(stdout) as Array<{ model: string }>).map(row => row.model) + expect(models(english.stdout)).toEqual(['acme/z-unknown-969', 'acme/ä-unknown-969']) + expect(models(swedish.stdout)).toEqual(models(english.stdout)) + }) + }) + + it('honors an explicitly supplied finite --min-cost threshold', async () => { + await withFixture([ + userLine('2026-05-20T10:00:00.000Z'), + assistantLine('acme/unknown-zero-969', '2026-05-20T10:01:00.000Z', 'zero', 1_000), + ], home => { + const result = runCli(['models', '--unpriced', '--min-cost', '0.01', '--format', 'json', ...range], home) + expect(result.status, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toEqual([]) + }) + }) + + it('lists every unpriced model in table output with an actionable hint', async () => { + await withFixture([ + userLine('2026-05-20T10:00:00.000Z'), + assistantLine('acme/unknown-alpha-969', '2026-05-20T10:01:00.000Z', 'alpha', 1_000), + assistantLine('acme/unknown-beta-969', '2026-05-20T10:02:00.000Z', 'beta', 2_000), + assistantLine('claude-opus-4-6', '2026-05-20T10:03:00.000Z', 'priced', 3_000), + ], home => { + const result = runCli(['models', '--unpriced', ...range], home) + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('acme/unknown-alpha-969') + expect(result.stdout).toContain('acme/unknown-beta-969') + expect(result.stdout).not.toContain('claude-opus-4-6') + expect(result.stdout).toContain('codeburn model-alias "" ') + }) + }) + + it('reports a clean period explicitly', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-models-unpriced-empty-')) + try { + const result = runCli(['models', '--unpriced', ...range], home) + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toBe('No unpriced models found for the selected period.\n') + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + + it('sanitizes hostile IDs in human formats while JSON stays lossless', async () => { + const hostile = `acme/alpha\u001b]0;forged\u0007click\u001b[31m\nforged-row-${'x'.repeat(300)}` + await withFixture([ + userLine('2026-05-20T10:00:00.000Z'), + assistantLine(hostile, '2026-05-20T10:01:00.000Z', 'hostile', 1_000), + ], home => { + for (const format of ['table', 'markdown', 'csv']) { + const result = runCli(['models', '--unpriced', '--format', format, ...range], home) + expect(result.status, `${format}: ${result.stderr}`).toBe(0) + expect(result.stdout).not.toMatch(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/) + expect(result.stdout).not.toContain('\nforged-row-') + expect(result.stdout).not.toContain('x'.repeat(201)) + } + const json = runCli(['models', '--unpriced', '--format', 'json', ...range], home) + expect(json.status, json.stderr).toBe(0) + expect((JSON.parse(json.stdout) as Array<{ model: string }>)[0]?.model).toBe(hostile) + }) + }, 15_000) +}) diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index 9879c8a1..9d96a8df 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -395,6 +395,57 @@ describe('interactive terminal rendering', () => { expect(INTERACTIVE_RENDER_OPTIONS).toMatchObject({ alternateScreen: true }) }) + it.each([ + { columns: 42, expected: 'codeburn models --unpriced' }, + { columns: 43, expected: 'codeburn models --unpriced' }, + { columns: 44, expected: 'codeburn models --unpriced' }, + { columns: 80, expected: '! 10 unpriced: codeburn models --unpriced' }, + ])('shows an actionable unpriced-model command in a real $columns-column Ink frame', async ({ columns, expected }) => { + const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream + const stdout = new PassThrough() as PassThrough & NodeJS.WriteStream + stdin.isTTY = true + stdin.setRawMode = () => stdin + stdin.ref = () => stdin + stdin.unref = () => stdin + stdout.isTTY = true + stdout.columns = columns + stdout.rows = 100 + const frames: string[] = [] + stdout.on('data', chunk => frames.push(stripAnsi(String(chunk)))) + + const session = makeSession('unpriced-session', 0) + for (let index = 0; index < 10; index++) { + const model = `vendor-${index}/unknown-model-${index}-969` + session.modelBreakdown[model] = { + calls: 1, + costUSD: 0, + savingsUSD: 0, + tokens: { + inputTokens: 1_000, + outputTokens: 100, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + }, + } + } + + const app = render(React.createElement(InteractiveDashboard, { + initialProjects: [makeProject('unpriced-project', [session])], + initialPeriod: 'today', + initialProvider: 'all', + refreshSeconds: 0, + windowColumns: columns, + }), { stdin, stdout, debug: true, interactive: true, patchConsole: false }) + onTestFinished(() => app.unmount()) + await app.waitUntilRenderFlush() + + const frame = frames.filter(value => value.trim()).at(-1) ?? '' + expect(frame).toContain(expected) + }) + it('leaves resize frame synchronization entirely to Ink', () => { const source = readFileSync(new URL('../src/dashboard.tsx', import.meta.url), 'utf8') expect(source).not.toContain('process.stdout.write(BSU)') diff --git a/tests/models-report.test.ts b/tests/models-report.test.ts index 33317fd8..8808ca58 100644 --- a/tests/models-report.test.ts +++ b/tests/models-report.test.ts @@ -1,6 +1,9 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { spawnSync } from 'node:child_process' -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import chalk from 'chalk' import stripAnsi from 'strip-ansi' @@ -713,6 +716,66 @@ describe('renderCsv', () => { }) describe('models CLI breakdown flags', () => { + vi.setConfig({ testTimeout: 30_000 }) + + it('filters the models report to unpriced rows', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-models-unpriced-')) + try { + const projectDir = join(home, '.claude', 'projects', 'models-unpriced') + await mkdir(projectDir, { recursive: true }) + await writeFile(join(projectDir, 'session.jsonl'), [ + JSON.stringify({ + type: 'user', + sessionId: 'models-unpriced-session', + timestamp: '2026-05-09T00:00:00.000Z', + cwd: '/tmp/models-unpriced', + message: { role: 'user', content: 'Use one priced and one unpriced model.' }, + }), + JSON.stringify({ + type: 'assistant', + sessionId: 'models-unpriced-session', + timestamp: '2026-05-09T00:01:00.000Z', + cwd: '/tmp/models-unpriced', + message: { + id: 'priced', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-6', + content: [{ type: 'text', text: 'priced' }], + usage: { input_tokens: 1000, output_tokens: 100, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + }, + }), + JSON.stringify({ + type: 'assistant', + sessionId: 'models-unpriced-session', + timestamp: '2026-05-09T00:02:00.000Z', + cwd: '/tmp/models-unpriced', + message: { + id: 'unpriced', + type: 'message', + role: 'assistant', + model: 'zz-unpriced-frontier-model', + content: [{ type: 'text', text: 'unpriced' }], + usage: { input_tokens: 2000, output_tokens: 200, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + }, + }), + ].join('\n') + '\n') + + const res = spawnSync( + process.execPath, + ['--import', 'tsx', 'src/cli.ts', 'models', '--unpriced', '--from', '2026-05-09', '--to', '2026-05-09', '--provider', 'claude', '--format', 'json'], + { cwd: process.cwd(), env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'), TZ: 'UTC' }, encoding: 'utf-8', timeout: 30_000 }, + ) + + expect(res.status, `stdout: ${res.stdout}\nstderr: ${res.stderr}`).toBe(0) + const rows = JSON.parse(res.stdout) as Array<{ model: string; calls: number }> + expect(rows.map(row => row.model)).toEqual(['zz-unpriced-frontier-model']) + expect(rows[0]?.calls).toBe(1) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + it('rejects --by-task and --by-agent together with a clear error and exit 1', () => { const res = spawnSync( process.execPath,