mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-23 15:34:19 +00:00
fix(models): treat subscription SKUs as honestly $0
Unpriced warnings were firing on flat-rate product ids and telling users to model-alias them, which invents spend.
This commit is contained in:
parent
7862aabd47
commit
4228c87595
13 changed files with 352 additions and 18 deletions
|
|
@ -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 |
|
||||
|
|
@ -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. `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
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,11 @@ 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[]
|
||||
// Spend budgets are stored in the configured display currency, not USD.
|
||||
budget?: {
|
||||
daily?: number
|
||||
|
|
|
|||
62
src/main.ts
62
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, sanitizeModelForDisplay, setModelAliases, setPriceOverrides, setLocalModelSavings, setProxyPaths, normalizeProxyPath } from './models.js'
|
||||
import { findUnpricedModels, loadPricing, sanitizeModelForDisplay, setModelAliases, setPriceOverrides, setLocalModelSavings, setFlatRateModels, setProxyPaths, normalizeProxyPath, unpricedModelHint } 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'
|
||||
|
|
@ -465,6 +465,7 @@ program.hook('preAction', async (thisCommand) => {
|
|||
setModelAliases(config.modelAliases ?? {})
|
||||
setPriceOverrides(config.priceOverrides ?? {})
|
||||
setLocalModelSavings(config.localModelSavings ?? {})
|
||||
setFlatRateModels(config.flatRateModels ?? [])
|
||||
setProxyPaths(config.proxyPaths ?? [])
|
||||
if (thisCommand.opts<{ verbose?: boolean }>().verbose) {
|
||||
process.env['CODEBURN_VERBOSE'] = '1'
|
||||
|
|
@ -1578,6 +1579,63 @@ 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')
|
||||
.option('--list', 'List configured flat-rate models')
|
||||
.action(async (model?: string, opts?: { remove?: string; list?: boolean }) => {
|
||||
const config = await readConfig()
|
||||
const marked = [...(config.flatRateModels ?? [])]
|
||||
|
||||
if (opts?.list || (!model && !opts?.remove)) {
|
||||
if (marked.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 {
|
||||
console.log('\n Flat-rate / subscription models:')
|
||||
for (const name of marked) {
|
||||
console.log(` ${name}`)
|
||||
}
|
||||
console.log(` Config: ${getConfigFilePath()}\n`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (opts?.remove) {
|
||||
const idx = marked.indexOf(opts.remove)
|
||||
if (idx < 0) {
|
||||
console.error(`\n No flat-rate mark found for: ${opts.remove}\n`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
marked.splice(idx, 1)
|
||||
config.flatRateModels = marked.length > 0 ? marked : undefined
|
||||
await saveConfig(config)
|
||||
console.log(`\n Removed flat-rate mark: ${opts.remove}\n`)
|
||||
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
|
||||
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.')
|
||||
|
|
@ -2163,7 +2221,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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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_',
|
||||
|
|
|
|||
|
|
@ -520,6 +520,75 @@ export function getLocalModelSavingsConfigHash(): string {
|
|||
return parts.join('\u0002')
|
||||
}
|
||||
|
||||
// Subscription / flat-rate product SKUs. $0 is the correct cost; aliasing
|
||||
// them onto a per-token row fabricates spend (#968). Distinct from
|
||||
// model-savings (counterfactual local baseline) and from a zero-rate
|
||||
// price-override (user-declared free). Built-in families plus a user hatch.
|
||||
let userFlatRateModels = new Set<string>()
|
||||
let userFlatRateLeaves = new Set<string>()
|
||||
|
||||
function flatRateLeaf(model: string): string {
|
||||
const trimmed = model.trim().replace(/@.*$/, '').replace(/-\d{8}$/, '')
|
||||
const leaf = trimmed.includes('/') ? trimmed.slice(trimmed.lastIndexOf('/') + 1) : trimmed
|
||||
return leaf.toLowerCase()
|
||||
}
|
||||
|
||||
export function setFlatRateModels(models: Iterable<string>): void {
|
||||
userFlatRateModels = new Set()
|
||||
userFlatRateLeaves = new Set()
|
||||
for (const model of models) {
|
||||
if (!model || typeof model !== 'string') continue
|
||||
userFlatRateModels.add(model)
|
||||
const leaf = flatRateLeaf(model)
|
||||
if (leaf) userFlatRateLeaves.add(leaf)
|
||||
}
|
||||
}
|
||||
|
||||
export function getFlatRateModelsConfigHash(): string {
|
||||
return [...userFlatRateModels].sort().join('\u0002')
|
||||
}
|
||||
|
||||
export function getFlatRateModels(): string[] {
|
||||
return [...userFlatRateModels]
|
||||
}
|
||||
|
||||
function isUserFlatRateModel(model: string): boolean {
|
||||
if (userFlatRateModels.has(model)) return true
|
||||
const leaf = flatRateLeaf(model)
|
||||
return leaf.length > 0 && userFlatRateLeaves.has(leaf)
|
||||
}
|
||||
|
||||
/// Product SKUs billed as a subscription, not missing LiteLLM rows.
|
||||
/// Match raw ids, path-prefixed ids (`cline-pass/auto-genius`), and the
|
||||
/// display names aggregation keys by (parser.ts uses getShortModelName).
|
||||
function isBuiltInFlatRateModel(model: string): boolean {
|
||||
const leaf = flatRateLeaf(model)
|
||||
if (
|
||||
leaf === 'warp'
|
||||
|| leaf === 'codex-auto-review'
|
||||
|| leaf === 'auto-genius'
|
||||
|| leaf === 'big-pickle'
|
||||
) return true
|
||||
if (leaf.startsWith('grok-composer-')) return true
|
||||
if (leaf.startsWith('warp-auto-')) return true
|
||||
const display = model.trim()
|
||||
if (/^codex auto review$/i.test(display)) return true
|
||||
if (/^grok composer\b/i.test(display)) return true
|
||||
if (/^warp auto\b/i.test(display)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function isFlatRateModel(model: string): boolean {
|
||||
if (!model) return false
|
||||
return isUserFlatRateModel(model) || isBuiltInFlatRateModel(model)
|
||||
}
|
||||
|
||||
/// Shared unpriced-warning copy. Never tell the user to alias unconditionally:
|
||||
/// mapping a subscription SKU onto a priced row invents spend.
|
||||
export function unpricedModelHint(): string {
|
||||
return 'If a model is billed per token, map it with: codeburn model-alias "<model>" <known-model>. If $0 is correct (subscription / flat-rate): codeburn model-flat-rate "<model>".'
|
||||
}
|
||||
|
||||
/// Stable hash of the model-alias map, for the same staleness class as the
|
||||
/// hashes below: a resident process (codeburn serve) must not serve memoized
|
||||
/// parse results priced under aliases the user has since changed.
|
||||
|
|
@ -848,14 +917,18 @@ function exactPriceOverrideFor(model: string): ModelCosts | null {
|
|||
// correct cost, as are zero-rate USER overrides (explicitly declared free).
|
||||
/// Models whose $0 cost is CORRECT rather than a pricing gap, mirroring the
|
||||
/// exclusions findUnpricedModels applies: local-looking models, models mapped
|
||||
/// to a local-savings baseline, and models an exact zero-rate user override
|
||||
/// declares free. Used to keep their calls out of the pricing-coverage
|
||||
/// denominator — otherwise a 95%-ollama user reads high coverage while every
|
||||
/// genuinely cost-bearing call is unpriced.
|
||||
/// to a local-savings baseline, subscription / flat-rate product SKUs, and
|
||||
/// models an exact zero-rate user override declares free. Used to keep their
|
||||
/// calls out of the pricing-coverage denominator — otherwise a 95%-ollama
|
||||
/// user reads high coverage while every genuinely cost-bearing call is unpriced.
|
||||
export function isExpectedFreeModel(model: string): boolean {
|
||||
if (looksLikeLocalModel(model)) return true
|
||||
if (getLocalSavingsBaseline(model)) return true
|
||||
const costs = getModelCosts(model)
|
||||
// A builtin/user alias can still attach a billable rate to a subscription
|
||||
// SKU (warp-auto-* today). Those calls are priced, so they stay in the
|
||||
// coverage denominator. Only the $0 / no-rate case is expected-free.
|
||||
if (isFlatRateModel(model) && (!costs || !hasBillableRate(costs))) return true
|
||||
if (costs && !hasBillableRate(costs) && exactPriceOverrideFor(model)) return true
|
||||
return false
|
||||
}
|
||||
|
|
@ -872,6 +945,7 @@ export function findUnpricedModels(
|
|||
if (row.cost > 0) continue
|
||||
if (looksLikeLocalModel(model)) continue
|
||||
if (getLocalSavingsBaseline(model)) continue
|
||||
if (isFlatRateModel(model)) continue
|
||||
const costs = getModelCosts(model)
|
||||
if (costs && hasBillableRate(costs)) continue
|
||||
if (costs && exactPriceOverrideFor(model)) continue
|
||||
|
|
@ -888,7 +962,8 @@ function shouldWarnAboutUnknownModel(name: string): boolean {
|
|||
// actively misleading there. Users who need cost visibility for local
|
||||
// inference can still set an alias via `codeburn model-alias`.
|
||||
if (looksLikeLocalModel(name)) return false
|
||||
// The warning fired on every CLI invocation (including the default
|
||||
if (isFlatRateModel(name)) return false
|
||||
// The warning fired on every CLI invocation (including the default)
|
||||
// dashboard) which made first launches look broken — three "no pricing
|
||||
// data" lines greet a user before the dashboard even draws. Now opt-in
|
||||
// via --verbose. The unknown model still costs $0 in reports; users who
|
||||
|
|
@ -1157,6 +1232,7 @@ export type PricingSnapshot = {
|
|||
aliases: Record<string, string>
|
||||
priceOverrides: Record<string, PriceOverrideRates>
|
||||
localModelSavings: Record<string, string>
|
||||
flatRateModels?: string[]
|
||||
}
|
||||
|
||||
export function snapshotPricingState(): PricingSnapshot {
|
||||
|
|
@ -1165,6 +1241,7 @@ export function snapshotPricingState(): PricingSnapshot {
|
|||
aliases: userAliases,
|
||||
priceOverrides: userPriceOverridesConfig,
|
||||
localModelSavings: userLocalModelSavings,
|
||||
flatRateModels: getFlatRateModels(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1176,4 +1253,5 @@ export function restorePricingState(snapshot: PricingSnapshot): void {
|
|||
setModelAliases(snapshot.aliases)
|
||||
setPriceOverrides(snapshot.priceOverrides)
|
||||
setLocalModelSavings(snapshot.localModelSavings)
|
||||
setFlatRateModels(snapshot.flatRateModels ?? [])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { homedir } from 'os'
|
|||
|
||||
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
|
||||
import { formatCost as baseCost, getCurrency } from './currency.js'
|
||||
import { findUnpricedModels, getShortModelName } from './models.js'
|
||||
import { findUnpricedModels, getShortModelName, unpricedModelHint } from './models.js'
|
||||
import { markEstimated } from './format.js'
|
||||
import { dateKey } from './day-aggregator.js'
|
||||
import type { DailyEntry } from './daily-cache.js'
|
||||
|
|
@ -224,7 +224,7 @@ export function renderOverview(
|
|||
.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 "<model>" <known-model>')))
|
||||
out.push(kv('', c.dim(unpricedModelHint())))
|
||||
}
|
||||
if (opts.budget) {
|
||||
const label = opts.budget.tier === 'daily'
|
||||
|
|
|
|||
|
|
@ -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, type ClaudeConfigSelector, buildMenubarPayload } from './menubar-json.js'
|
||||
import { parseAllSessions, filterProjectsByName, filterProjectsByDays, filterProjectsByClaudeConfigSource, isSessionHydrationComplete } from './parser.js'
|
||||
import { findUnpricedModels, getLocalModelSavingsConfigHash, getPriceOverridesConfigHash, getShortModelName, isExpectedFreeModel } from './models.js'
|
||||
import { findUnpricedModels, getFlatRateModelsConfigHash, getLocalModelSavingsConfigHash, getPriceOverridesConfigHash, getShortModelName, isExpectedFreeModel } from './models.js'
|
||||
import { getAllProviders, safeDiscoverSessions } from './providers/index.js'
|
||||
import { claude, getClaudeConfigDirs, getDesktopSessionsDirs } from './providers/claude.js'
|
||||
import { stat } from 'node:fs/promises'
|
||||
|
|
@ -82,8 +82,8 @@ export function buildPeriodData(label: string, projects: ProjectSummary[]): Peri
|
|||
export function getDailyCacheConfigHash(): string {
|
||||
const savingsHash = getLocalModelSavingsConfigHash()
|
||||
const overridesHash = getPriceOverridesConfigHash()
|
||||
if (!overridesHash) return savingsHash
|
||||
return `localModelSavings=${savingsHash}\u0002priceOverrides=${overridesHash}`
|
||||
const flatRateHash = getFlatRateModelsConfigHash()
|
||||
return `localModelSavings=${savingsHash}\u0002priceOverrides=${overridesHash}\u0002flatRateModels=${flatRateHash}`
|
||||
}
|
||||
|
||||
async function hydrateCache(): Promise<DailyCache> {
|
||||
|
|
|
|||
77
tests/cli-model-flat-rate.test.ts
Normal file
77
tests/cli-model-flat-rate.test.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { mkdtemp, readFile, rm } 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'
|
||||
|
||||
const CLI_TIMEOUT_MS = 10_000
|
||||
|
||||
function runCli(args: string[], home: string) {
|
||||
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
HOMEPATH: home,
|
||||
HOMEDRIVE: '',
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
}
|
||||
|
||||
function readConfig(home: string): Promise<Record<string, unknown>> {
|
||||
return readFile(join(home, '.config', 'codeburn', 'config.json'), 'utf-8')
|
||||
.then(raw => JSON.parse(raw) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
describe('codeburn model-flat-rate command', () => {
|
||||
it('saves, lists, and removes a flat-rate mark', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-flat-rate-'))
|
||||
try {
|
||||
const set = runCli(['model-flat-rate', 'auto-genius'], home)
|
||||
expect(set.status).toBe(0)
|
||||
expect(set.stdout).toContain('Flat-rate mark saved: auto-genius')
|
||||
|
||||
const saved = await readConfig(home)
|
||||
expect(saved.flatRateModels).toEqual(['auto-genius'])
|
||||
|
||||
const list = runCli(['model-flat-rate', '--list'], home)
|
||||
expect(list.status).toBe(0)
|
||||
expect(list.stdout).toContain('auto-genius')
|
||||
|
||||
const remove = runCli(['model-flat-rate', '--remove', 'auto-genius'], home)
|
||||
expect(remove.status).toBe(0)
|
||||
|
||||
const after = await readConfig(home)
|
||||
expect(after.flatRateModels).toBeUndefined()
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
}, CLI_TIMEOUT_MS)
|
||||
|
||||
it('warns when the same model is also configured in modelAliases', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-flat-rate-'))
|
||||
try {
|
||||
expect(runCli(['model-alias', 'auto-genius', 'gpt-4o'], home).status).toBe(0)
|
||||
const set = runCli(['model-flat-rate', 'auto-genius'], home)
|
||||
expect(set.status).toBe(0)
|
||||
expect(set.stdout).toContain('also in modelAliases')
|
||||
expect(set.stdout).toContain('invents per-token spend')
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
}, CLI_TIMEOUT_MS)
|
||||
|
||||
it('rejects a remove for an unknown mark', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-flat-rate-'))
|
||||
try {
|
||||
const result = runCli(['model-flat-rate', '--remove', 'unknown-sku'], home)
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('No flat-rate mark found')
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
}, CLI_TIMEOUT_MS)
|
||||
})
|
||||
|
|
@ -114,9 +114,7 @@ describe('codeburn models --unpriced public CLI', () => {
|
|||
expect(result.stdout).toContain('acme/unknown-beta-969')
|
||||
expect(result.stdout).not.toContain('claude-opus-4-6')
|
||||
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).toContain('codeburn model-flat-rate')
|
||||
expect(result.stdout).not.toContain('Fix: codeburn model-alias')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -25,6 +25,14 @@ describe('tables', () => {
|
|||
expect(t).toContain('Opus 4.8')
|
||||
expect(t).toContain('| Model | Cost | Calls |')
|
||||
})
|
||||
it('unpriced warning names the flat-rate hatch instead of only alias', () => {
|
||||
const p = payload()
|
||||
p.current.unpricedModels = [{ model: 'zz-mystery-paid-model-999', calls: 3, tokens: 1200 }]
|
||||
const t = renderSummaryTable(p)
|
||||
expect(t).toContain('zz-mystery-paid-model-999')
|
||||
expect(t).toContain('model-flat-rate')
|
||||
expect(t).not.toContain('fix with `codeburn model-alias`')
|
||||
})
|
||||
it('breakdown by provider lists providers', () => {
|
||||
expect(renderBreakdownTable(payload(), 'provider', 20)).toContain('claude code')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -12,10 +12,15 @@ import {
|
|||
setModelAliases,
|
||||
setPriceOverrides,
|
||||
setLocalModelSavings,
|
||||
setFlatRateModels,
|
||||
isExpectedFreeModel,
|
||||
isFlatRateModel,
|
||||
getLocalModelSavingsConfigHash,
|
||||
getPriceOverridesConfigHash,
|
||||
getModelAliasesConfigHash,
|
||||
getFlatRateModelsConfigHash,
|
||||
parseLiteLLMEntry,
|
||||
unpricedModelHint,
|
||||
} from '../src/models.js'
|
||||
import { getDailyCacheConfigHash } from '../src/usage-aggregator.js'
|
||||
|
||||
|
|
@ -27,6 +32,7 @@ afterEach(() => {
|
|||
setModelAliases({})
|
||||
setPriceOverrides({})
|
||||
setLocalModelSavings({})
|
||||
setFlatRateModels([])
|
||||
})
|
||||
|
||||
describe('getModelCosts', () => {
|
||||
|
|
@ -486,6 +492,17 @@ describe('user price overrides', () => {
|
|||
expect(secondCombined).not.toBe(baseline)
|
||||
expect(secondCombined).not.toBe(firstCombined)
|
||||
})
|
||||
|
||||
it('includes flat-rate marks in the daily cache config hash', () => {
|
||||
setLocalModelSavings({})
|
||||
setPriceOverrides({})
|
||||
setFlatRateModels([])
|
||||
const baseline = getDailyCacheConfigHash()
|
||||
setFlatRateModels(['zz-flat-hash'])
|
||||
expect(getDailyCacheConfigHash()).not.toBe(baseline)
|
||||
setFlatRateModels([])
|
||||
expect(getDailyCacheConfigHash()).toBe(baseline)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateCost - OMP names produce non-zero cost', () => {
|
||||
|
|
@ -980,6 +997,41 @@ describe('findUnpricedModels', () => {
|
|||
expect(findUnpricedModels([{ model, calls: 1, cost: 0, tokens: 10 }])).toEqual([])
|
||||
})
|
||||
|
||||
it('skips subscription / flat-rate product SKUs where $0 is correct', () => {
|
||||
const rows = [
|
||||
{ model: 'auto-genius', calls: 898, cost: 0, tokens: 35_300_000 },
|
||||
{ model: 'cline-pass/big-pickle', calls: 4, cost: 0, tokens: 33_900 },
|
||||
{ model: 'warp', calls: 449, cost: 0, tokens: 17_700_000 },
|
||||
{ model: 'codex-auto-review', calls: 940, cost: 0, tokens: 7_200_000 },
|
||||
{ model: 'grok-composer-2.5-fast', calls: 10, cost: 0, tokens: 1_900_000 },
|
||||
{ model: 'Grok Composer 2.5 Fast', calls: 10, cost: 0, tokens: 1_900_000 },
|
||||
{ model: 'Codex Auto Review', calls: 2, cost: 0, tokens: 100 },
|
||||
{ model: 'zz-mystery-paid-model-999', calls: 3, cost: 0, tokens: 1200 },
|
||||
]
|
||||
expect(findUnpricedModels(rows)).toEqual([
|
||||
{ model: 'zz-mystery-paid-model-999', calls: 3, tokens: 1200 },
|
||||
])
|
||||
})
|
||||
|
||||
it('skips a user-declared flat-rate model, including path-prefixed siblings', () => {
|
||||
const model = 'zz-my-pass-codename'
|
||||
expect(findUnpricedModels([{ model, calls: 1, cost: 0, tokens: 10 }])).toHaveLength(1)
|
||||
setFlatRateModels([model])
|
||||
expect(findUnpricedModels([{ model, calls: 1, cost: 0, tokens: 10 }])).toEqual([])
|
||||
expect(findUnpricedModels([{ model: `vendor/${model}`, calls: 1, cost: 0, tokens: 10 }])).toEqual([])
|
||||
expect(findUnpricedModels([{ model: 'zz-other-unknown', calls: 1, cost: 0, tokens: 10 }])).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not treat a priced sibling as expected-free just because a family is flat-rate', () => {
|
||||
// warp-auto-* is a subscription SKU, but main already aliases it onto a
|
||||
// billable row. Coverage must still count those priced calls.
|
||||
expect(isFlatRateModel('warp-auto-efficient')).toBe(true)
|
||||
expect(getModelCosts('warp-auto-efficient')).not.toBeNull()
|
||||
expect(isExpectedFreeModel('warp-auto-efficient')).toBe(false)
|
||||
expect(isExpectedFreeModel('auto-genius')).toBe(true)
|
||||
expect(isExpectedFreeModel('zz-mystery-paid-model-999')).toBe(false)
|
||||
})
|
||||
|
||||
it('sorts by tokens, then calls', () => {
|
||||
const unpriced = findUnpricedModels([
|
||||
{ model: 'zz-small', calls: 9, cost: 0, tokens: 10 },
|
||||
|
|
@ -1019,3 +1071,41 @@ describe('getModelAliasesConfigHash', () => {
|
|||
setModelAliases({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFlatRateModelsConfigHash', () => {
|
||||
it('is empty for no marks, changes with content, ignores insertion order', () => {
|
||||
setFlatRateModels([])
|
||||
expect(getFlatRateModelsConfigHash()).toBe('')
|
||||
setFlatRateModels(['auto-genius'])
|
||||
const one = getFlatRateModelsConfigHash()
|
||||
expect(one).not.toBe('')
|
||||
setFlatRateModels(['warp', 'auto-genius'])
|
||||
const two = getFlatRateModelsConfigHash()
|
||||
expect(two).not.toBe(one)
|
||||
setFlatRateModels(['auto-genius', 'warp'])
|
||||
expect(getFlatRateModelsConfigHash()).toBe(two)
|
||||
setFlatRateModels([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('pricing snapshot carries flat-rate marks', () => {
|
||||
it('restorePricingState reapplies user flat-rate marks', async () => {
|
||||
const { snapshotPricingState, restorePricingState } = await import('../src/models.js')
|
||||
setFlatRateModels(['zz-snapshot-flat'])
|
||||
const snap = snapshotPricingState()
|
||||
expect(snap.flatRateModels).toEqual(['zz-snapshot-flat'])
|
||||
setFlatRateModels([])
|
||||
expect(isFlatRateModel('zz-snapshot-flat')).toBe(false)
|
||||
restorePricingState(snap)
|
||||
expect(isFlatRateModel('zz-snapshot-flat')).toBe(true)
|
||||
setFlatRateModels([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('unpricedModelHint', () => {
|
||||
it('never tells the user to alias unconditionally', () => {
|
||||
expect(unpricedModelHint()).toContain('If a model is billed per token')
|
||||
expect(unpricedModelHint()).toContain('model-flat-rate')
|
||||
expect(unpricedModelHint()).not.toContain('Fix: codeburn model-alias')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -240,6 +240,23 @@ describe('renderOverview unpriced models', () => {
|
|||
expect(out).toContain('1 model at $0')
|
||||
expect(out).toContain('zz-mystery-paid-model-999')
|
||||
expect(out).toContain('codeburn model-alias')
|
||||
expect(out).toContain('model-flat-rate')
|
||||
expect(out).not.toContain('Fix: codeburn model-alias')
|
||||
})
|
||||
|
||||
it('stays silent for subscription SKUs whose $0 is correct', () => {
|
||||
const out = renderOverview([makeProject({
|
||||
project: 'pass',
|
||||
projectPath: '/Users/test/pass',
|
||||
cost: 0,
|
||||
calls: 4,
|
||||
model: 'auto-genius',
|
||||
provider: 'cline-cli',
|
||||
tokens: { input: 1000, output: 200, cacheR: 0, cacheW: 0 },
|
||||
})], { label: 'June 2026', color: false })
|
||||
|
||||
expect(out).not.toContain('Unpriced')
|
||||
expect(out).not.toContain('model-alias')
|
||||
})
|
||||
|
||||
it('stays silent when every model is priced', () => {
|
||||
|
|
|
|||
|
|
@ -350,6 +350,7 @@ describe('review-findings regressions', () => {
|
|||
expect(isExpectedFreeModel('qwen3.6:35b-a3b-bf16')).toBe(true)
|
||||
expect(isExpectedFreeModel('llama-3-8b-q4')).toBe(true)
|
||||
expect(isExpectedFreeModel('claude-opus-4-8')).toBe(false)
|
||||
expect(isExpectedFreeModel('auto-genius')).toBe(true)
|
||||
// 95 local calls + 5 unpriced cloud calls: coverage must be 0, not 0.95.
|
||||
expect(computePricingCoverage(5, 5)).toBe(0)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue