feat(pricing): proxy-aware cost attribution for subscription-backed Claude (#417) (#459)

Claude Code routed through a GitHub-Copilot-backed proxy (ANTHROPIC_BASE_URL
-> claude-code-over-github-copilot / claudegate) costs ~$0 marginal but was
priced at full Anthropic API rates, producing a misleading cost figure. The
JSONL records only the model name and no endpoint, so proxying cannot be
auto-detected; the user declares it.

Add a `proxyPaths` config (managed via a new `codeburn proxy-path` subcommand)
listing project directories served through a subscription proxy. Sessions whose
canonical path is under one keep their full API-rate totalCostUSD (the billable
would-be figure is never destroyed) and additionally report totalProxiedCostUSD,
so the JSON report overview exposes cost / proxiedCost / netCost
(netCost = cost - proxiedCost). With no proxyPaths configured the new fields are
0 and every existing consumer is unchanged.

Design: "full cost, flagged" was chosen over zeroing cost so a misconfigured or
since-changed path can never silently erase real Anthropic spend. Attribution is
project-level (one canonical path per project), computed in a single helper
(summarizeProject) that all ProjectSummary builders route through, including the
cross-provider merge in parseAllSessions (re-derived from the merged total so a
repo used with both Claude and Codex stays correct). The in-memory session-cache
key folds in a proxy-config hash so toggling proxyPaths in a long-lived process
cannot serve stale attribution. Path matching is segment-boundary anchored
(/foo does not match /foobar), trailing-slash and backslash tolerant, leading-
slash agnostic (so a non-Claude provider's slash-stripped path matches the same
way Claude's absolute path does), and case-folded only on case-insensitive
filesystems (macOS/Windows, not Linux). The proxy-path CLI sanitizes a
hand-edited config.json (non-array / non-string entries) rather than crashing.

Tested: isProxiedPath matching matrix (boundary, case, Windows, empty, root,
multi-path, leading-slash form); config-hash distinctness/order-independence;
end-to-end attribution through parseAllSessions incl. the critical negative
cases (real spend must not be zeroed); cross-provider Claude+Codex merge;
Codex-only project under a proxy path; date-range-filtered attribution;
cache-staleness after a config change; and the proxy-path CLI add/list/remove,
malformed-config robustness, and the report --format json overview.

Scope note: proxiedCost/netCost currently surface in `report --format json`
only; wiring them into the TUI dashboard and menubar payload is a follow-up.
This commit is contained in:
Resham Joshi 2026-06-09 21:30:57 +02:00 committed by GitHub
parent f5d1a8513d
commit e83160f415
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 787 additions and 35 deletions

View file

@ -39,6 +39,17 @@ export type CodeburnConfig = {
// can show "saved $X by running locally". Distinct from modelAliases which
// rewrites actual spend.
localModelSavings?: Record<string, string>
// Absolute directory prefixes whose Claude Code sessions are routed through a
// subscription-backed LLM proxy (e.g. GitHub Copilot via ANTHROPIC_BASE_URL;
// tools like claude-code-over-github-copilot / claudegate). The JSONL records
// the underlying model name and no endpoint, so codeburn cannot auto-detect
// proxying — the user declares it here, scoped by the project's canonical cwd.
// Matching projects keep their full API-rate `totalCostUSD` (the billable /
// would-be figure is never destroyed) but expose `totalProxiedCostUSD` so the
// report can show what was subscription-covered and the net out-of-pocket.
// Matched against the canonical project path: prefix on a path-segment
// boundary, case-insensitive, trailing-slash and backslash tolerant.
proxyPaths?: string[]
}
function getConfigDir(): string {

View file

@ -1,7 +1,8 @@
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 } from './models.js'
import { loadPricing, setModelAliases, setLocalModelSavings, setProxyPaths, normalizeProxyPath } from './models.js'
import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache } from './parser.js'
import { convertCost } from './currency.js'
import { renderStatusBar } from './format.js'
@ -150,6 +151,7 @@ program.hook('preAction', async (thisCommand) => {
const config = await readConfig()
setModelAliases(config.modelAliases ?? {})
setLocalModelSavings(config.localModelSavings ?? {})
setProxyPaths(config.proxyPaths ?? [])
if (thisCommand.opts<{ verbose?: boolean }>().verbose) {
process.env['CODEBURN_VERBOSE'] = '1'
}
@ -162,6 +164,10 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
const totalCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0)
const totalSavingsUSD = projects.reduce((s, p) => s + p.totalSavingsUSD, 0)
// Subscription-covered (proxied) portion of totalCostUSD, and the resulting
// out-of-pocket figure. `cost` stays the full billable/would-be amount.
const totalProxiedUSD = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
const netCostUSD = totalCostUSD - totalProxiedUSD
const totalCalls = projects.reduce((s, p) => s + p.totalApiCalls, 0)
const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0)
const totalInput = sessions.reduce((s, sess) => s + sess.totalInputTokens, 0)
@ -360,6 +366,12 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
periodKey,
overview: {
cost: convertCost(totalCostUSD),
// Subscription-covered spend (config `proxyPaths`) and net out-of-pocket.
// `cost` is the full API-rate figure; `proxiedCost` is the part billed to
// a subscription; `netCost` = cost - proxiedCost. Both 0 with no proxy
// paths configured, so existing consumers are unaffected.
proxiedCost: convertCost(totalProxiedUSD),
netCost: convertCost(netCostUSD),
savings: convertCost(totalSavingsUSD),
calls: totalCalls,
sessions: totalSessions,
@ -795,6 +807,73 @@ program
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.')
.option('--remove <path>', 'Remove a configured proxy path')
.option('--list', 'List configured proxy paths')
.action(async (path?: string, opts?: { remove?: string; list?: boolean }) => {
const config = await readConfig()
// Sanitize the on-disk shape the same way setProxyPaths does: a hand-edited
// config.json could have proxyPaths as a non-array or hold non-string
// entries, which would otherwise throw when spread or normalized below.
const paths = (Array.isArray(config.proxyPaths) ? config.proxyPaths : [])
.filter((p): p is string => typeof p === 'string')
const samePath = (a: string, b: string) => normalizeProxyPath(a) === normalizeProxyPath(b)
if (opts?.list || (!path && !opts?.remove)) {
if (paths.length === 0) {
console.log('\n No proxy paths configured.')
console.log(` Config: ${getConfigFilePath()}`)
console.log(' Add one with: codeburn proxy-path <project-dir>\n')
} else {
console.log('\n Proxy paths (sessions under these are subscription-covered):')
for (const p of paths) console.log(` ${p}`)
console.log(` Config: ${getConfigFilePath()}\n`)
}
return
}
if (opts?.remove) {
const idx = paths.findIndex(p => samePath(p, opts.remove!))
if (idx === -1) {
console.error(`\n No proxy path found matching: ${opts.remove}\n`)
process.exitCode = 1
return
}
paths.splice(idx, 1)
config.proxyPaths = paths.length > 0 ? paths : undefined
await saveConfig(config)
console.log(`\n Removed proxy path: ${opts.remove}\n`)
return
}
if (!path) {
console.error('\n Usage: codeburn proxy-path <project-dir>\n')
process.exitCode = 1
return
}
const trimmed = path.trim()
if (!isAbsolute(trimmed) || normalizeProxyPath(trimmed) === '') {
console.error(`\n Proxy path must be an absolute project directory (got: ${path}).`)
console.error(' codeburn matches sessions by their recorded absolute cwd; the')
console.error(' filesystem root is too broad and is not accepted.\n')
process.exitCode = 1
return
}
if (paths.some(p => samePath(p, trimmed))) {
console.log(`\n Proxy path already configured: ${trimmed}\n`)
return
}
paths.push(trimmed)
config.proxyPaths = paths
await saveConfig(config)
console.log(`\n Proxy path saved: ${trimmed}`)
console.log(' Sessions under it keep their full API-rate cost as the would-be figure; that amount is reported as subscription-covered (net out-of-pocket excludes it).')
console.log(` Config: ${getConfigFilePath()}\n`)
})
program
.command('plan [action] [id]')
.description('Show or configure a subscription plan for overage tracking')

View file

@ -394,6 +394,58 @@ export function getLocalModelSavingsConfigHash(): string {
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
// CodeburnConfig.proxyPaths for the product rationale.
let userProxyPaths: string[] = []
/// Normalize a path for prefix comparison: backslashes -> forward slashes
/// (Windows configs / cwds), strip leading AND trailing slashes, fold case on
/// case-insensitive filesystems. Leading slashes are stripped because provider
/// project paths arrive in two forms — Claude keeps the absolute "/Users/x"
/// while Codex (sanitizeProject) and the unsanitizePath fallback drop the
/// leading slash to "Users/x". Folding both to a slashless form (mirroring
/// crossProviderKey) makes matching agnostic to which provider produced the
/// path, so the same directory is flagged whether or not a Claude session
/// happens to co-exist there. Case is folded only on macOS/Windows; on Linux
/// "/home/Me" and "/home/me" are different dirs, so folding would risk
/// crediting unrelated spend. A path that normalizes to empty (e.g. "/" or "")
/// is dropped by callers so it can never match everything. Exported so the CLI
/// dedupes with the same rule.
export function normalizeProxyPath(p: string): string {
const s = p.trim().replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '')
return (process.platform === 'darwin' || process.platform === 'win32') ? s.toLowerCase() : s
}
export function setProxyPaths(paths: string[]): void {
userProxyPaths = (Array.isArray(paths) ? paths : [])
.filter((p): p is string => typeof p === 'string')
.map(normalizeProxyPath)
.filter(p => p !== '')
}
/// True when `cwd` is at or under a configured proxy path. Prefix match is
/// anchored to a path-segment boundary so "/a/proj" matches "/a/proj" and
/// "/a/proj/sub" but NOT "/a/project-x". Empty/undefined cwd or empty config
/// never matches (so a misconfig can't silently zero unrelated spend).
export function isProxiedPath(cwd: string | undefined | null): boolean {
if (!cwd || typeof cwd !== 'string') return false
if (userProxyPaths.length === 0) return false
const c = normalizeProxyPath(cwd)
if (c === '') return false
return userProxyPaths.some(p => c === p || c.startsWith(p + '/'))
}
/// Stable hash of the active proxy-path config. Project-level proxy attribution
/// is computed live from this set and then cached in the in-memory session
/// cache, so the cache key must vary with it — otherwise a long-lived process
/// (menubar) that re-reads config could serve attribution from a stale set.
export function getProxyPathsConfigHash(): string {
if (userProxyPaths.length === 0) return ''
return [...userProxyPaths].sort().join('')
}
function resolveAlias(model: string): string {
if (Object.hasOwn(userAliases, model)) return userAliases[model]!
if (Object.hasOwn(BUILTIN_ALIASES, model)) return BUILTIN_ALIASES[model]!

View file

@ -1,7 +1,7 @@
import { lstat, readFile, readdir, stat } from 'fs/promises'
import { basename, dirname, join, resolve, sep } from 'path'
import { readSessionLines } from './fs-utils.js'
import { calculateCost, calculateLocalModelSavings, getShortModelName } from './models.js'
import { calculateCost, calculateLocalModelSavings, getShortModelName, isProxiedPath, getProxyPathsConfigHash } from './models.js'
import { normalizeContentBlocks } from './content-utils.js'
import { discoverAllSessions, getProvider } from './providers/index.js'
import { flushCodexCache } from './codex-cache.js'
@ -1613,19 +1613,32 @@ async function scanProjectDirs(
const projects: ProjectSummary[] = []
for (const { project, projectPath, sessions } of projectMap.values()) {
projects.push({
project,
projectPath,
sessions,
totalCostUSD: sessions.reduce((s, sess) => s + sess.totalCostUSD, 0),
totalSavingsUSD: sessions.reduce((s, sess) => s + sess.totalSavingsUSD, 0),
totalApiCalls: sessions.reduce((s, sess) => s + sess.apiCalls, 0),
})
projects.push(summarizeProject(project, projectPath, sessions))
}
return projects
}
/// Build a ProjectSummary from its sessions, rolling up cost/savings/calls and
/// deriving the proxy attribution. This is the single place proxy matching
/// happens: a project whose canonical path is under a configured `proxyPaths`
/// prefix keeps its full API-rate `totalCostUSD` but records that amount as
/// `totalProxiedCostUSD` (subscription-covered). All ProjectSummary callers go
/// through here so the rule stays consistent across the fresh, cached, and
/// date/day-filtered paths.
function summarizeProject(project: string, projectPath: string, sessions: SessionSummary[]): ProjectSummary {
const totalCostUSD = sessions.reduce((s, sess) => s + sess.totalCostUSD, 0)
return {
project,
projectPath,
sessions,
totalCostUSD,
totalSavingsUSD: sessions.reduce((s, sess) => s + sess.totalSavingsUSD, 0),
totalApiCalls: sessions.reduce((s, sess) => s + sess.apiCalls, 0),
totalProxiedCostUSD: isProxiedPath(projectPath) ? totalCostUSD : 0,
}
}
function providerCallToTurn(call: ParsedProviderCall): ParsedTurn {
const tools = call.tools
const usage: TokenUsage = {
@ -2049,14 +2062,7 @@ async function parseProviderSources(
const projects: ProjectSummary[] = []
for (const [dirName, { projectPath, sessions }] of projectMap) {
projects.push({
project: dirName,
projectPath: projectPath ?? unsanitizePath(dirName),
sessions,
totalCostUSD: sessions.reduce((s, sess) => s + sess.totalCostUSD, 0),
totalSavingsUSD: sessions.reduce((s, sess) => s + sess.totalSavingsUSD, 0),
totalApiCalls: sessions.reduce((s, sess) => s + sess.apiCalls, 0),
})
projects.push(summarizeProject(dirName, projectPath ?? unsanitizePath(dirName), sessions))
}
return projects
@ -2072,7 +2078,9 @@ function cacheKey(dateRange?: DateRange, providerFilter?: string): string {
// process (menubar / GNOME extension / test workers) does not return
// stale data keyed under a previous configuration.
const claudeEnv = (process.env['CLAUDE_CONFIG_DIRS'] ?? '') + '|' + (process.env['CLAUDE_CONFIG_DIR'] ?? '')
return `${s}:${providerFilter ?? 'all'}:${claudeEnv}`
// Proxy attribution (totalProxiedCostUSD) is computed live from proxyPaths and
// then cached, so the key must change when that config changes.
return `${s}:${providerFilter ?? 'all'}:${claudeEnv}:${getProxyPathsConfigHash()}`
}
export function clearSessionCache(): void {
@ -2148,14 +2156,7 @@ export function filterProjectsByDays(projects: ProjectSummary[], days: Set<strin
sessions.push(buildSessionSummary(session.sessionId, session.project, turns, session.mcpInventory))
}
if (sessions.length === 0) continue
filtered.push({
project: project.project,
projectPath: project.projectPath,
sessions,
totalCostUSD: sessions.reduce((s, sess) => s + sess.totalCostUSD, 0),
totalSavingsUSD: sessions.reduce((s, sess) => s + sess.totalSavingsUSD, 0),
totalApiCalls: sessions.reduce((s, sess) => s + sess.apiCalls, 0),
})
filtered.push(summarizeProject(project.project, project.projectPath, sessions))
}
return filtered.sort((a, b) => b.totalCostUSD - a.totalCostUSD)
}
@ -2170,14 +2171,7 @@ export function filterProjectsByDateRange(projects: ProjectSummary[], dateRange:
sessions.push(buildSessionSummary(session.sessionId, session.project, turns, session.mcpInventory))
}
if (sessions.length === 0) continue
filtered.push({
project: project.project,
projectPath: project.projectPath,
sessions,
totalCostUSD: sessions.reduce((s, sess) => s + sess.totalCostUSD, 0),
totalSavingsUSD: sessions.reduce((s, sess) => s + sess.totalSavingsUSD, 0),
totalApiCalls: sessions.reduce((s, sess) => s + sess.apiCalls, 0),
})
filtered.push(summarizeProject(project.project, project.projectPath, sessions))
}
return filtered.sort((a, b) => b.totalCostUSD - a.totalCostUSD)
}
@ -2257,6 +2251,17 @@ export async function parseAllSessions(dateRange?: DateRange, providerFilter?: s
}
}
// Re-derive proxy attribution on the merged total: the merge above sums
// totalCostUSD across providers that share a canonical path but never
// recomputed totalProxiedCostUSD, so a merged project (e.g. the same repo
// used with Claude Code + Codex) would otherwise carry the proxied amount of
// only the first-seen provider. The merge key is the canonical path, so both
// sides share the same proxied status — keying off the surviving projectPath
// and the final cost keeps the project-level all-or-nothing rule intact.
for (const p of mergedMap.values()) {
p.totalProxiedCostUSD = isProxiedPath(p.projectPath) ? p.totalCostUSD : 0
}
const result = Array.from(mergedMap.values()).sort((a, b) => b.totalCostUSD - a.totalCostUSD)
cachePut(key, result)
return result

View file

@ -158,6 +158,12 @@ export type ProjectSummary = {
totalCostUSD: number
totalSavingsUSD: number
totalApiCalls: number
// Portion of `totalCostUSD` served through a subscription-backed proxy
// (config `proxyPaths`). `totalCostUSD` is left at the full API rate (the
// billable / would-be figure); this is the subscription-covered amount, so
// net out-of-pocket for the project is `totalCostUSD - totalProxiedCostUSD`.
// 0 when the project is not under a configured proxy path.
totalProxiedCostUSD: number
}
export type DateRange = {

View file

@ -0,0 +1,148 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { afterEach, describe, expect, it } from 'vitest'
let homes: string[] = []
afterEach(async () => {
while (homes.length > 0) {
const h = homes.pop()
if (h) await rm(h, { recursive: true, force: true })
}
})
async function makeHome(): Promise<string> {
const home = await mkdtemp(join(tmpdir(), 'codeburn-proxy-cli-'))
homes.push(home)
return home
}
function runCli(args: string[], home: string) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), TZ: 'UTC' },
encoding: 'utf-8',
timeout: 30_000,
})
}
const configPath = (home: string) => join(home, '.config', 'codeburn', 'config.json')
async function readConfig(home: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(configPath(home), 'utf-8'))
}
async function writeConfig(home: string, obj: unknown): Promise<void> {
await mkdir(join(home, '.config', 'codeburn'), { recursive: true })
await writeFile(configPath(home), JSON.stringify(obj), 'utf-8')
}
describe('codeburn proxy-path CLI', () => {
it('adds, lists, dedupes, and removes a proxy path', async () => {
const home = await makeHome()
expect(runCli(['proxy-path', '--list'], home).stdout).toContain('No proxy paths configured')
const add = runCli(['proxy-path', '/work/copilot-repo'], home)
expect(add.status).toBe(0)
expect(add.stdout).toContain('Proxy path saved')
expect((await readConfig(home)).proxyPaths).toEqual(['/work/copilot-repo'])
// Trailing-slash variant is the same path -> deduped, not a second entry.
const dup = runCli(['proxy-path', '/work/copilot-repo/'], home)
expect(dup.stdout).toContain('already configured')
expect((await readConfig(home)).proxyPaths).toEqual(['/work/copilot-repo'])
expect(runCli(['proxy-path', '--list'], home).stdout).toContain('/work/copilot-repo')
const rm = runCli(['proxy-path', '--remove', '/work/copilot-repo'], home)
expect(rm.status).toBe(0)
expect((await readConfig(home)).proxyPaths).toBeUndefined()
})
it('rejects a relative path and the filesystem root', async () => {
const home = await makeHome()
const rel = runCli(['proxy-path', './rel'], home)
expect(rel.status).toBe(1)
expect(rel.stderr).toContain('absolute')
const root = runCli(['proxy-path', '/'], home)
expect(root.status).toBe(1)
})
it('errors (exit 1) when removing a path that is not configured', async () => {
const home = await makeHome()
const res = runCli(['proxy-path', '--remove', '/not/configured'], home)
expect(res.status).toBe(1)
expect(res.stderr).toContain('No proxy path found')
})
it('does not crash on a malformed config (proxyPaths as a non-array)', async () => {
const home = await makeHome()
await writeConfig(home, { proxyPaths: 42 })
const list = runCli(['proxy-path', '--list'], home)
expect(list.status).toBe(0)
expect(list.stderr).not.toMatch(/TypeError|is not iterable|is not a function/)
expect(list.stdout).toContain('No proxy paths configured')
const add = runCli(['proxy-path', '/work/repo'], home)
expect(add.status).toBe(0)
expect(add.stderr).not.toMatch(/TypeError/)
// The garbage was discarded; only the valid absolute path persists.
expect((await readConfig(home)).proxyPaths).toEqual(['/work/repo'])
})
it('does not crash on a malformed config (proxyPaths array with non-string entries)', async () => {
const home = await makeHome()
await writeConfig(home, { proxyPaths: [42, null, '/work/keep'] })
const add = runCli(['proxy-path', '/work/new'], home)
expect(add.status).toBe(0)
expect(add.stderr).not.toMatch(/TypeError|is not a function/)
expect((await readConfig(home)).proxyPaths).toEqual(['/work/keep', '/work/new'])
})
})
describe('codeburn report --format json: proxy overview', () => {
async function writeClaudeSession(home: string, cwd: string): Promise<void> {
const dir = join(home, '.claude', 'projects', 'proxied')
await mkdir(dir, { recursive: true })
const ts = new Date().toISOString()
const line = JSON.stringify({
type: 'assistant', sessionId: 's1', timestamp: ts, cwd,
message: {
id: 'm1', type: 'message', role: 'assistant', model: 'claude-sonnet-4-6',
content: [{ type: 'text', text: 'hi' }],
usage: { input_tokens: 10000, output_tokens: 2000, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
},
})
await writeFile(join(dir, 's1.jsonl'), line + '\n', 'utf-8')
}
function overview(home: string): { cost: number; proxiedCost: number; netCost: number } {
const res = runCli(['report', '--period', 'all', '--format', 'json'], home)
expect(res.status).toBe(0)
return JSON.parse(res.stdout).overview
}
it('reports proxiedCost == cost and netCost == 0 under a proxy path, and netCost == cost without one', async () => {
const home = await makeHome()
const cwd = '/proj/proxiedrepo'
await writeClaudeSession(home, cwd)
// Baseline: no proxy config -> nothing attributed, net == cost.
const base = overview(home)
expect(base.cost).toBeGreaterThan(0)
expect(base.proxiedCost).toBe(0)
expect(base.netCost).toBeCloseTo(base.cost, 10)
// With the proxy path -> full cost preserved, fully subscription-covered.
await writeConfig(home, { proxyPaths: [cwd] })
const proxied = overview(home)
expect(proxied.cost).toBeCloseTo(base.cost, 10) // cost is never modified
expect(proxied.proxiedCost).toBeCloseTo(proxied.cost, 10)
expect(proxied.netCost).toBeCloseTo(0, 10)
expect(proxied.netCost).toBeCloseTo(proxied.cost - proxied.proxiedCost, 10)
})
})

View file

@ -0,0 +1,77 @@
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, expect, it } from 'vitest'
// A non-Claude provider records its project path with the leading slash stripped
// ("Users/test/x"), while a configured proxy path keeps it ("/Users/test/x").
// Matching must be leading-slash agnostic, or a Codex-ONLY project under a proxy
// path is silently reported as out-of-pocket even though the SAME path is flagged
// when a Claude session happens to co-exist there (covered by the merge test) —
// i.e. attribution would depend on incidental provider co-location.
//
// This lives in its own file because the codex provider captures CODEX_HOME when
// its module is first evaluated; isolating it gives a fresh module graph that
// reads the env set below before the dynamic import.
const CWD = '/Users/test/codexonlyproxied'
let tmpDirs: string[] = []
afterEach(async () => {
delete process.env['CODEX_HOME']
delete process.env['CLAUDE_CONFIG_DIR']
while (tmpDirs.length > 0) {
const d = tmpDirs.pop()
if (d) await rm(d, { recursive: true, force: true })
}
})
it('flags a Codex-only project under a proxy path (leading-slash agnostic match)', async () => {
// Codex fixture (the only sessions present).
const home = await mkdtemp(join(tmpdir(), 'codeburn-codexonly-'))
tmpDirs.push(home)
const dir = join(home, 'sessions', '2026', '04', '16')
await mkdir(dir, { recursive: true })
const meta = JSON.stringify({
type: 'session_meta', timestamp: '2026-04-16T10:00:00Z',
payload: { cwd: CWD, originator: 'codex-cli', session_id: 'codex-1', model: 'gpt-5.3-codex' },
})
const tokens = JSON.stringify({
type: 'event_msg', timestamp: '2026-04-16T10:01:00Z',
payload: {
type: 'token_count',
info: {
model: 'gpt-5.3-codex',
last_token_usage: { input_tokens: 1000, cached_input_tokens: 0, output_tokens: 200, reasoning_output_tokens: 0, total_tokens: 1200 },
total_token_usage: { input_tokens: 1000, cached_input_tokens: 0, output_tokens: 200, reasoning_output_tokens: 0, total_tokens: 1200 },
},
},
})
await writeFile(join(dir, 'rollout-codex-1.jsonl'), meta + '\n' + tokens + '\n', 'utf-8')
process.env['CODEX_HOME'] = home
// Empty Claude dir so the only project is the Codex one.
const claudeEmpty = await mkdtemp(join(tmpdir(), 'codeburn-codexonly-claude-'))
tmpDirs.push(claudeEmpty)
await mkdir(join(claudeEmpty, 'projects'), { recursive: true })
process.env['CLAUDE_CONFIG_DIR'] = claudeEmpty
// Import AFTER env is set so the codex provider reads CODEX_HOME.
const { parseAllSessions, clearSessionCache } = await import('../src/parser.js')
const { setProxyPaths, loadPricing } = await import('../src/models.js')
await loadPricing()
setProxyPaths([CWD])
clearSessionCache()
const range = { start: new Date(Date.UTC(2026, 3, 15)), end: new Date(Date.UTC(2026, 3, 17)) }
const projects = await parseAllSessions(range, 'all')
const codex = projects.find(p => p.sessions.some(s => s.turns.some(t => t.assistantCalls.some(c => c.provider === 'codex'))))
expect(codex).toBeDefined()
expect(codex!.totalCostUSD).toBeGreaterThan(0)
// The fix: leading-slash agnostic matching flags the Codex-only project.
expect(codex!.totalProxiedCostUSD).toBeCloseTo(codex!.totalCostUSD, 10)
setProxyPaths([])
clearSessionCache()
})

View file

@ -0,0 +1,106 @@
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
// Regression guard for the cross-provider merge in parseAllSessions: when the
// same repo is used with Claude Code AND another tool (Codex), the two
// ProjectSummaries merge by canonical path and totalCostUSD is summed. The
// merge must RE-DERIVE totalProxiedCostUSD from the final path+cost, or only
// the first-seen provider's proxied amount survives and net out-of-pocket is
// overstated — silently reintroducing the exact bug issue #417 fixes.
//
// The codex provider captures its sessions dir (CODEX_HOME) when its module is
// first evaluated, so this file sets the env and creates fixtures BEFORE a
// dynamic import of the parser. A hyphen-free cwd is used so codex's
// sanitize/unsanitize path round-trips to the same merge key as Claude.
const MERGE_CWD = '/Users/test/proxiedmerge'
let tmpDirs: string[] = []
afterEach(async () => {
delete process.env['CLAUDE_CONFIG_DIR']
delete process.env['CODEX_HOME']
while (tmpDirs.length > 0) {
const d = tmpDirs.pop()
if (d) await rm(d, { recursive: true, force: true })
}
})
async function writeClaudeFixture(cwd: string): Promise<void> {
const base = await mkdtemp(join(tmpdir(), 'codeburn-merge-claude-'))
tmpDirs.push(base)
const dir = join(base, 'projects', 'p')
await mkdir(dir, { recursive: true })
const line = JSON.stringify({
type: 'assistant',
timestamp: '2026-04-16T10:00:00.000Z',
sessionId: 's1',
cwd,
message: {
type: 'message', role: 'assistant', model: 'claude-sonnet-4-6', id: 'm1', content: [],
usage: { input_tokens: 1000, output_tokens: 200, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
},
})
await writeFile(join(dir, 's1.jsonl'), line + '\n', 'utf-8')
process.env['CLAUDE_CONFIG_DIR'] = base
}
async function writeCodexFixture(cwd: string): Promise<void> {
const home = await mkdtemp(join(tmpdir(), 'codeburn-merge-codex-'))
tmpDirs.push(home)
const dir = join(home, 'sessions', '2026', '04', '16')
await mkdir(dir, { recursive: true })
const meta = JSON.stringify({
type: 'session_meta', timestamp: '2026-04-16T10:00:00Z',
payload: { cwd, originator: 'codex-cli', session_id: 'codex-1', model: 'gpt-5.3-codex' },
})
const tokens = JSON.stringify({
type: 'event_msg', timestamp: '2026-04-16T10:01:00Z',
payload: {
type: 'token_count',
info: {
model: 'gpt-5.3-codex',
last_token_usage: { input_tokens: 1000, cached_input_tokens: 0, output_tokens: 200, reasoning_output_tokens: 0, total_tokens: 1200 },
total_token_usage: { input_tokens: 1000, cached_input_tokens: 0, output_tokens: 200, reasoning_output_tokens: 0, total_tokens: 1200 },
},
},
})
await writeFile(join(dir, 'rollout-codex-1.jsonl'), meta + '\n' + tokens + '\n', 'utf-8')
process.env['CODEX_HOME'] = home
}
describe('proxy pricing: cross-provider merge', () => {
it('re-derives proxied == total when Claude and Codex sessions merge under a proxy path', async () => {
await writeClaudeFixture(MERGE_CWD)
await writeCodexFixture(MERGE_CWD)
// Import AFTER env is set so the codex provider reads CODEX_HOME.
const { parseAllSessions, clearSessionCache } = await import('../src/parser.js')
const { setProxyPaths, loadPricing } = await import('../src/models.js')
await loadPricing()
setProxyPaths([MERGE_CWD])
clearSessionCache()
const range = { start: new Date(Date.UTC(2026, 3, 15)), end: new Date(Date.UTC(2026, 3, 17)) }
const projects = await parseAllSessions(range, 'all')
// Sanity: both providers landed in a single merged project (proves the
// cross-provider merge path actually ran, not just a lone Claude project).
expect(projects).toHaveLength(1)
const merged = projects[0]!
const providers = new Set(
merged.sessions.flatMap(s => s.turns.flatMap(t => t.assistantCalls.map(c => c.provider))),
)
expect(providers.has('claude')).toBe(true)
expect(providers.has('codex')).toBe(true)
// The fix: the whole merged total is subscription-covered, not just the
// first provider's slice. Without the merge re-derivation this is < total.
expect(merged.totalProxiedCostUSD).toBeCloseTo(merged.totalCostUSD, 10)
expect(merged.totalCostUSD - merged.totalProxiedCostUSD).toBeCloseTo(0, 10)
setProxyPaths([])
clearSessionCache()
})
})

View file

@ -0,0 +1,268 @@
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { setProxyPaths, isProxiedPath, getProxyPathsConfigHash, setLocalModelSavings, setModelAliases, loadPricing } from '../src/models.js'
import { parseAllSessions, clearSessionCache, filterProjectsByDateRange } from '../src/parser.js'
import type { DateRange, ProjectSummary } from '../src/types.js'
// ── Part A: isProxiedPath matching rule (pure) ─────────────────────────────
describe('isProxiedPath: path matching rule', () => {
beforeEach(() => setProxyPaths([]))
afterEach(() => setProxyPaths([]))
it('never matches when no proxy paths are configured', () => {
expect(isProxiedPath('/Users/me/work/acme')).toBe(false)
})
it('matches an exact path', () => {
setProxyPaths(['/Users/me/work/acme'])
expect(isProxiedPath('/Users/me/work/acme')).toBe(true)
})
it('matches a child directory under the prefix', () => {
setProxyPaths(['/Users/me/work'])
expect(isProxiedPath('/Users/me/work/acme/sub')).toBe(true)
})
it('does NOT match across a partial path segment (boundary guard)', () => {
// The single most important negative: a string prefix that is not a
// directory-segment boundary must not silently zero unrelated spend.
setProxyPaths(['/Users/me/proj'])
expect(isProxiedPath('/Users/me/project-unrelated')).toBe(false)
})
it('is tolerant of trailing slashes on both config and cwd', () => {
setProxyPaths(['/Users/me/work/'])
expect(isProxiedPath('/Users/me/work')).toBe(true)
expect(isProxiedPath('/Users/me/work/')).toBe(true)
})
it('is case-insensitive (macOS/Windows default filesystems)', () => {
setProxyPaths(['/Users/Me/Work'])
expect(isProxiedPath('/users/me/work/acme')).toBe(true)
})
it('matches a Windows-style config against a forward-slash cwd', () => {
setProxyPaths(['C:\\Users\\me\\work'])
expect(isProxiedPath('C:/Users/me/work/acme')).toBe(true)
})
it('never matches an empty/undefined/null cwd', () => {
setProxyPaths(['/Users/me/work'])
expect(isProxiedPath('')).toBe(false)
expect(isProxiedPath(undefined)).toBe(false)
expect(isProxiedPath(null)).toBe(false)
})
it('drops a root "/" entry so it can never match everything', () => {
setProxyPaths(['/'])
expect(isProxiedPath('/Users/me/anything')).toBe(false)
})
it('drops blank / non-string entries', () => {
setProxyPaths(['', ' ', undefined as unknown as string, '/Users/me/work'])
expect(isProxiedPath('/Users/me/work/x')).toBe(true)
expect(isProxiedPath('/somewhere/else')).toBe(false)
})
it('matches when any one of several configured paths matches', () => {
setProxyPaths(['/Users/me/a', '/Users/me/b'])
expect(isProxiedPath('/Users/me/b/deep')).toBe(true)
expect(isProxiedPath('/Users/me/c')).toBe(false)
})
it('is reset by setProxyPaths([])', () => {
setProxyPaths(['/Users/me/work'])
expect(isProxiedPath('/Users/me/work')).toBe(true)
setProxyPaths([])
expect(isProxiedPath('/Users/me/work')).toBe(false)
})
it('matches a leading-slash-stripped cwd (non-Claude provider path form)', () => {
// Codex/unsanitizePath project paths drop the leading slash; the configured
// path keeps it. Matching must be agnostic to that difference.
setProxyPaths(['/Users/me/work'])
expect(isProxiedPath('Users/me/work/acme')).toBe(true)
expect(isProxiedPath('Users/me/work')).toBe(true)
})
})
describe('getProxyPathsConfigHash: cache-key stability', () => {
beforeEach(() => setProxyPaths([]))
afterEach(() => setProxyPaths([]))
it('is empty when unconfigured', () => {
expect(getProxyPathsConfigHash()).toBe('')
})
it('is order-independent', () => {
setProxyPaths(['/a', '/b'])
const h1 = getProxyPathsConfigHash()
setProxyPaths(['/b', '/a'])
expect(getProxyPathsConfigHash()).toBe(h1)
})
it('does NOT collide two materially different sets (delimited join)', () => {
// Regression guard: a separator-less join would make {'/a','/b'} and
// {'/a/b'} hash identically and let the session cache serve stale numbers.
setProxyPaths(['/a', '/b'])
const h1 = getProxyPathsConfigHash()
setProxyPaths(['/a/b'])
expect(getProxyPathsConfigHash()).not.toBe(h1)
})
})
// ── Part B: end-to-end attribution through parseAllSessions ────────────────
const FIXTURE_DAY = Date.UTC(2026, 3, 16)
const RANGE_START = new Date(FIXTURE_DAY - 24 * 60 * 60 * 1000)
const RANGE_END = new Date(FIXTURE_DAY + 24 * 60 * 60 * 1000)
const makeRange = (): DateRange => ({ start: RANGE_START, end: RANGE_END })
// A stable, non-existent absolute path: resolveCanonicalProjectPath finds no
// .git ancestor and returns it unchanged, so projectPath is predictable.
const FIXTURE_CWD = '/private/var/eywa-proxy-fixture/acme'
let tmpDirs: string[] = []
let originalConfigDir: string | undefined
beforeAll(async () => {
await loadPricing()
})
beforeEach(() => {
originalConfigDir = process.env['CLAUDE_CONFIG_DIR']
setProxyPaths([])
setLocalModelSavings({})
setModelAliases({})
clearSessionCache()
})
afterEach(async () => {
setProxyPaths([])
if (originalConfigDir === undefined) delete process.env['CLAUDE_CONFIG_DIR']
else process.env['CLAUDE_CONFIG_DIR'] = originalConfigDir
clearSessionCache()
while (tmpDirs.length > 0) {
const d = tmpDirs.pop()
if (d) await rm(d, { recursive: true, force: true })
}
})
async function setupProxiedSession(cwd: string = FIXTURE_CWD): Promise<void> {
const base = await mkdtemp(join(tmpdir(), 'codeburn-proxy-'))
tmpDirs.push(base)
const projectDir = join(base, 'projects', 'p')
await mkdir(projectDir, { recursive: true })
const line = JSON.stringify({
type: 'assistant',
timestamp: '2026-04-16T10:00:00.000Z',
sessionId: 's1',
cwd,
message: {
type: 'message',
role: 'assistant',
model: 'claude-sonnet-4-6',
id: 'msg-1',
content: [],
usage: { input_tokens: 1000, output_tokens: 200, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
},
})
await writeFile(join(projectDir, 's1.jsonl'), line + '\n', 'utf-8')
process.env['CLAUDE_CONFIG_DIR'] = base
}
const allCalls = (projects: ProjectSummary[]) =>
projects.flatMap(p => p.sessions.flatMap(s => s.turns.flatMap(t => t.assistantCalls)))
describe('proxy pricing: end-to-end through parseAllSessions', () => {
it('attributes nothing as proxied when no proxy paths are configured', async () => {
await setupProxiedSession()
const projects = await parseAllSessions(makeRange(), 'all')
const total = projects.reduce((s, p) => s + p.totalCostUSD, 0)
const proxied = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
expect(total).toBeGreaterThan(0)
expect(proxied).toBe(0)
})
it('flags the full cost as proxied when the project is under a proxy path, WITHOUT altering costUSD', async () => {
await setupProxiedSession()
setProxyPaths([FIXTURE_CWD])
clearSessionCache()
const projects = await parseAllSessions(makeRange(), 'all')
const total = projects.reduce((s, p) => s + p.totalCostUSD, 0)
const proxied = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
expect(total).toBeGreaterThan(0)
// "Full cost, flagged": the billable figure is preserved, the same amount
// is reported as subscription-covered, so net out-of-pocket is 0.
expect(proxied).toBeCloseTo(total, 10)
expect(total - proxied).toBeCloseTo(0, 10)
// The raw per-call cost is never destroyed — it stays at the full API rate.
const calls = allCalls(projects)
expect(calls.length).toBeGreaterThan(0)
for (const c of calls) expect(c.costUSD).toBeGreaterThan(0)
})
it('matches a parent prefix on a segment boundary', async () => {
await setupProxiedSession()
setProxyPaths(['/private/var/eywa-proxy-fixture'])
clearSessionCache()
const projects = await parseAllSessions(makeRange(), 'all')
const proxied = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
expect(proxied).toBeGreaterThan(0)
})
it('does NOT flag a sibling path that is only a string prefix (no spend silently zeroed)', async () => {
await setupProxiedSession()
// '/private/var/eywa-proxy-fixture/ac' is a string prefix of '.../acme'
// but not a directory-segment boundary — must not match.
setProxyPaths(['/private/var/eywa-proxy-fixture/ac'])
clearSessionCache()
const projects = await parseAllSessions(makeRange(), 'all')
const total = projects.reduce((s, p) => s + p.totalCostUSD, 0)
const proxied = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
expect(total).toBeGreaterThan(0)
expect(proxied).toBe(0)
})
it('attributes nothing when a different, unrelated path is configured', async () => {
await setupProxiedSession()
setProxyPaths(['/Users/someone/else'])
clearSessionCache()
const projects = await parseAllSessions(makeRange(), 'all')
const proxied = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
expect(proxied).toBe(0)
})
it('preserves proxy attribution after date-range filtering (filterProjectsByDateRange)', async () => {
await setupProxiedSession()
setProxyPaths([FIXTURE_CWD])
clearSessionCache()
const projects = await parseAllSessions(makeRange(), 'all')
const filtered = filterProjectsByDateRange(projects, makeRange())
expect(filtered.length).toBeGreaterThan(0)
const total = filtered.reduce((s, p) => s + p.totalCostUSD, 0)
const proxied = filtered.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
expect(total).toBeGreaterThan(0)
expect(proxied).toBeCloseTo(total, 10)
})
it('does not serve stale proxy attribution from the in-memory cache after proxyPaths changes', async () => {
// parseAllSessions caches ProjectSummary[] for 180s keyed partly on the
// proxy-config hash. Toggling proxyPaths must change the key so the second
// call recomputes rather than returning the pre-change (proxied=0) result.
await setupProxiedSession()
const before = await parseAllSessions(makeRange(), 'all')
expect(before.reduce((s, p) => s + p.totalProxiedCostUSD, 0)).toBe(0)
setProxyPaths([FIXTURE_CWD]) // deliberately NO clearSessionCache()
const after = await parseAllSessions(makeRange(), 'all')
const proxied = after.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
expect(proxied).toBeGreaterThan(0)
})
})