Files
model-router/hooks/pricing.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

186 lines
7.9 KiB
TypeScript

// Cost of a run, and what the same work would have cost on Opus. Pure: no `$`.
//
// Prices: USD per million tokens, from platform.claude.com/docs/en/about-claude/pricing
// (read 2026-10-08). A setting can replace them.
export type Usage = {
input_tokens: number
output_tokens: number
cache_creation_input_tokens?: number | null
cache_read_input_tokens?: number | null
}
/** in, out, cache write (5 min), cache read: USD per MTok. */
export type Price = { in: number; out: number; write: number; read: number }
export type PriceRow = { match: string; price: Price; label: string }
// First match wins: more specific ids come first.
export const DEFAULT_PRICES: readonly PriceRow[] = [
{ match: 'fable-5-1', label: 'Fable 5.1', price: { in: 10, out: 50, write: 12.5, read: 0.25 } },
{ match: 'mythos-5-1', label: 'Mythos 5.1', price: { in: 10, out: 50, write: 12.5, read: 0.25 } },
{ match: 'opus-5-5', label: 'Opus 5.5', price: { in: 4, out: 20, write: 5, read: 0.2 } },
{ match: 'sonnet-5-5', label: 'Sonnet 5.5', price: { in: 2, out: 10, write: 2.5, read: 0.1 } },
// Haiku 5.5 doubles past 100k-token prompts; a run's summed usage cannot tell, so the low tier is used.
{ match: 'haiku-5-5', label: 'Haiku 5.5', price: { in: 0.1, out: 0.5, write: 0.125, read: 0.01 } },
{ match: 'haiku-4-5', label: 'Haiku 4.5', price: { in: 1, out: 5, write: 1.25, read: 0.1 } },
{ match: 'fable-5', label: 'Fable 5', price: { in: 10, out: 50, write: 12.5, read: 1 } },
{ match: 'opus-5', label: 'Opus 5', price: { in: 5, out: 25, write: 6.25, read: 0.5 } },
{ match: 'sonnet-5', label: 'Sonnet 5', price: { in: 2, out: 10, write: 2.5, read: 0.2 } },
]
/** The model every saving is measured against. */
export const BASELINE = 'opus-5-5'
/**
* Models before Claude 4.7 use a tokenizer that yields ~30% fewer tokens for
* the same text, so their counts are scaled up before pricing them as Opus 5.5.
*/
const OLD_TOKENIZER = /(haiku-4-5|sonnet-4|opus-4-[0-6]|opus-4-1|opus-4(?!-)|haiku-3)/
export const tokenizerFactor = (modelId: string) => (OLD_TOKENIZER.test(modelId) ? 1.3 : 1)
export const priceRow = (modelId: string, table: readonly PriceRow[] = DEFAULT_PRICES) =>
table.find(r => modelId.toLowerCase().includes(r.match))
/** USD for a usage at a price; tokens scaled by `factor`. */
export const cost = (u: Usage, p: Price, factor = 1) =>
(factor *
(u.input_tokens * p.in +
u.output_tokens * p.out +
(u.cache_creation_input_tokens ?? 0) * p.write +
(u.cache_read_input_tokens ?? 0) * p.read)) /
1_000_000
export type Priced = { actual: number; opus: number; known: boolean; label: string }
/** What a run cost on its own model, and the same tokens on the baseline. */
export const priceRun = (modelId: string, u: Usage, table: readonly PriceRow[] = DEFAULT_PRICES): Priced => {
const row = priceRow(modelId, table)
const base = priceRow(BASELINE, table)
if (!row || !base) return { actual: 0, opus: 0, known: false, label: modelId }
return {
actual: cost(u, row.price),
opus: cost(u, base.price, tokenizerFactor(modelId)),
known: true,
label: row.label,
}
}
/** A task's running totals, across every agent and round it took. */
export type TaskCost = {
task: string
/** Writer runs on each model, USD. */
writerActual: number
/** Writer runs below Opus, priced at Opus: what Opus would have cost for the same tokens. */
writerAsOpus: number
/** Writer runs that were on Opus already, USD. */
writerOnOpus: number
/** Classifier and verifier calls the router added, USD. */
overhead: number
models: string[]
unpriced: string[]
}
export const emptyTask = (task: string): TaskCost => ({
task,
writerActual: 0,
writerAsOpus: 0,
writerOnOpus: 0,
overhead: 0,
models: [],
unpriced: [],
})
export const addWriterRun = (t: TaskCost, modelId: string, u: Usage, table?: readonly PriceRow[]): TaskCost => {
const p = priceRun(modelId, u, table)
const next = { ...t, models: [...t.models], unpriced: [...t.unpriced] }
if (!p.known) {
if (!next.unpriced.includes(modelId)) next.unpriced.push(modelId)
return next
}
if (!next.models.includes(p.label)) next.models.push(p.label)
next.writerActual += p.actual
if (modelId.toLowerCase().includes(BASELINE)) next.writerOnOpus += p.actual
else next.writerAsOpus += p.opus
return next
}
export const addOverhead = (t: TaskCost, modelId: string, u: Usage, table?: readonly PriceRow[]): TaskCost => {
const p = priceRun(modelId, u, table)
return p.known ? { ...t, overhead: t.overhead + p.actual } : t
}
/**
* What the task would have cost had it gone to Opus at once.
*
* When Opus wrote part of it (a rewrite after failed rounds, or an open
* design), Opus's own runs are taken as what Opus alone would have needed:
* the failed cheaper rounds then count as pure loss. Otherwise every writer
* token is priced at Opus, an upper bound, since Opus may need fewer tokens.
*/
export const counterfactual = (t: TaskCost) => (t.writerOnOpus > 0 ? t.writerOnOpus : t.writerAsOpus)
export const actualTotal = (t: TaskCost) => t.writerActual + t.overhead
export const saved = (t: TaskCost) => counterfactual(t) - actualTotal(t)
export const savedPct = (t: TaskCost) => {
const c = counterfactual(t)
return c > 0 ? (saved(t) / c) * 100 : 0
}
/** Reads a prices setting: a JSON list of rows, or empty for the defaults. */
export const parsePrices = (json: string): readonly PriceRow[] => {
if (!json.trim()) return DEFAULT_PRICES
try {
const rows = JSON.parse(json) as PriceRow[]
const ok = Array.isArray(rows) && rows.every(r => typeof r.match === 'string' && typeof r.price?.in === 'number')
return ok ? [...rows.map(r => ({ ...r, label: r.label ?? r.match })), ...DEFAULT_PRICES] : DEFAULT_PRICES
} catch {
return DEFAULT_PRICES
}
}
export const usd = (n: number) => (Math.abs(n) < 0.01 && n !== 0 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}`)
/**
* The savings table: per task, what it cost, what Opus would have cost, and
* the difference, newest first; then the total over every task.
*/
export const savingsReport = (costs: Readonly<Record<string, TaskCost>>, limit: number, only?: string): string => {
const ids = Object.keys(costs)
.filter(id => !only || id === only)
.sort((a, b) => Number(b.slice(1)) - Number(a.slice(1)))
if (ids.length === 0) return only ? `model-router: no costs recorded for ${only}.` : 'savings: no agent runs recorded yet.'
const row = (id: string, t: TaskCost) => {
const pct = counterfactual(t) > 0 ? `${savedPct(t).toFixed(0)}%` : '-'
const how = t.writerActual === 0 ? 'running' : t.writerOnOpus > 0 ? 'opus ran' : 'upper bound'
return `${id.padEnd(5)} ${usd(actualTotal(t)).padStart(9)} ${usd(counterfactual(t)).padStart(9)} ${usd(saved(t)).padStart(9)} ${pct.padStart(5)} ${how.padEnd(11)} ${t.models.join('+').padEnd(20)} ${t.task.slice(0, 32)}${t.unpriced.length ? ` [no price: ${t.unpriced.join(', ')}]` : ''}`
}
let actual = 0
let opus = 0
for (const id of Object.keys(costs)) {
const t = costs[id]
// a task whose writer has not finished has no comparison yet
if (!t || t.writerActual === 0) continue
actual += actualTotal(t)
opus += counterfactual(t)
}
const shown = ids.slice(0, limit)
const lines = [
`savings vs Opus 5.5 (USD, API list prices)${only ? '' : ` - ${shown.length} of ${ids.length} tasks`}`,
`task actual if opus saved % basis models task`,
...shown.map(id => row(id, costs[id] as TaskCost)),
]
if (!only)
lines.push(
`total ${usd(actual).padStart(9)} ${usd(opus).padStart(9)} ${usd(opus - actual).padStart(9)} ${(opus > 0 ? `${(((opus - actual) / opus) * 100).toFixed(0)}%` : '-').padStart(5)}`,
)
lines.push(
'actual = writer runs + classifier + verifier. "upper bound": all tokens priced at Opus; Opus may need fewer. "opus ran": the Opus run stands for Opus alone, cheaper failed rounds count as loss.',
)
return lines.join('\n')
}