Files
model-router/tests/pricing.test.ts
T
adminandClaude Opus 5.5 92bc4dc348 model-router v0.4.4
Claude Code plugin: routes subagents by how well-defined the brief is,
verifies dev work on a second model in the agent's own clone, and
estimates savings against Opus 5.5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MdqPWvuDpbpRvDEdURoAwo
2026-11-06 23:46:30 +00:00

180 lines
7.6 KiB
TypeScript

import type { On } from 'claude-code'
import { expect, mock, test, type Engine } from 'claude-code/testing'
import {
addOverhead,
addWriterRun,
actualTotal,
counterfactual,
emptyTask,
parsePrices,
priceRun,
saved,
savingsReport,
tokenizerFactor,
} from '../hooks/pricing'
const U = (i: number, o: number, w = 0, r = 0) => ({
input_tokens: i,
output_tokens: o,
cache_creation_input_tokens: w,
cache_read_input_tokens: r,
})
const M = 1_000_000
const round = (n: number) => Math.round(n * 1e6) / 1e6
test('prices: each part of the usage at its own rate', async () => {
// Sonnet 5.5: in 2, out 10, write 2.5, read 0.1 per MTok
expect(priceRun('claude-sonnet-5-5', U(M, M, M, M)).actual).toBe(2 + 10 + 2.5 + 0.1)
// the same tokens at Opus 5.5: 4 + 20 + 5 + 0.2
expect(round(priceRun('claude-sonnet-5-5', U(M, M, M, M)).opus)).toBe(29.2)
expect(priceRun('claude-opus-5-5', U(M, 0)).actual).toBe(4)
expect(priceRun('claude-haiku-5-5', U(M, 0)).actual).toBe(0.1)
expect(priceRun('claude-haiku-4-5', U(M, 0)).actual).toBe(1)
})
test('prices: the most specific model wins, unknown models are flagged', async () => {
expect(priceRun('claude-opus-5-5-20260601', U(M, 0)).label).toBe('Opus 5.5')
expect(priceRun('claude-opus-5', U(M, 0)).label).toBe('Opus 5')
expect(priceRun('claude-sonnet-5', U(M, 0)).label).toBe('Sonnet 5')
expect(priceRun('gpt-9', U(M, 0)).known).toBe(false)
})
test('older tokenizers are scaled before pricing as Opus 5.5', async () => {
expect(tokenizerFactor('claude-haiku-4-5')).toBe(1.3)
expect(tokenizerFactor('claude-sonnet-5-5')).toBe(1)
expect(tokenizerFactor('claude-haiku-5-5')).toBe(1)
expect(round(priceRun('claude-haiku-4-5', U(M, 0)).opus)).toBe(5.2)
})
test('a task below opus: every writer token at opus price is the comparison', async () => {
let t = emptyTask('clamp')
t = addWriterRun(t, 'claude-sonnet-5-5', U(M, 0))
t = addWriterRun(t, 'claude-sonnet-5-5', U(M, 0)) // resumed round
t = addOverhead(t, 'claude-opus-5-5', U(M / 2, 0)) // verifier
expect(t.writerActual).toBe(4)
expect(counterfactual(t)).toBe(8)
expect(actualTotal(t)).toBe(6)
expect(saved(t)).toBe(2)
})
test('a chain that ended on opus: the opus run is the comparison, failed rounds are loss', async () => {
let t = emptyTask('clamp')
t = addWriterRun(t, 'claude-sonnet-5-5', U(M, 0)) // failed twice
t = addWriterRun(t, 'claude-opus-5-5', U(M, 0)) // rewrite
expect(counterfactual(t)).toBe(4)
expect(actualTotal(t)).toBe(6)
expect(saved(t)).toBe(-2)
})
test('unpriced models are listed, never counted as free', async () => {
const t = addWriterRun(emptyTask('x'), 'mystery-1', U(M, M))
expect(t.unpriced).toEqual(['mystery-1'])
expect(t.writerActual).toBe(0)
expect(savingsReport({ T1: t }, 5)).toContain('[no price: mystery-1]')
})
test('price overrides come first; bad JSON falls back to list prices', async () => {
const table = parsePrices('[{"match":"sonnet-5-5","price":{"in":1,"out":1,"write":1,"read":1}}]')
expect(priceRun('claude-sonnet-5-5', U(M, 0), table).actual).toBe(1)
expect(priceRun('claude-opus-5-5', U(M, 0), table).actual).toBe(4)
expect(priceRun('claude-sonnet-5-5', U(M, 0), parsePrices('{oops')).actual).toBe(2)
})
test('the report: newest first, totals over every task, filter by task', async () => {
const a = addWriterRun(emptyTask('first'), 'claude-sonnet-5-5', U(M, 0))
const b = addWriterRun(emptyTask('second'), 'claude-sonnet-5-5', U(2 * M, 0))
const r = savingsReport({ T1: a, T2: b }, 1)
expect(r.indexOf('T2')).toBeGreaterThan(-1)
expect(r).not.toContain('T1 ')
expect(r).toMatch(/total\s+\$6\.00\s+\$12\.00\s+\$6\.00\s+50%/)
expect(savingsReport({ T1: a, T2: b }, 9, 'T1')).toContain('first')
expect(savingsReport({ T1: a, T2: b }, 9, 'T1')).not.toContain('second')
})
// ---------- end to end ----------
const USAGE = { input_tokens: 1000, output_tokens: 10, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }
const ok = (stdout: string) => ({ value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } })
const DEV = '/home/homelab/projects/goodwe/clones/OPT-11-P4-dev'
function world(on: On, models: string[]) {
mock.store(on)
const clock = mock.clock(on)
on('ui.status', async () => ({ value: undefined }))
on('ui.toast', async () => ({ value: undefined }))
on('model.complete', async (_$, e) => {
if (e.system?.startsWith('You route')) return { value: { isAnswered: true as const, text: 'sonnet', usage: USAGE } }
return { value: { isAnswered: true as const, text: '{"verdict":"PASS"}', usage: USAGE } }
})
on('process.run', async (_$, e) => {
const a = e.argv.join(' ')
if (a.startsWith('git merge-base')) return ok('base\n')
if (a.startsWith('git diff')) return ok('diff')
return ok('')
})
on('agent.spawn', async (_$, e) => {
models.push(String(e.model))
return { model: e.model === 'sonnet' ? 'claude-sonnet-5-5' : String(e.model), agentId: `a${models.length}` }
})
on('turn.complete', async (_$, e) => ({ text: e.answer }))
on('prompt.submit', async (_$, e) => ({ text: e.text }))
return clock
}
async function end($: Engine, agentId: string, model: string, input: number) {
await ($.turn as unknown as { complete: (e: unknown) => Promise<unknown> }).complete({
answer: 'done',
durationMs: 1,
isAborted: false,
turnId: 't',
agentId,
reason: 'answer',
usage: { model, input_tokens: input, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
})
}
test('end to end: a routed dev agent is priced on the model it ran on, a kept review agent is not counted', async ($, on) => {
const models: string[] = []
const clock = world(on, models)
const dev = await $.agent.spawn({ prompt: `Clone: ${DEV}/\nExpected: x`, description: 'P4 dev' } as never)
await end($, dev.agentId as string, 'claude-sonnet-5-5', M)
await clock.advance(1)
const review = await $.agent.spawn({ prompt: 'Clone: /x/clones/OPT-11-P4-review/', description: 'P4 review', model: 'opus' } as never)
await end($, review.agentId as string, 'claude-opus-5-5', M)
await clock.advance(1)
const r = (await $.command.run({ command: 'router', args: 'savings' } as never)).text ?? ''
expect(r).toContain('P4 dev')
expect(r).not.toContain('P4 review')
// writer $2.00 + classifier and verifier (1000 in + 10 out each, Haiku 4.5 then Opus 5.5)
expect(r).toMatch(/T1\s+\$2\.01\s+\$4\.00/)
expect(r).toContain('Sonnet 5.5')
})
test('agents started in parallel each get their own task, and every run is counted', async ($, on) => {
const models: string[] = []
const clock = world(on, models)
const ids = (
await Promise.all(
['A', 'D', 'E', 'F', 'G'].map(c => $.agent.spawn({ prompt: `Scope chunk ${c}: list the open cards`, description: `Scope chunk ${c}` } as never)),
)
).map(r => r.agentId as string)
await Promise.all(ids.map(id => end($, id, 'claude-sonnet-5-5', M)))
await clock.advance(1)
const r = (await $.command.run({ command: 'router', args: 'savings' } as never)).text ?? ''
for (const c of ['A', 'D', 'E', 'F', 'G']) expect(r).toContain(`Scope chunk ${c}`)
expect(r).toContain('5 of 5 tasks')
// five writer runs of $2.00 each, all counted
expect(r).toMatch(/total\s+\$10\.\d\d\s+\$20\.00/)
})
test('a task whose writer has not finished shows as running and stays out of the total', async () => {
const running = addOverhead(emptyTask('MAINT-14 dev'), 'claude-haiku-4-5', U(M, 0)) // $1 of classifier, no writer yet
const done = addWriterRun(emptyTask('done'), 'claude-sonnet-5-5', U(M, 0))
const r = savingsReport({ T1: done, T2: running }, 5)
expect(r).toMatch(/T2 .* running /)
expect(r).toMatch(/total\s+\$2\.00\s+\$4\.00/)
})