fix(models): honest unpriced guidance, shared ID sanitizing, readable narrow warning

The unpriced-models pointer told every user to run `codeburn model-alias`. A
subscription or flat-rate model is correctly $0, and mapping it onto another
model's per-token rate invents spend that was never billed (#968), so the hint
now states the condition instead of the instruction.

`sanitizeModelForDisplay` guarded only the `--unpriced` path, leaving every
other command and format rendering provider-supplied IDs unfiltered. It moves
to the `formatModel` closures in models-report and audit-report, the two sites
every renderer routes through, covering the raw-ID fallback as well. The
`--unpriced` override stays: it bypasses that path deliberately, because
`model-alias` keys on the raw ID, not the friendly name. README says so.

Below 45 columns of panel the dashboard warning dropped its marker and count
and printed a bare command, so a narrow terminal gave no signal that anything
was wrong. `! N: codeburn models --unpriced` is 31 characters and fits.
This commit is contained in:
iamtoruk 2026-08-18 09:58:25 -07:00
parent 81eb7310a3
commit 6b427d72ce
8 changed files with 24 additions and 11 deletions

View file

@ -29,6 +29,7 @@
- **The resident `codeburn serve` child.** The first real panel request is also the cache warm-up, so startup never runs an artificial warm-up query beside a duplicate one-shot child; each served command carries its own read-only option allowlist, and anything outside it falls back to a normal spawn; the child exits when its stdin closes, so it can never outlive the app. Requests whose response exceeds the 16 MiB frame limit still replace the child, but that deliberate kill no longer spends the resident's unexpected-death budget. (#972)
### Fixed
- **The unpriced-models warning in the dashboard is now readable at every terminal width.** It lived in a fixed-width panel with an inline model list and a fix command, so it clipped mid-name at 80 columns and clipped *earlier* at 200, where the three-column layout narrows each panel - neither the affected models nor a runnable command survived. The panel line is now a pointer, `! N unpriced: codeburn models --unpriced` (shortened to `! N: codeburn models --unpriced` below 45 columns of panel), and the model list moves to that command's plain output, which is full width, copyable, and lists every model rather than the first two. The command's hint no longer reads as an unconditional instruction to alias: a subscription or flat-rate model is correctly $0, and mapping it onto another model's per-token rate would invent spend that was never billed. Provider-supplied model IDs are now stripped of terminal control characters in every human-readable report rather than only on the unpriced path, and `--unpriced` shows raw IDs instead of friendly names because `model-alias` keys on the raw ID. (#969)
- **`codeburn models --unpriced --top N` returned nothing for a `--top N` smaller than the number of priced models.** `--top` is applied inside `aggregateModels`, before the unpriced filter, on rows sorted cost-first — and unpriced rows are $0 on both, so they sorted last and the slice removed exactly the rows the flag exists to show. A user with unpriced models was told they had none. The slice now runs after the filter — and after ranking, because unpriced rows tie at $0 on both keys, so slicing them in aggregate order kept whichever models happened to appear earliest in the transcript rather than the largest. The order now matches the one the unpriced-models warning shows. (#969)
- **Old durable sources remain visible while they still exist.** The 90-day session-cache age-out now applies only after a durable source disappears from discovery, so an unchanged older Copilot source keeps reporting usage and reuses its persisted fingerprint instead of being reparsed and immediately discarded. (#987) On long-lived machines this makes previously dropped history reappear, so lifetime totals can jump once after upgrading.
- **`optimize` no longer treats subagent transcripts as your sessions.** Claude Code writes each subagent's transcript to its own `subagents/agent-*.jsonl` file with `isSidechain: true` on every entry, and optimize counted each one as a user-started session. That inflated the session count in the header and fed the session-level detectors a population that fails their tests by construction: a sidechain is handed a large context and returns a short answer (context-heavy), and it never commits or opens a PR because its parent does (low-worth). Excluded from sidechains now: the header session count, the `low-worth-sessions`, `context-bloat`, `cost-outliers` and `capability-reliability` detectors, the coaching notes, the file-churn table, the median time-to-first-edit, the worst one-shot category, and the model-default recommendation - plus `duplicate-reads`, because a subagent starts on a fresh context and re-reading what its parent read is a necessary read, not a repeat. Everything else keeps the full population: `build-folder-reads` and `read-edit-ratio` still count calls made inside a sidechain, since reading `node_modules` or editing without reading is the same waste whoever does it and the `CLAUDE.md` rule they suggest binds subagents too, and so do the MCP, cache-bloat, ghost-command and configuration-overhead findings. Classification is sticky across the whole file, so calls that appear before the first marked entry are reclassified too, and `isSidechain` now survives the compact parser's 32 KB large-line path and warm-cache range rebuilds. Nothing is deleted from spend: sidechain tokens, calls and cost stay in every total and in `status`, and the optimize result cache keys on sidechain identity so a run cannot be served a pre-fix result. Absent markers still read as user-started, so no cache re-parse is needed. (#974)

View file

@ -516,11 +516,10 @@ Sync sends token counts, costs, models, and projects, never prompts or code. Thi
| `codeburn models --by-task` | Break each model into per-task-type rows |
| `codeburn models --by-agent` | Break each model into per-agent rows: which agent drove which model's spend (`(main)` covers non-agent sessions; `--min-cost 0` shows sub-cent agents) |
| `codeburn models --top 10` | Only the 10 most expensive models |
| `codeburn models --unpriced` | Only models with usage that currently price at $0 — the copyable form of the unpriced-models warning |
| `codeburn models --unpriced` | Only models with usage that currently price at $0 — the copyable form of the unpriced-models warning. Shows raw model IDs (not friendly names) so they can be pasted into `model-alias`; JSON keeps them exact |
| `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.

View file

@ -1,4 +1,4 @@
import { getModelCosts, type ModelCosts } from './models.js'
import { getModelCosts, sanitizeModelForDisplay, type ModelCosts } from './models.js'
import { getProvider } from './providers/index.js'
import { formatCost, formatTokens } from './format.js'
import { renderTable, type TableColumn } from './text-table.js'
@ -111,7 +111,9 @@ export async function aggregateAudit(projects: ProjectSummary[]): Promise<AuditR
const p = await getProvider(name)
const entry = {
displayName: p?.displayName ?? name,
formatModel: p ? (m: string) => p.modelDisplayName(m) : (m: string) => m,
formatModel: p
? (m: string) => sanitizeModelForDisplay(p.modelDisplayName(m))
: sanitizeModelForDisplay,
}
providerCache.set(name, entry)
return entry

View file

@ -655,7 +655,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
{unpriced.length > 0 && (
<Text color="yellow" wrap={pw <= 44 ? 'wrap' : 'truncate-end'}>
{pw <= 44
? 'codeburn models --unpriced'
? `! ${unpriced.length}: codeburn models --unpriced`
: `! ${unpriced.length} unpriced: codeburn models --unpriced`}
</Text>
)}

View file

@ -2148,6 +2148,8 @@ program
: 'No model usage found for the selected period.\n')
return
}
// The friendly name is useless for `model-alias`, which keys on the raw ID.
// Sanitized because this bypasses the shared display path in models-report.
const renderRows = opts.unpriced && fmt !== 'json'
? rows.map(row => ({ ...row, modelDisplayName: sanitizeModelForDisplay(row.model) }))
: rows
@ -2159,7 +2161,9 @@ program
process.stdout.write(renderMarkdown(renderRows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, showTotals: opts.totals !== false }) + '\n')
} else if (fmt === 'table') {
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 "<model>" <known-model>\n')
// Never advise aliasing unconditionally: a subscription or flat-rate model
// is correctly $0, and mapping it onto another model's rate invents spend.
if (opts.unpriced) process.stdout.write('If a model is billed per token, map it with: codeburn model-alias "<model>" <known-model>. Subscription or flat-rate models are correctly $0.\n')
} else {
process.stderr.write(`codeburn: unknown --format "${opts.format}". Choose table, markdown, json, or csv.\n`)
process.exit(1)

View file

@ -3,6 +3,7 @@ import stripAnsi from 'strip-ansi'
import { codexCredits } from './codex-credits.js'
import { formatCost, formatTokens } from './format.js'
import { sanitizeModelForDisplay } from './models.js'
import { getProvider } from './providers/index.js'
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
@ -153,7 +154,9 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat
const p = await getProvider(name)
const entry = {
displayName: p?.displayName ?? name,
formatModel: p ? (m: string) => p.modelDisplayName(m) : (m: string) => m,
formatModel: p
? (m: string) => sanitizeModelForDisplay(p.modelDisplayName(m))
: sanitizeModelForDisplay,
}
providerCache.set(name, entry)
return entry

View file

@ -113,7 +113,11 @@ describe('codeburn models --unpriced public CLI', () => {
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 "<model>" <known-model>')
expect(result.stdout).toContain('If a model is billed per token, map it with: codeburn model-alias "<model>" <known-model>')
// #968: aliasing a subscription-billed model fabricates spend, so the
// hint must never read as an unconditional instruction.
expect(result.stdout).toContain('Subscription or flat-rate models are correctly $0.')
expect(result.stdout).not.toContain('Fix: codeburn model-alias')
})
})

View file

@ -396,9 +396,9 @@ describe('interactive terminal rendering', () => {
})
it.each([
{ columns: 42, expected: 'codeburn models --unpriced' },
{ columns: 43, expected: 'codeburn models --unpriced' },
{ columns: 44, expected: 'codeburn models --unpriced' },
{ columns: 42, expected: '! 10: codeburn models --unpriced' },
{ columns: 43, expected: '! 10: codeburn models --unpriced' },
{ columns: 44, expected: '! 10: 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