mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 09:39:24 +00:00
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.
148 lines
5.8 KiB
TypeScript
148 lines
5.8 KiB
TypeScript
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)
|
|
})
|
|
})
|