codeburn/tests/parser-proxy-merge.test.ts
Resham Joshi e83160f415
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.
2026-06-09 21:30:57 +02:00

106 lines
4.4 KiB
TypeScript

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()
})
})