Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,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
- **Subscription SKUs are classified from real product ids, and a false-positive built-in can be opted out.** `codex-auto-review` consumes ordinary Codex usage ([openai/codex#32224](https://github.com/openai/codex/issues/32224)) and is priced as GPT-5.5 on #1056, so treating it as $0 hid real spend — it left the flat-rate list. Warp's product id is `auto`, not the synthetic `warp`. `kimi-for-coding-highspeed` (the SKU #968 was filed around) is now honestly $0. `big-pickle` was dropped: it appears under OpenCode, not as a cited ClinePass codename. `codeburn model-flat-rate --remove` now opts out of a built-in, so a wrong classifier entry can warn again without waiting for a release. The daily-cache config hash now always includes the flat-rate section (even when empty), so the first run after upgrade re-derives every stored day once from the warm session cache. (#968, #1050)
- **Codex MCP and skill usage is attributed from every shape Codex records a shell command in.** `mcp-cli call <server> <tool>` was only recognized when the command arrived as `function_call` arguments (#656). Codex has two other shapes for the same exec: its custom-tool transport records the shell tool as a `custom_tool_call` whose payload is an `input` program rather than `arguments`, and its item model repeats a finished command as `event_msg`/`item_completed` carrying a `CommandExecution` item with an argv `command`. Both reached the Bash counter and neither reached the matcher, so a CLI-wrapped MCP call stayed absent from the MCP breakdown exactly as before the fix. All three shapes now feed one classification pipeline. The same pipeline learns skills: Codex has no skill tool, so loading one is a shell read of the skill's `SKILL.md`, and those reads landed entirely under Bash with the Skills dimension empty. A read counts as a skill load only when the command segment starts with a file-reading binary (`cat`/`bat`/`sed`/`head`/`tail`/`less`/`more`) and the path it reads ends in `<name>/SKILL.md`; the skill is `<name>`, the same key `pi` derives for a native skill read (#588) and the same vocabulary the Claude parser records from the `Skill` tool. A `grep`/`rg`/`ls` that merely mentions a `SKILL.md` is a search near the file, not a skill load, and stays plain Bash. This is attribution only — no call, token or cost figure moves, and a command carried by both a response item and an item-model item is attributed once. On a 1,397-rollout corpus: Skills went from empty to 7 skills over 35 turns (55 attributions), Bash was unchanged at 42,170, and cost, calls, tokens, sessions, daily, models and projects came back identical. Cached Codex sessions re-parse once (`CODEX_CACHE_VERSION` 13 → 14 and the codex parse version both move; without them the fix is invisible on a warm cache). Thanks @chr-evensen. (#478)
- **`gpt-5.6-codex` and `gpt-5.6-codex-max` now have their own pricing rows.** Neither id is in LiteLLM yet, and both were missing from the bundled snapshot — flagged during #1075 verification on a real corpus (285 sessions, 5,446 calls). `getModelCosts` already resolved both through the `gpt-5.6` prefix fallback, so live pricing was already correct once a session priced fresh; every prior Codex-suffixed id LiteLLM does carry bills identically to its bare-model sibling of the same generation (`gpt-5-codex` == `gpt-5`, `gpt-5.1-codex` == `gpt-5.1-codex-max` == `gpt-5.1`, `gpt-5.2-codex` == `gpt-5.2`, `gpt-5.3-codex` == `gpt-5.3`), which is the evidence both new rows mirror rather than inventing a rate. The gap that does not self-heal is the daily cache: it has no per-provider invalidation, so a day finalized while either id had no billable rate keeps that $0 forever. Raising `MIN_SUPPORTED_VERSION` (v23 -> v24) forces the one-time re-derivation, a lossless no-op for days already correct. (#1077)
- **Mixed-version installs no longer thrash the Codex / Cursor / Antigravity result caches.** Daily and session caches already own a version-suffixed file so an old desktop binary and a newer CLI cannot clobber each other. The three per-provider result caches still used one unsuffixed filename with an internal version field, so a v10 and a v11 binary rewrote the same `codex-results.json` (and the Cursor / Antigravity siblings) on every run and each re-parsed its whole corpus. They now write `*-results.v<n>.json` the same way the daily cache does. The unsuffixed file is left for older binaries; a matching-version copy is adopted once and never overwritten. (#1082)
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -516,7 +516,7 @@ 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. Shows raw model IDs (not friendly names) so they can be pasted into `model-alias`; JSON keeps them exact |
| `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). Per-token gaps go to `model-alias`; subscription / flat-rate SKUs go to `model-flat-rate`. JSON keeps IDs 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 |
Expand Down Expand Up @@ -608,10 +608,11 @@ Aliases are stored in `~/.config/codeburn/config.json` and applied at runtime be
```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 model-flat-rate auto-genius # subscription SKU, $0 is correct
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`.
`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. `model-flat-rate` marks a subscription-billed product SKU so the unpriced warning stays quiet and `model-alias` is not suggested — aliasing those ids invents spend. `--remove` also opts out of a built-in SKU. `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 four support `--list` and `--remove`.

### Filtering

Expand Down
9 changes: 9 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ export type CodeburnConfig = {
// can show "saved $X by running locally". Distinct from modelAliases which
// rewrites actual spend.
localModelSavings?: Record<string, string>
// Model ids whose $0 cost is correct because they are billed as a
// subscription / flat-rate product, not missing LiteLLM rows. Distinct from
// modelAliases (which invent per-token spend) and localModelSavings
// (counterfactual local baseline). See `codeburn model-flat-rate`.
flatRateModels?: string[]
// Opt-outs from the built-in flat-rate classifier. `model-flat-rate --remove`
// on a built-in SKU records the id here so a false positive can warn again
// without waiting for a release.
flatRateModelsRemoved?: string[]
// Spend budgets are stored in the configured display currency, not USD.
budget?: {
daily?: number
Expand Down
87 changes: 85 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, sanitizeModelForDisplay, setModelAliases, setPriceOverrides, setLocalModelSavings, setProxyPaths, normalizeProxyPath } from './models.js'
import { findUnpricedModels, loadPricing, sanitizeModelForDisplay, setModelAliases, setPriceOverrides, setLocalModelSavings, setFlatRateModels, setFlatRateRemoved, setProxyPaths, normalizeProxyPath, unpricedModelHint, isBuiltInFlatRateModel, isSameFlatRateModel } 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'
Expand Down Expand Up @@ -465,6 +465,8 @@ program.hook('preAction', async (thisCommand) => {
setModelAliases(config.modelAliases ?? {})
setPriceOverrides(config.priceOverrides ?? {})
setLocalModelSavings(config.localModelSavings ?? {})
setFlatRateModels(config.flatRateModels ?? [])
setFlatRateRemoved(config.flatRateModelsRemoved ?? [])
setProxyPaths(config.proxyPaths ?? [])
if (thisCommand.opts<{ verbose?: boolean }>().verbose) {
process.env['CODEBURN_VERBOSE'] = '1'
Expand Down Expand Up @@ -1524,6 +1526,87 @@ program
console.log(` Config: ${getConfigFilePath()}\n`)
})

program
.command('model-flat-rate [model]')
.description('Mark a model as subscription / flat-rate billed. $0 is the correct cost and the unpriced warning is silenced. Do not use model-alias for these — that maps them onto another model\'s per-token rate and invents spend (e.g. codeburn model-flat-rate auto-genius).')
.option('--remove <model>', 'Remove a flat-rate mark, including a built-in SKU')
.option('--list', 'List configured flat-rate models and built-in opt-outs')
.action(async (model?: string, opts?: { remove?: string; list?: boolean }) => {
const config = await readConfig()
const marked = [...(config.flatRateModels ?? [])]
const removed = [...(config.flatRateModelsRemoved ?? [])]

if (opts?.list || (!model && !opts?.remove)) {
if (marked.length === 0 && removed.length === 0) {
console.log('\n No flat-rate models configured.')
console.log(` Config: ${getConfigFilePath()}`)
console.log(' Add one with: codeburn model-flat-rate <model>\n')
} else {
if (marked.length > 0) {
console.log('\n Flat-rate / subscription models:')
for (const name of marked) {
console.log(` ${name}`)
}
}
if (removed.length > 0) {
console.log('\n Built-in flat-rate opt-outs (unpriced warning fires again):')
for (const name of removed) {
console.log(` ${name}`)
}
}
console.log(` Config: ${getConfigFilePath()}\n`)
}
return
}

if (opts?.remove) {
const target = opts.remove
const idx = marked.indexOf(target)
const builtIn = isBuiltInFlatRateModel(target)
const alreadyOptedOut = removed.some(id => isSameFlatRateModel(id, target))
if (idx < 0 && (!builtIn || alreadyOptedOut)) {
console.error(`\n No flat-rate mark found for: ${target}\n`)
process.exitCode = 1
return
}
if (idx >= 0) {
marked.splice(idx, 1)
config.flatRateModels = marked.length > 0 ? marked : undefined
}
if (builtIn && !alreadyOptedOut) {
removed.push(target)
config.flatRateModelsRemoved = removed
}
await saveConfig(config)
console.log(`\n Removed flat-rate mark: ${target}`)
if (builtIn) {
console.log(' Built-in SKU opted out; the unpriced warning will fire again until you re-add it.')
}
console.log()
return
}

if (!model) {
console.error('\n Usage: codeburn model-flat-rate <model>\n')
process.exitCode = 1
return
}

if (!marked.includes(model)) marked.push(model)
config.flatRateModels = marked
const remainingOptOuts = removed.filter(id => !isSameFlatRateModel(id, model))
config.flatRateModelsRemoved = remainingOptOuts.length > 0 ? remainingOptOuts : undefined
await saveConfig(config)

if (config.modelAliases && Object.hasOwn(config.modelAliases, model)) {
console.log(`\n Note: ${model} is also in modelAliases (-> ${config.modelAliases[model]}).`)
console.log(' The alias still invents per-token spend. Remove it if $0 is the correct cost.')
}

console.log(`\n Flat-rate mark saved: ${model}`)
console.log(` Config: ${getConfigFilePath()}\n`)
})

program
.command('proxy-path [path]')
.description('Mark a project directory as routed through a subscription-backed LLM proxy (e.g. Claude Code over GitHub Copilot). Sessions whose canonical path is under it keep their full API-rate cost as the "would-be" figure, but that amount is reported as subscription-covered so the report can show net out-of-pocket (e.g. codeburn proxy-path ~/work/copilot-repo). Actual API-key sessions elsewhere are untouched.')
Expand Down Expand Up @@ -2109,7 +2192,7 @@ program
process.stdout.write(renderTable(renderRows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, showTotals: opts.totals !== false }) + '\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')
if (opts.unpriced) process.stdout.write(unpricedModelHint() + '\n')
} else {
process.stderr.write(`codeburn: unknown --format "${opts.format}". Choose table, markdown, json, or csv.\n`)
process.exit(1)
Expand Down
3 changes: 2 additions & 1 deletion src/mcp/tables.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { formatCost, formatTokens, markEstimated } from '../format.js'
import type { MenubarPayload } from '../menubar-json.js'
import { unpricedModelHint } from '../models.js'

const ESTIMATED_LEGEND = '_~ estimated cost (priced from estimated tokens)_'
const isEstimated = (m: { estimatedCostUSD?: number }) => (m.estimatedCostUSD ?? 0) > 0
Expand All @@ -23,7 +24,7 @@ export function renderSummaryTable(p: MenubarPayload): string {
`**${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\`.`]
? [`⚠ ${unpriced.length} model${unpriced.length === 1 ? '' : 's'} unpriced, counted at $0: ${unpriced.map(u => `${u.model} (${u.calls} calls)`).join(', ')}. ${unpricedModelHint()}`]
: []),
'',
'_Top models_',
Expand Down
Loading
Loading