From e83160f4153af5cbf744811e6f849934adeeff4b Mon Sep 17 00:00:00 2001 From: Resham Joshi <65915470+iamtoruk@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:30:57 +0200 Subject: [PATCH] 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. --- src/config.ts | 11 ++ src/main.ts | 81 +++++++- src/models.ts | 52 +++++ src/parser.ts | 73 +++---- src/types.ts | 6 + tests/cli-proxy-path.test.ts | 148 ++++++++++++++ tests/parser-proxy-codex-only.test.ts | 77 ++++++++ tests/parser-proxy-merge.test.ts | 106 ++++++++++ tests/parser-proxy-pricing.test.ts | 268 ++++++++++++++++++++++++++ 9 files changed, 787 insertions(+), 35 deletions(-) create mode 100644 tests/cli-proxy-path.test.ts create mode 100644 tests/parser-proxy-codex-only.test.ts create mode 100644 tests/parser-proxy-merge.test.ts create mode 100644 tests/parser-proxy-pricing.test.ts diff --git a/src/config.ts b/src/config.ts index 47f0cada..16c9e1dc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -39,6 +39,17 @@ export type CodeburnConfig = { // can show "saved $X by running locally". Distinct from modelAliases which // rewrites actual spend. localModelSavings?: Record + // 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 { diff --git a/src/main.ts b/src/main.ts index 384723ab..883ab504 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 ', '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 \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 \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') diff --git a/src/models.ts b/src/models.ts index 01372428..ffd207cc 100644 --- a/src/models.ts +++ b/src/models.ts @@ -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]! diff --git a/src/parser.ts b/src/parser.ts index bfd180a0..3c6bf320 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -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 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 diff --git a/src/types.ts b/src/types.ts index 68638718..8a6b37c9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 = { diff --git a/tests/cli-proxy-path.test.ts b/tests/cli-proxy-path.test.ts new file mode 100644 index 00000000..eb1fc1f9 --- /dev/null +++ b/tests/cli-proxy-path.test.ts @@ -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 { + 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> { + return JSON.parse(await readFile(configPath(home), 'utf-8')) +} +async function writeConfig(home: string, obj: unknown): Promise { + 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 { + 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) + }) +}) diff --git a/tests/parser-proxy-codex-only.test.ts b/tests/parser-proxy-codex-only.test.ts new file mode 100644 index 00000000..9c59949a --- /dev/null +++ b/tests/parser-proxy-codex-only.test.ts @@ -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() +}) diff --git a/tests/parser-proxy-merge.test.ts b/tests/parser-proxy-merge.test.ts new file mode 100644 index 00000000..19dd3c85 --- /dev/null +++ b/tests/parser-proxy-merge.test.ts @@ -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 { + 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 { + 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() + }) +}) diff --git a/tests/parser-proxy-pricing.test.ts b/tests/parser-proxy-pricing.test.ts new file mode 100644 index 00000000..10cf768e --- /dev/null +++ b/tests/parser-proxy-pricing.test.ts @@ -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 { + 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) + }) +})