Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export type CodeburnConfig = {
plan?: Plan
plans?: PlanConfigMap
modelAliases?: Record<string, string>
// Rates are stored as USD per 1,000,000 tokens; models.ts converts them to per-token ModelCosts.
priceOverrides?: Record<string, { input: number; output: number; cacheRead?: number; cacheCreation?: number }>
// Extra Claude config directories to aggregate usage across (e.g. work /
// personal accounts). Honored by getClaudeConfigDirs() below the
// CLAUDE_CONFIG_DIRS/CLAUDE_CONFIG_DIR env vars. Lets the macOS menubar (a
Expand Down
108 changes: 106 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 } from 'commander'
import { installMenubarApp } from './menubar-installer.js'
import { exportCsv, exportJson, type PeriodExport } from './export.js'
import { loadPricing, setModelAliases, setLocalModelSavings, setProxyPaths, normalizeProxyPath } from './models.js'
import { 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'
Expand All @@ -29,7 +29,7 @@ import {
runAgyStatusLineHook,
uninstallAntigravityStatusLineHook,
} from './antigravity-statusline.js'
import { clearPlan, readConfig, readPlan, readPlans, saveConfig, savePlan, getConfigFilePath, type Plan, type PlanId, type PlanProvider } from './config.js'
import { clearPlan, readConfig, readPlan, readPlans, saveConfig, savePlan, getConfigFilePath, type CodeburnConfig, type Plan, type PlanId, type PlanProvider } from './config.js'
import { clampResetDay, getPlanUsageOrNull, getPlanUsages, type PlanUsage } from './plan-usage.js'
import { getPresetPlan, isPlanId, isPlanProvider, PLAN_IDS, PLAN_PROVIDERS, planDisplayName } from './plans.js'
import { createRequire } from 'node:module'
Expand Down Expand Up @@ -59,6 +59,30 @@ function parseInteger(value: string): number {
return parseInt(value, 10)
}

type PriceOverrideConfig = NonNullable<CodeburnConfig['priceOverrides']>[string]

type PriceOverrideOptions = {
input?: number
output?: number
cacheRead?: number
cacheCreation?: number
remove?: string
list?: boolean
}

function invalidUsdPerMillionRate(option: string, value: number | undefined): string | null {
if (value === undefined) return null
if (Number.isFinite(value) && value >= 0) return null
return `Invalid ${option}: expected a finite number >= 0 (USD per 1,000,000 tokens).`
}

function formatPriceOverrideParts(rates: PriceOverrideConfig): string {
const parts = [`input ${rates.input}`, `output ${rates.output}`]
if (typeof rates.cacheRead === 'number') parts.push(`cache read ${rates.cacheRead}`)
if (typeof rates.cacheCreation === 'number') parts.push(`cache creation ${rates.cacheCreation}`)
return parts.join(', ')
}

type JsonPlanSummary = {
id: PlanId
provider: PlanProvider
Expand Down Expand Up @@ -176,6 +200,7 @@ program.hook('preAction', async (thisCommand) => {
}
const config = await readConfig()
setModelAliases(config.modelAliases ?? {})
setPriceOverrides(config.priceOverrides ?? {})
setLocalModelSavings(config.localModelSavings ?? {})
setProxyPaths(config.proxyPaths ?? [])
if (thisCommand.opts<{ verbose?: boolean }>().verbose) {
Expand Down Expand Up @@ -906,6 +931,85 @@ program
console.log(` Config: ${getConfigFilePath()}\n`)
})

program
.command('price-override [model]')
.description('Override or add local model pricing. Rates are USD per 1,000,000 tokens (e.g. --input 0.27).')
.option('--input <usd-per-1M>', 'Input token price in USD per 1,000,000 tokens', parseNumber)
.option('--output <usd-per-1M>', 'Output token price in USD per 1,000,000 tokens', parseNumber)
.option('--cache-read <usd-per-1M>', 'Cache-read token price in USD per 1,000,000 tokens', parseNumber)
.option('--cache-creation <usd-per-1M>', 'Cache-creation token price in USD per 1,000,000 tokens', parseNumber)
.option('--remove <model>', 'Remove a price override')
.option('--list', 'List configured price overrides')
.action(async (model?: string, opts?: PriceOverrideOptions) => {
const config = await readConfig()
const overrides = new Map<string, PriceOverrideConfig>(Object.entries(config.priceOverrides ?? {}))

if (opts?.list || (!model && !opts?.remove)) {
const entries = [...overrides.entries()]
if (entries.length === 0) {
console.log('\n No price overrides configured.')
console.log(' Rates use USD per 1,000,000 tokens.')
console.log(` Config: ${getConfigFilePath()}`)
console.log(' Add one with: codeburn price-override <model> --input <usd-per-1M> --output <usd-per-1M>\n')
} else {
console.log('\n Price overrides (USD per 1,000,000 tokens):')
for (const [name, rates] of entries) {
console.log(` ${name}: ${formatPriceOverrideParts(rates)}`)
}
console.log(` Config: ${getConfigFilePath()}\n`)
}
return
}

if (opts?.remove) {
if (!overrides.has(opts.remove)) {
console.error(`\n Price override not found: ${opts.remove}\n`)
process.exitCode = 1
return
}
overrides.delete(opts.remove)
config.priceOverrides = overrides.size > 0 ? Object.fromEntries(overrides) : undefined
await saveConfig(config)
console.log(`\n Removed price override: ${opts.remove}\n`)
return
}

const input = opts?.input
const output = opts?.output
const cacheRead = opts?.cacheRead
const cacheCreation = opts?.cacheCreation
if (!model || input === undefined || output === undefined) {
console.error('\n Usage: codeburn price-override <model> --input <usd-per-1M> --output <usd-per-1M> [--cache-read <usd-per-1M>] [--cache-creation <usd-per-1M>]\n')
process.exitCode = 1
return
}

const invalidRate = [
invalidUsdPerMillionRate('--input', input),
invalidUsdPerMillionRate('--output', output),
invalidUsdPerMillionRate('--cache-read', cacheRead),
invalidUsdPerMillionRate('--cache-creation', cacheCreation),
].find((message): message is string => message !== null)
if (invalidRate) {
console.error(`\n ${invalidRate}\n`)
process.exitCode = 1
return
}

const override: PriceOverrideConfig = {
input,
output,
...(cacheRead !== undefined ? { cacheRead } : {}),
...(cacheCreation !== undefined ? { cacheCreation } : {}),
}
overrides.set(model, override)
config.priceOverrides = Object.fromEntries(overrides)
await saveConfig(config)
console.log(`\n Price override saved: ${model}: ${formatPriceOverrideParts(override)}`)
console.log(' Unit: USD per 1,000,000 tokens')
console.log(` Config: ${getConfigFilePath()}\n`)
})

program
.command('model-savings [local] [baseline]')
.description('Track a local model as "savings" rather than cost. Maps a local-model name to a paid baseline so the dashboard can show what the same tokens would have cost on the baseline (e.g. codeburn model-savings "llama3.1:8b" gpt-4o). The local call itself still costs $0 — actual cost is left untouched.')
Expand Down
106 changes: 106 additions & 0 deletions src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ export type ModelCosts = {
fastMultiplier: number
}

type PriceOverrideRates = {
input: number
output: number
cacheRead?: number
cacheCreation?: number
}

type LiteLLMEntry = {
input_cost_per_token?: number
output_cost_per_token?: number
Expand Down Expand Up @@ -332,13 +339,87 @@ const BUILTIN_ALIASES: Record<string, string> = {
}

let userAliases: Record<string, string> = {}
let userPriceOverrides: Map<string, ModelCosts> = new Map()
let userPriceOverridesConfig: Record<string, PriceOverrideRates> = {}
let sortedPriceOverrideKeys: string[] | null = null
let lowercasePriceOverrideIndex: Map<string, ModelCosts> | null = null

// Called once during CLI startup after config is loaded.
// User aliases take precedence over built-ins.
export function setModelAliases(aliases: Record<string, string>): void {
userAliases = aliases
}

function priceOverrideRatePerToken(usdPerMillion: number | undefined): number | null {
if (typeof usdPerMillion !== 'number') return null
return safePerTokenRate(usdPerMillion / 1_000_000)
}

// Called once during CLI startup after config is loaded.
// Config/CLI rates are USD per 1,000,000 tokens; ModelCosts stores USD/token.
export function setPriceOverrides(overrides: Record<string, PriceOverrideRates>): void {
const next = new Map<string, ModelCosts>()
const nextConfig: Record<string, PriceOverrideRates> = {}
for (const [model, rates] of Object.entries(overrides)) {
if (!model || !rates || typeof rates !== 'object') continue
nextConfig[model] = { ...rates }
const input = priceOverrideRatePerToken(rates.input)
const output = priceOverrideRatePerToken(rates.output)
if (input === null || output === null) continue
next.set(model, buildCosts(
input,
output,
priceOverrideRatePerToken(rates.cacheCreation),
priceOverrideRatePerToken(rates.cacheRead),
undefined,
))
}
userPriceOverrides = next
userPriceOverridesConfig = nextConfig
sortedPriceOverrideKeys = null
lowercasePriceOverrideIndex = null
}

function getSortedPriceOverrideKeys(): string[] {
if (sortedPriceOverrideKeys === null) {
sortedPriceOverrideKeys = Array.from(userPriceOverrides.keys()).sort((a, b) => b.length - a.length)
}
return sortedPriceOverrideKeys
}

function getLowercasePriceOverrideIndex(): Map<string, ModelCosts> {
if (lowercasePriceOverrideIndex === null) {
lowercasePriceOverrideIndex = new Map()
for (const [key, costs] of userPriceOverrides) {
const lk = key.toLowerCase()
if (!lowercasePriceOverrideIndex.has(lk)) lowercasePriceOverrideIndex.set(lk, costs)
}
}
return lowercasePriceOverrideIndex
}

function getPriceOverrideExact(...keys: string[]): ModelCosts | null {
for (const key of keys) {
const costs = userPriceOverrides.get(key)
if (costs) return costs
}
return null
}

function getPriceOverridePrefix(canonical: string): ModelCosts | null {
for (const key of getSortedPriceOverrideKeys()) {
if (canonical.startsWith(key + '-') || canonical === key) {
return userPriceOverrides.get(key)!
}
}
return null
}

function getPriceOverrideCaseInsensitive(canonical: string, withPrefix: string): ModelCosts | null {
const lowerIndex = getLowercasePriceOverrideIndex()
return lowerIndex.get(canonical.toLowerCase()) ?? lowerIndex.get(withPrefix.toLowerCase()) ?? null
}

// Local-model savings config. Kept separate from userAliases: a `modelAliases`
// entry rewrites a model's identity for actual cost; a `localModelSavings`
// entry keeps the model cost at $0 and reports the *avoided* spend against a
Expand Down Expand Up @@ -402,6 +483,22 @@ export function getLocalModelSavingsConfigHash(): string {
return parts.join('\u0002')
}

export function getPriceOverridesConfigHash(): string {
const keys = Object.keys(userPriceOverridesConfig).sort()
if (keys.length === 0) return ''
const parts = keys.map(k => {
const rates = userPriceOverridesConfig[k]
return [
k,
rates.input,
rates.output,
rates.cacheRead ?? '',
rates.cacheCreation ?? '',
].join('\u0001')
})
return parts.join('\u0002')
}

// Absolute directory prefixes whose sessions are routed through a
// subscription-backed proxy (config `proxyPaths`). Stored already-normalized so
// the per-project match is a cheap compare. Set during preAction. See
Expand Down Expand Up @@ -472,6 +569,9 @@ export function getModelCosts(model: string): ModelCosts | null {
const canonicalName = getCanonicalName(model)
const canonical = resolveAlias(canonicalName)

const override = getPriceOverrideExact(model, withPrefix, canonicalName, canonical)
if (override) return override

// An explicit alias for a bare (un-prefixed) model name is authoritative: it
// must win over a coincidental stripped reseller key of the same name. LiteLLM
// ships `snowflake/claude-4-opus` ($5), which the bundler strips to a bare
Expand All @@ -485,6 +585,9 @@ export function getModelCosts(model: string): ModelCosts | null {

if (pricingCache.has(canonical)) return pricingCache.get(canonical)!

const prefixOverride = getPriceOverridePrefix(canonical)
if (prefixOverride) return prefixOverride

// Iterate keys longest-first so a model id like `gpt-5-mini` matches the
// `gpt-5-mini` entry rather than collapsing to the shorter `gpt-5` entry
// due to dictionary insertion order.
Expand All @@ -494,6 +597,9 @@ export function getModelCosts(model: string): ModelCosts | null {
}
}

const caseInsensitiveOverride = getPriceOverrideCaseInsensitive(canonical, withPrefix)
if (caseInsensitiveOverride) return caseInsensitiveOverride

// Case-insensitive fallback: gap-filled keys from OpenRouter are lowercase
// slugs (e.g. `minimax-m3`), but sessions report `MiniMax-M3`. Only consulted
// after the exact/canonical/prefix attempts, so it never changes a match that
Expand Down
11 changes: 9 additions & 2 deletions src/usage-aggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, getShortModelName } from './models.js'
import { 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'
Expand Down Expand Up @@ -53,12 +53,19 @@ 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}`
}

async function hydrateCache(): Promise<DailyCache> {
try {
return await ensureCacheHydrated(
(range) => parseAllSessions(range, 'all'),
aggregateProjectsIntoDays,
getLocalModelSavingsConfigHash(),
getDailyCacheConfigHash(),
)
} catch (err) {
// Previously swallowed silently, which turned any backfill failure into an
Expand Down
Loading
Loading