codeburn/tests/cli-route-surfaces.test.ts
Ulises 724b9f3621
feat(models): carry the billing route on the call and key model rows on it (#1448)
The same model billed through a different door is its own row on every surface.

The route rides on the call as a field: the model id names it when the door renames the model (Bedrock's <vendor>.<model>[-vN:M], with the cross-region profile kept as its own SKU), and the provider's own column names it when it does not (Hermes billing_provider). Only bedrock is registered; openrouter and bedrock-mantle wait for real sessions.

Every report keys a model row on one function, modelRowKey: short name plus the door's label. models-report folds on that row key rather than the route id, so two doors sharing one label can never print two rows a reader cannot tell apart. codeburn models --format json carries route per row.

Daily cache moves to v33: day.models is keyed by the row key so a column-sourced route survives into a finalized day, and hermes joins the pending-rederive table. Hermes sessions re-parse once.

Pricing never consults the route and runs on the raw id. granular-history still keys on the raw model id, so the desktop Trend timeline is the one surface outside this.

Verified on a real cache: 142 finalized days, $37,898.851498 and 272,985 calls unchanged.
2026-09-17 13:32:34 +02:00

128 lines
7 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, vi } from 'vitest'
import { DAILY_CACHE_VERSION } from '../src/daily-cache.js'
// Each test spawns `tsx src/cli.ts`, which re-transpiles the CLI per spawn.
vi.setConfig({ testTimeout: 60_000 })
// The #1448 review seeded three Claude sessions of one model — direct,
// single-region Bedrock, cross-region Bedrock — and found `codeburn models`
// printing two identical labels while the menubar payload blended them into
// one row: the surfaces keyed model rows differently. This runs the real
// binary over the same three sessions and asserts every surface agrees on the
// same three rows, at the prices main already showed for them (#1450).
const SESSIONS = [
{ id: 's-direct', model: 'claude-haiku-4-5-20251001', expectRow: 'Haiku 4.5' },
{ id: 's-bedrock', model: 'anthropic.claude-haiku-4-5-20251001-v1:0', expectRow: 'Haiku 4.5 (Bedrock)' },
{ id: 's-bedrock-us', model: 'us.anthropic.claude-haiku-4-5-20251001-v1:0', expectRow: 'Haiku 4.5 (Bedrock us)' },
] as const
let homes: string[] = []
afterEach(async () => {
while (homes.length > 0) {
const home = homes.pop()
if (home) await rm(home, { recursive: true, force: true })
}
})
async function seedHome(): Promise<string> {
const home = await mkdtemp(join(tmpdir(), 'codeburn-route-surfaces-'))
homes.push(home)
const dir = join(home, '.claude', 'projects', '-Users-gone-app')
await mkdir(dir, { recursive: true })
// Dated yesterday (UTC): the daily cache never persists today's entries
// (daily-cache.ts, "no today/future entries"), and the last assertion reads
// the finalized day back off disk.
const yesterdayNoon = new Date()
yesterdayNoon.setUTCDate(yesterdayNoon.getUTCDate() - 1)
yesterdayNoon.setUTCHours(12, 0, 0, 0)
for (const [index, session] of SESSIONS.entries()) {
const ts = new Date(yesterdayNoon.getTime() + index * 60_000).toISOString()
const user = JSON.stringify({ type: 'user', uuid: `${session.id}-u`, parentUuid: null, sessionId: session.id, timestamp: ts, cwd: '/Users/gone/app', message: { role: 'user', content: 'hello' } })
const assistant = JSON.stringify({
type: 'assistant', uuid: `${session.id}-a`, parentUuid: `${session.id}-u`, sessionId: session.id, timestamp: ts, cwd: '/Users/gone/app',
message: {
type: 'message', role: 'assistant', model: session.model, id: `${session.id}-m`, content: [{ type: 'text', text: 'hi' }],
usage: { input_tokens: 1_000_000, output_tokens: 100_000, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
},
})
await writeFile(join(dir, `${session.id}.jsonl`), `${user}\n${assistant}\n`, 'utf-8')
}
return home
}
function runCli(args: string[], home: string): string {
const result = spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
encoding: 'utf-8',
env: {
...process.env,
HOME: home, USERPROFILE: home,
CLAUDE_CONFIG_DIR: join(home, '.claude'),
CODEBURN_CACHE_DIR: join(home, 'cache'),
// Keep every other provider out of the run so the rows are exactly ours.
HERMES_HOME: join(home, 'no-hermes'), CODEX_HOME: join(home, 'no-codex'),
TZ: 'UTC',
},
})
expect(result.status, result.stderr).toBe(0)
return result.stdout
}
type Row = { name: string; cost: number; calls: number }
const round = (n: number) => Math.round(n * 100) / 100
describe('billing routes: the same three rows on every surface', () => {
it('models, the menubar payload (current + history) and the daily cache agree, at the prices main showed', async () => {
const home = await seedHome()
// codeburn models --format json: rows carry the route and their raw ids.
const models = JSON.parse(runCli(['models', '--provider', 'claude', '--period', 'week', '--format', 'json'], home)) as unknown
const modelRows = (Array.isArray(models) ? models : (models as { rows?: unknown[]; models?: unknown[] }).rows ?? (models as { models?: unknown[] }).models ?? []) as Array<{ modelDisplayName: string; costUSD: number; calls: number; rawModels: string[]; route: string | null }>
const modelsByName = new Map(modelRows.map(r => [r.modelDisplayName, r]))
expect([...modelsByName.keys()].sort()).toEqual(SESSIONS.map(s => s.expectRow).sort())
expect(modelsByName.get('Haiku 4.5')).toMatchObject({ route: null, rawModels: ['claude-haiku-4-5-20251001'] })
expect(modelsByName.get('Haiku 4.5 (Bedrock)')).toMatchObject({ route: 'bedrock', rawModels: ['anthropic.claude-haiku-4-5-20251001-v1:0'] })
expect(modelsByName.get('Haiku 4.5 (Bedrock us)')).toMatchObject({ route: 'bedrock', rawModels: ['us.anthropic.claude-haiku-4-5-20251001-v1:0'] })
// The cross-region profile is a dearer SKU and keeps its own price; the
// bare Bedrock id matches direct. Neither moved: routes are display-only.
const direct = modelsByName.get('Haiku 4.5')!.costUSD
const bedrock = modelsByName.get('Haiku 4.5 (Bedrock)')!.costUSD
const bedrockUs = modelsByName.get('Haiku 4.5 (Bedrock us)')!.costUSD
expect(direct).toBeGreaterThan(0)
expect(round(bedrock)).toBe(round(direct))
expect(bedrockUs).toBeGreaterThan(bedrock)
// status --format menubar-json: current.topModels and history.daily[].topModels
// key on the same function, so they carry the same three rows.
const payload = JSON.parse(runCli(['status', '--format', 'menubar-json', '--period', 'week'], home)) as {
current: { topModels: Row[] }
history: { daily: Array<{ topModels: Array<Row & { rawModels?: string[] }> }> }
}
const current = new Map(payload.current.topModels.map(m => [m.name, m]))
expect([...current.keys()].sort()).toEqual(SESSIONS.map(s => s.expectRow).sort())
for (const s of SESSIONS) {
expect(round(current.get(s.expectRow)!.cost), s.expectRow).toBe(round(modelsByName.get(s.expectRow)!.costUSD))
expect(current.get(s.expectRow)!.calls, s.expectRow).toBe(1)
}
const history = new Map(payload.history.daily.flatMap(d => d.topModels).map(m => [m.name, m]))
expect([...history.keys()].sort()).toEqual(SESSIONS.map(s => s.expectRow).sort())
// A row that folds exactly one raw id reports no rawModels list (#1241 contract).
for (const s of SESSIONS) expect(history.get(s.expectRow)!.rawModels, s.expectRow).toBeUndefined()
// The finalized day is keyed by the row key too (v33), so a route the
// provider recorded in its own column would survive into it.
const cache = JSON.parse(await readFile(join(home, 'cache', `daily-cache.v${DAILY_CACHE_VERSION}.json`), 'utf-8')) as { days: Array<{ models: Record<string, unknown> }> }
const dayKeys = new Set(cache.days.flatMap(d => Object.keys(d.models)))
for (const s of SESSIONS) expect(dayKeys.has(s.expectRow), s.expectRow).toBe(true)
expect(dayKeys.has('anthropic.claude-haiku-4-5-20251001-v1:0')).toBe(false)
})
})