diff --git a/README.md b/README.md
index 3fa2f1d8..8cab6b46 100644
--- a/README.md
+++ b/README.md
@@ -34,8 +34,11 @@ Everything runs locally. No wrapper, no proxy, no API keys, nothing leaves your
Quick start ·
Find waste ·
+ Apply fixes ·
+ Guard ·
Compare models ·
Track what shipped ·
+ MCP ·
Supported tools ·
Commands ·
Features ·
@@ -66,6 +69,8 @@ Also runs via `bunx codeburn` or `dx codeburn`, or `brew install codeburn` on ma
codeburn menubar
```
+On Linux, a GNOME Shell extension gives the same panel view; see [Linux (GNOME)](#linux-gnome).
+
Requires **Node.js 22.13+** and at least one supported tool with session data on disk. For Cursor and OpenCode, `better-sqlite3` installs automatically.
## Your month at a glance
@@ -131,6 +136,39 @@ Each finding shows the estimated token and dollar savings plus a ready-to-paste
You can also open it inline from the dashboard: press `o` when a finding count appears in the status bar, `b` to return.
+## Apply fixes, undo anytime
+
+```bash
+codeburn optimize --apply # review and apply fixes interactively
+codeburn optimize --apply --dry-run # print the plan, change nothing
+codeburn optimize --apply --yes # apply every appliable fix without prompting
+codeburn act list # every change CodeBurn has made
+codeburn act undo --last # roll the most recent change back
+codeburn act report # realized vs estimated savings
+```
+
+`codeburn optimize` finds the waste; `--apply` fixes the config-class findings for you: settings values, environment variables, archiving unused agents and skills. Every change is backed up and journaled before it lands. `codeburn act list` shows the history and `codeburn act undo ` restores the original files (it refuses if the files changed since being applied, unless you pass `--force`).
+
+The loop closes on honesty: once an applied fix is at least 3 days old, `codeburn act report` compares its estimated savings against what your sessions actually did, and later `codeburn optimize` runs show that realized figure in the header. Estimates get checked against reality, not just claimed.
+
+## Guard your budget
+
+```bash
+codeburn guard install # hooks into this project's .claude/settings.json
+codeburn guard install --global # or into ~/.claude/settings.json
+codeburn guard status # caps, install locations, flagged projects
+codeburn guard uninstall # removes cleanly, leaves your own hooks alone
+```
+
+Guard installs opt-in hooks into Claude Code that watch session cost while you work:
+
+- **Soft cap** (default $5): a one-time in-session warning when a session passes it.
+- **Hard cap** (default $15): stops the session; `codeburn guard allow` lifts it for that session only.
+- **Checkpoint** (default $3): if a session ends past this with no edits and no commits, a nudge suggests starting fresh with a named deliverable.
+- **Session openers**: projects where optimize found waste get a one-line flag at session start.
+
+Caps are edited in `~/.config/codeburn/guard.json` (set a value to `null` to disable it). Add `--statusline` to show session cost in the Claude Code status line. Installs go through the same journal as everything else, so `codeburn act undo` removes them too. Hooks fail open: a broken guard never blocks a session.
+
## Compare models
```bash
@@ -237,6 +275,33 @@ defaults write org.agentseal.codeburn-menubar CodeBurnMenubarCompact -bool true
Relaunch the app to apply. To revert: `defaults delete org.agentseal.codeburn-menubar CodeBurnMenubarCompact`.
+### Linux (GNOME)
+
+Linux gets the same ambient view through a GNOME Shell extension (GNOME 45+): spend in the top panel, period switcher, compact mode, and daily budget alerts. It lives in [`gnome/`](gnome/):
+
+```bash
+git clone https://github.com/getagentseal/codeburn && cd codeburn/gnome
+./install.sh
+gnome-extensions enable codeburn@codeburn.dev
+```
+
+See [gnome/README.md](gnome/README.md) for settings and development notes. On Windows, `codeburn web` is the always-on view for now.
+
+## CodeBurn in your agent (MCP)
+
+```bash
+claude mcp add codeburn -- npx -y codeburn mcp
+```
+
+`codeburn mcp` runs a local MCP server over stdio, so Claude Code, Cursor, or any MCP client can ask "where did my tokens go this week?" or "how do I spend less?" mid-conversation. It exposes two tools:
+
+| Tool | What it returns |
+|------|-----------------|
+| `get_usage` | Spend and usage with breakdowns by tool, model, project, and task (fast) |
+| `get_savings` | Cost reductions: waste findings, retry tax, routing waste (slower, deeper analysis) |
+
+Everything is read from local disk, same as the CLI. Project names are pseudonymized by default; the agent only sees real names if it asks with `include_project_names: true`. For other MCP clients, configure a stdio server with command `npx` and args `-y codeburn mcp`.
+
## Supported tools
CodeBurn auto-detects which AI tools you use. Each logo links to its provider doc.
@@ -336,6 +401,19 @@ Run `codeburn` for the dashboard, or use a subcommand below. Most commands also
| `codeburn yield` | Productive vs reverted/abandoned spend, correlated against git |
| `codeburn yield -p 30days` | Yield analysis for the last 30 days |
+**Fix & control**
+
+| Command | What it does |
+|---------|--------------|
+| `codeburn optimize --apply` | Interactively apply config-class fixes (`--yes`, `--dry-run`, `--only `) |
+| `codeburn act list` | Every change CodeBurn has applied, newest first |
+| `codeburn act undo ` | Roll a change back (`--last` for the most recent, `--force` if files drifted) |
+| `codeburn act report` | Realized vs estimated savings for applied fixes |
+| `codeburn guard install` | Budget-cap hooks for Claude Code (`--global`, `--statusline`) |
+| `codeburn guard status` | Show caps, install locations, and flagged projects |
+| `codeburn guard allow` | Lift the hard cap for the current session |
+| `codeburn mcp` | MCP server (stdio) exposing usage and savings to AI agents |
+
**Models**
| Command | What it does |
@@ -428,6 +506,16 @@ codeburn model-alias --remove "my-proxy-model" # remove alias
Aliases are stored in `~/.config/codeburn/config.json` and applied at runtime before pricing lookup. The target name can be anything in the [LiteLLM model list](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json) or a canonical name from the fallback table (e.g. `claude-sonnet-4-6`, `claude-opus-4-5`, `gpt-4o`). Built-in aliases ship for known proxy model name variants. User-configured aliases take precedence over built-ins.
+### Local Models, Custom Prices, and Proxies
+
+```bash
+codeburn price-override my-model --input 0.27 --output 1.10 # USD per 1M tokens
+codeburn model-savings "llama3.1:8b" gpt-4o # local model, counted as savings
+codeburn proxy-path ~/work/copilot-repo # subscription-covered project
+```
+
+`price-override` sets exact rates for any model (input, output, cache read, cache creation), useful for private deployments or models LiteLLM prices wrong. `model-savings` maps a free local model to a paid baseline: the local calls stay $0, and the dashboard shows what the same tokens would have cost on the baseline. `proxy-path` marks a project routed through a subscription-backed proxy (e.g. Claude Code over GitHub Copilot), so its API-rate cost is reported as subscription-covered and your net out-of-pocket stays honest. All three support `--list` and `--remove`.
+
### Filtering
```bash
diff --git a/src/dashboard.tsx b/src/dashboard.tsx
index 62156173..a79c9c22 100644
--- a/src/dashboard.tsx
+++ b/src/dashboard.tsx
@@ -6,7 +6,7 @@ import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory
import { formatCost, formatTokens } from './format.js'
import { aggregateModelEfficiency } from './model-efficiency.js'
import { parseAllSessions, filterProjectsByName } from './parser.js'
-import { loadPricing } from './models.js'
+import { findUnpricedModels, loadPricing } from './models.js'
import { getAllProviders } from './providers/index.js'
import { scanAndDetect, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js'
import { estimateContextBudget, type ContextBudget } from './context-budget.js'
@@ -358,6 +358,12 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
}
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]) => ({
+ model,
+ calls: d.calls,
+ cost: d.costUSD,
+ tokens: d.freshInput + d.cacheRead + d.cacheWrite,
+ })))
return (
@@ -381,6 +387,11 @@ 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 ? ', ...' : ''})`}
+
+ )}
)
}
diff --git a/src/main.ts b/src/main.ts
index cc992670..a159408f 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -2,7 +2,7 @@ import { isAbsolute } from 'path'
import { Command } from 'commander'
import { installMenubarApp } from './menubar-installer.js'
import { exportCsv, exportJson, type PeriodExport } from './export.js'
-import { loadPricing, setModelAliases, setPriceOverrides, setLocalModelSavings, setProxyPaths, normalizeProxyPath } from './models.js'
+import { findUnpricedModels, loadPricing, setModelAliases, setPriceOverrides, setLocalModelSavings, setProxyPaths, normalizeProxyPath } from './models.js'
import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache } from './parser.js'
import { allProviderNames } from './providers/index.js'
import { convertCost } from './currency.js'
@@ -460,6 +460,16 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
daily,
projects: projectList,
models,
+ // Models with recorded usage that resolve to no pricing data right now
+ // (#638). Their calls contribute $0 to every cost figure above, so
+ // consumers can tell "cheap" from "uncounted". Empty when all models
+ // priced. Fix entries via `codeburn model-alias` or `price-override`.
+ unpricedModels: findUnpricedModels(Object.entries(modelMap).map(([model, d]) => ({
+ model,
+ calls: d.calls,
+ cost: d.cost,
+ tokens: d.inputTokens + d.outputTokens + d.cacheReadTokens + d.cacheWriteTokens,
+ }))),
activities,
tools: sortedMap(toolMap),
mcpServers: sortedMap(mcpMap),
diff --git a/src/mcp/tables.ts b/src/mcp/tables.ts
index cfcb9957..6ce27bec 100644
--- a/src/mcp/tables.ts
+++ b/src/mcp/tables.ts
@@ -15,9 +15,13 @@ const oneShot = (r: number | null) => (r === null ? 'n/a' : pct(r * 100))
export function renderSummaryTable(p: MenubarPayload): string {
const c = p.current
+ const unpriced = c.unpricedModels ?? []
return [
`**${c.label}** — ${formatCost(c.cost)} · ${c.calls} calls · ${c.sessions} sessions`,
`cache hit ${pct(c.cacheHitPercent)} · one-shot ${oneShot(c.oneShotRate)} · in ${formatTokens(c.inputTokens)} / out ${formatTokens(c.outputTokens)}`,
+ ...(unpriced.length > 0
+ ? [`⚠ ${unpriced.length} model${unpriced.length === 1 ? '' : 's'} unpriced, counted at $0: ${unpriced.map(u => `${u.model} (${u.calls} calls)`).join(', ')}. Cost above understates real spend; fix with \`codeburn model-alias\` or \`codeburn price-override\`.`]
+ : []),
'',
'_Top models_',
mdTable(['Model', 'Cost', 'Calls'], c.topModels.slice(0, 5).map(m => [m.name, formatCost(m.cost), String(m.calls)])),
diff --git a/src/menubar-json.ts b/src/menubar-json.ts
index f337f9c9..b4ff9191 100644
--- a/src/menubar-json.ts
+++ b/src/menubar-json.ts
@@ -21,6 +21,10 @@ export type PeriodData = {
codexCredits?: number
categories: Array<{ name: string; cost: number; savingsUSD: number; turns: number; editTurns: number; oneShotTurns: number }>
models: Array<{ name: string; cost: number; savingsUSD: number; calls: number }>
+ /// Models with usage in the period whose pricing lookup fails against the
+ /// current tables (#638): their calls contribute $0 to `cost`. Optional so
+ /// PeriodData producers that predate the field keep compiling.
+ unpricedModels?: Array<{ model: string; calls: number; tokens: number }>
projects?: Array<{ name: string; cost: number; savingsUSD: number; sessions: number; sessionDetails?: Array<{ cost: number; savingsUSD: number; calls: number; inputTokens: number; outputTokens: number; date: string; models: Array<{ name: string; cost: number; savingsUSD: number }> }> }>
modelEfficiency?: Array<{ name: string; costPerEdit: number | null; oneShotRate: number | null }>
topSessions?: Array<{ project: string; cost: number; savingsUSD: number; calls: number; date: string }>
@@ -140,6 +144,10 @@ export type MenubarPayload = {
savingsBaselineModel: string
calls: number
}>
+ /// See PeriodData.unpricedModels: usage priced at $0 for lack of pricing
+ /// data. Empty when every model in the period resolved a price. Optional
+ /// so payload producers that predate the field stay source-compatible.
+ unpricedModels?: Array<{ model: string; calls: number; tokens: number }>
/// Local-model savings rollup, distinct from the routing-waste /
/// optimize savings concepts which describe hypothetical optimization
/// opportunities. This block tracks counterfactual spend that was
@@ -368,6 +376,7 @@ export function buildMenubarPayload(
codexCredits: current.codexCredits ?? 0,
topActivities: buildTopActivities(current.categories),
topModels: buildTopModels(current.models),
+ unpricedModels: current.unpricedModels ?? [],
localModelSavings: breakdowns?.localModelSavings ?? { totalUSD: 0, calls: 0, byModel: [], byProvider: [] },
providers: buildProviders(providers),
topProjects: buildTopProjects(current.projects ?? []),
diff --git a/src/models.ts b/src/models.ts
index 7586fcd6..b314ae92 100644
--- a/src/models.ts
+++ b/src/models.ts
@@ -679,6 +679,71 @@ function looksLikeLocalModel(name: string): boolean {
return false
}
+export interface UnpricedModelUsage {
+ model: string
+ calls: number
+ tokens: number
+}
+
+function hasBillableRate(costs: ModelCosts): boolean {
+ return costs.inputCostPerToken > 0
+ || costs.outputCostPerToken > 0
+ || costs.cacheWriteCostPerToken > 0
+ || costs.cacheReadCostPerToken > 0
+}
+
+// Exact-override lookup with the same key derivation getModelCosts uses. Lets
+// the unpriced detector distinguish "explicitly declared free by the user" (a
+// zero-rate override) from a zero-rate LiteLLM stub, which means "listed but
+// unknown price" and must still be flagged. Only the EXACT override form is
+// consulted: getModelCosts checks it before any table hit, so when one exists
+// it is provably what priced the model. Prefix and case-insensitive overrides
+// resolve AFTER table hits and so cannot prove the $0 was intentional; a
+// zero-rate stub shadowed by one still gets flagged (the honest direction).
+function exactPriceOverrideFor(model: string): ModelCosts | null {
+ const withPrefix = model.replace(/@.*$/, '').replace(/-\d{8}$/, '')
+ const canonicalName = getCanonicalName(model)
+ const canonical = resolveAlias(canonicalName)
+ return getPriceOverrideExact(model, withPrefix, canonicalName, canonical)
+}
+
+// Render-time unpriced detection (#638): flag aggregated model rows that carry
+// usage but $0 cost AND whose pricing lookup yields no billable rate right
+// now. Cost is computed at parse time and cached, so a parse-time registry
+// would miss cached sessions; a render-time check covers both and heals the
+// moment pricing data, an alias, or a price override arrives.
+//
+// Rows with cost > 0 are never flagged: aggregation keys rows by DISPLAY name
+// (parser.ts keys modelBreakdown via getShortModelName), which the pricing
+// lookup misses, so a priced model like "Opus 4.8" would otherwise false-flag.
+// $0 display-name rows ARE flagged even when the raw id would price today:
+// those tokens really did enter the report at $0 (a provider priced a
+// transformed name, or the session was cached before its model's pricing
+// landed). Conservative by design: a display key merging priced and unpriced
+// raw ids carries cost > 0 and is not flagged. Local-looking models and
+// models with a local-savings mapping are excluded because $0 is their
+// correct cost, as are zero-rate USER overrides (explicitly declared free).
+export function findUnpricedModels(
+ rows: Iterable<{ model: string; calls: number; cost: number; tokens?: number }>,
+): UnpricedModelUsage[] {
+ const out: UnpricedModelUsage[] = []
+ for (const row of rows) {
+ const { model } = row
+ const tokens = row.tokens ?? 0
+ if (!model || model === '') continue
+ if (row.calls <= 0 && tokens <= 0) continue
+ if (row.cost > 0) continue
+ if (looksLikeLocalModel(model)) continue
+ if (getLocalSavingsBaseline(model)) continue
+ const costs = getModelCosts(model)
+ if (costs && hasBillableRate(costs)) continue
+ if (costs && exactPriceOverrideFor(model)) continue
+ out.push({ model, calls: row.calls, tokens })
+ }
+ return out.sort((a, b) => (b.tokens - a.tokens) || (b.calls - a.calls)
+ || (a.model < b.model ? -1 : a.model > b.model ? 1 : 0))
+}
+
function shouldWarnAboutUnknownModel(name: string): boolean {
if (!name || name === '') return false
if (warnedUnknownModels.has(name)) return false
diff --git a/src/overview.ts b/src/overview.ts
index a08ac198..074d8b01 100644
--- a/src/overview.ts
+++ b/src/overview.ts
@@ -4,7 +4,7 @@ import { homedir } from 'os'
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
import { formatCost as baseCost } from './currency.js'
-import { getShortModelName } from './models.js'
+import { findUnpricedModels, getShortModelName } from './models.js'
import { dateKey } from './day-aggregator.js'
// Display-only helpers. The shared formatters omit thousands separators and
@@ -160,6 +160,17 @@ export function renderOverview(
out.push(kv('Calls', formatCount(calls) + c.dim(' sessions ') + formatCount(sessions)))
out.push(kv('Cache hit', `${cacheHit.toFixed(1)}%`))
if (savings > 0) out.push(kv('Savings', formatCost(savings) + c.dim(' (local models)')))
+ const unpriced = findUnpricedModels(
+ [...byModel.entries()].map(([model, d]) => ({ model, calls: d.calls, cost: d.cost, tokens: d.tokens })),
+ )
+ if (unpriced.length > 0) {
+ const shown = unpriced.slice(0, 3)
+ .map((u) => `${u.model} (${formatTokens(u.tokens)} tok)`)
+ .join(', ')
+ const more = unpriced.length > 3 ? ` +${unpriced.length - 3} more` : ''
+ out.push(kv('Unpriced', c.yellow(`${unpriced.length} model${unpriced.length === 1 ? '' : 's'} at $0: `) + shown + more))
+ out.push(kv('', c.dim('Fix: codeburn model-alias "" ')))
+ }
out.push('')
// Tokens breakdown: input / output / cache in (written) / cache out (read)
diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts
index 42240122..6fceb7af 100644
--- a/src/usage-aggregator.ts
+++ b/src/usage-aggregator.ts
@@ -2,7 +2,7 @@ import { homedir } from 'node:os'
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory, type DateRange } from './types.js'
import { type PeriodData, type ProviderCost, type BreakdownArrays, type MenubarPayload, buildMenubarPayload } from './menubar-json.js'
import { parseAllSessions, filterProjectsByName, filterProjectsByDays } from './parser.js'
-import { getLocalModelSavingsConfigHash, getPriceOverridesConfigHash, getShortModelName } from './models.js'
+import { findUnpricedModels, getLocalModelSavingsConfigHash, getPriceOverridesConfigHash, getShortModelName } from './models.js'
import { getAllProviders } from './providers/index.js'
import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.js'
import { aggregateModelEfficiency } from './model-efficiency.js'
@@ -13,7 +13,7 @@ import { getDaysInRange, ensureCacheHydrated, loadDailyCache, emptyCache, BACKFI
export function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
const sessions = projects.flatMap(p => p.sessions)
const catTotals: Record = {}
- const modelTotals: Record = {}
+ const modelTotals: Record = {}
let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0
for (const sess of sessions) {
@@ -30,10 +30,11 @@ export function buildPeriodData(label: string, projects: ProjectSummary[]): Peri
catTotals[cat].oneShotTurns += d.oneShotTurns
}
for (const [model, d] of Object.entries(sess.modelBreakdown)) {
- if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0, savingsUSD: 0 }
+ if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0, savingsUSD: 0, tokens: 0 }
modelTotals[model].calls += d.calls
modelTotals[model].cost += d.costUSD
modelTotals[model].savingsUSD += d.savingsUSD
+ modelTotals[model].tokens += d.tokens.inputTokens + d.tokens.outputTokens + d.tokens.cacheReadInputTokens + d.tokens.cacheCreationInputTokens
}
}
@@ -49,7 +50,9 @@ export function buildPeriodData(label: string, projects: ProjectSummary[]): Peri
.map(([cat, d]) => ({ name: CATEGORY_LABELS[cat as TaskCategory] ?? cat, ...d })),
models: Object.entries(modelTotals)
.sort(([, a], [, b]) => b.cost - a.cost)
- .map(([name, d]) => ({ name, ...d })),
+ .map(([name, d]) => ({ name, calls: d.calls, cost: d.cost, savingsUSD: d.savingsUSD })),
+ unpricedModels: findUnpricedModels(Object.entries(modelTotals)
+ .map(([model, d]) => ({ model, calls: d.calls, cost: d.cost, tokens: d.tokens }))),
}
}
diff --git a/tests/models.test.ts b/tests/models.test.ts
index 4ce651f5..1f703857 100644
--- a/tests/models.test.ts
+++ b/tests/models.test.ts
@@ -4,6 +4,7 @@ import { join } from 'path'
import { describe, it, expect, beforeAll, afterEach } from 'vitest'
import {
+ findUnpricedModels,
getModelCosts,
getShortModelName,
calculateCost,
@@ -675,3 +676,115 @@ describe('observed provider model aliases', () => {
expect(calculateCost('qwen3-max-2026-01-23', 1_000_000, 1_000_000, 0, 0, 0)).toBe(0)
})
})
+
+describe('findUnpricedModels', () => {
+ it('flags an unknown paid-looking model with $0 cost and skips priced ones', () => {
+ const rows = [
+ { model: 'claude-opus-4-6', calls: 10, cost: 2.5, tokens: 5000 },
+ { model: 'zz-mystery-paid-model-999', calls: 3, cost: 0, tokens: 1200 },
+ ]
+ const unpriced = findUnpricedModels(rows)
+ expect(unpriced).toEqual([{ model: 'zz-mystery-paid-model-999', calls: 3, tokens: 1200 }])
+ })
+
+ it('never flags a row that carries real cost, even when the lookup misses', () => {
+ // Aggregation keys rows by display name; the lookup misses but the row was
+ // priced at parse time, so it must not be reported as unpriced.
+ const unpriced = findUnpricedModels([
+ { model: 'Opus 4.8', calls: 100, cost: 42.5, tokens: 1_000_000 },
+ { model: 'zz-unknown-but-priced-elsewhere', calls: 5, cost: 0.01, tokens: 500 },
+ ])
+ expect(unpriced).toEqual([])
+ })
+
+ it('flags $0 display-name rows even when the raw id would price today', () => {
+ // Droid prices the lowercased display name ("claude sonnet 4.6" -> no
+ // pricing -> $0) and the parser keys the row by display name. Those
+ // tokens really entered the report at $0, so the row must be flagged
+ // even though claude-sonnet-4-6 itself is priced.
+ const unpriced = findUnpricedModels([
+ { model: 'Sonnet 4.6', calls: 12, cost: 0, tokens: 500_000 },
+ ])
+ expect(unpriced).toEqual([{ model: 'Sonnet 4.6', calls: 12, tokens: 500_000 }])
+ })
+
+ it('flags zero-rate pricing stubs but not explicit zero-rate user overrides', async () => {
+ // LiteLLM ships [0,0] stubs for models it lists but has no price for;
+ // a stub hit means "unknown price", not "free".
+ const cacheRoot = await mkdtemp(join(tmpdir(), 'codeburn-pricing-cache-'))
+ try {
+ process.env['CODEBURN_CACHE_DIR'] = cacheRoot
+ await writeFile(join(cacheRoot, 'litellm-pricing.json'), JSON.stringify({
+ timestamp: Date.now(),
+ data: {
+ 'zz-zero-stub-model': {
+ inputCostPerToken: 0,
+ outputCostPerToken: 0,
+ cacheWriteCostPerToken: 0,
+ cacheReadCostPerToken: 0,
+ webSearchCostPerRequest: 0,
+ fastMultiplier: 1,
+ },
+ },
+ }), 'utf-8')
+ await loadPricing()
+
+ expect(getModelCosts('zz-zero-stub-model')).not.toBeNull()
+ const rows = [{ model: 'zz-zero-stub-model', calls: 3, cost: 0, tokens: 1100 }]
+ expect(findUnpricedModels(rows)).toHaveLength(1)
+
+ // An explicit user override at zero rates means "this model is free".
+ setPriceOverrides({ 'zz-zero-stub-model': { input: 0, output: 0 } })
+ expect(findUnpricedModels(rows)).toEqual([])
+
+ // A prefix override cannot prove intent: getModelCosts resolves table
+ // hits before prefix overrides, so the $0 came from the stub, not the
+ // user. Still flagged.
+ setPriceOverrides({ 'zz-zero-stub': { input: 0, output: 0 } })
+ expect(findUnpricedModels(rows)).toHaveLength(1)
+ } finally {
+ delete process.env['CODEBURN_CACHE_DIR']
+ await rm(cacheRoot, { recursive: true, force: true })
+ setPriceOverrides({})
+ await loadPricing()
+ }
+ })
+
+ it('skips synthetic, empty, local-looking, and zero-usage rows', () => {
+ const unpriced = findUnpricedModels([
+ { model: '', calls: 5, cost: 0, tokens: 100 },
+ { model: '', calls: 5, cost: 0, tokens: 100 },
+ { model: 'llama3.1:8b', calls: 5, cost: 0, tokens: 100 },
+ { model: 'zz-quantized-model-bf16', calls: 5, cost: 0, tokens: 100 },
+ { model: 'zz-no-usage-model', calls: 0, cost: 0, tokens: 0 },
+ ])
+ expect(unpriced).toEqual([])
+ })
+
+ it('heals when the user configures an alias or a price override', () => {
+ const model = 'zz-proxy-renamed-model-x1'
+ expect(findUnpricedModels([{ model, calls: 1, cost: 0, tokens: 10 }])).toHaveLength(1)
+
+ setModelAliases({ [model]: 'claude-opus-4-6' })
+ expect(findUnpricedModels([{ model, calls: 1, cost: 0, tokens: 10 }])).toEqual([])
+ setModelAliases({})
+
+ setPriceOverrides({ [model]: { input: 1, output: 2 } })
+ expect(findUnpricedModels([{ model, calls: 1, cost: 0, tokens: 10 }])).toEqual([])
+ })
+
+ it('skips models mapped via model-savings (intentionally $0)', () => {
+ const model = 'zz-my-local-runner'
+ expect(findUnpricedModels([{ model, calls: 1, cost: 0, tokens: 10 }])).toHaveLength(1)
+ setLocalModelSavings({ [model]: 'gpt-4o' })
+ expect(findUnpricedModels([{ model, calls: 1, cost: 0, tokens: 10 }])).toEqual([])
+ })
+
+ it('sorts by tokens, then calls', () => {
+ const unpriced = findUnpricedModels([
+ { model: 'zz-small', calls: 9, cost: 0, tokens: 10 },
+ { model: 'zz-big', calls: 1, cost: 0, tokens: 9999 },
+ ])
+ expect(unpriced.map(u => u.model)).toEqual(['zz-big', 'zz-small'])
+ })
+})
diff --git a/tests/overview.test.ts b/tests/overview.test.ts
index 5f2dd759..89d6d758 100644
--- a/tests/overview.test.ts
+++ b/tests/overview.test.ts
@@ -116,3 +116,36 @@ describe('renderOverview', () => {
expect(out).not.toContain(' OS ')
})
})
+
+describe('renderOverview unpriced models', () => {
+ it('warns when a model with usage has no pricing data', () => {
+ const out = renderOverview([makeProject({
+ project: 'mystery',
+ projectPath: '/Users/test/mystery',
+ cost: 0,
+ calls: 4,
+ model: 'zz-mystery-paid-model-999',
+ provider: 'claude',
+ tokens: { input: 1000, output: 200, cacheR: 0, cacheW: 0 },
+ })], { label: 'June 2026', color: false })
+
+ expect(out).toContain('Unpriced')
+ expect(out).toContain('1 model at $0')
+ expect(out).toContain('zz-mystery-paid-model-999')
+ expect(out).toContain('codeburn model-alias')
+ })
+
+ it('stays silent when every model is priced', () => {
+ const out = renderOverview([makeProject({
+ project: 'priced',
+ projectPath: '/Users/test/priced',
+ cost: 5,
+ calls: 2,
+ model: 'claude-opus-4-8',
+ provider: 'claude',
+ tokens: { input: 100, output: 50, cacheR: 0, cacheW: 0 },
+ })], { label: 'June 2026', color: false })
+
+ expect(out).not.toContain('Unpriced')
+ })
+})