Routes each turn and subagent to the cheapest Claude model that keeps quality, gated by a prompt-cache cost model.

A Claude Code mod that picks the model (Haiku, Sonnet, Opus, Fable) for each turn and each subagent. The goal is to spend fewer tokens without losing quality.
Your /model is home. Each turn either runs on the home model or takes a detour to another model. The router never changes /model itself.
Explore, claude-code-guide and statusline-setup run on Haiku.Plan and security reviewers run on Opus. Other reviewers run on Sonnet.general-purpose agents are sized from their task.git status, a rename or a lookup.fableMode, it takes a second rejection while the first is still held.If any part of the router fails, the turn runs on your /model unchanged.
| Input | Effect |
|---|---|
::haiku fix the typo in README | One turn on Haiku. The prefix is stripped before the model sees the prompt. |
::opus (on its own) | Pins Opus for the session. |
::auto (on its own) | Unpins. |
/route | Status, last decision with its cost estimate, today's and this week's savings and spend by model. |
/route log | The last 20 routing decisions, each with its session. |
/route sessions | The 10 most expensive sessions of the last 7 days, with spend by model. |
/route sonnet · /route auto | Pin or unpin. |
The status line shows the current tier, the reason for it, and today's estimated savings.
Change these in the config menu, or under pluginConfigs.model-router in settings.
| Option | Default | |
|---|---|---|
mainLoop | auto | shadow logs decisions but changes nothing; off disables main-loop routing |
subagents | auto | off leaves subagents alone |
fableMode | escalation | escalation: ::fable, or a second rejection in a row; manual: only ::fable; auto: very hard prompts and the first rejection too |
minTier | haiku | Floor: the router never picks a model below this |
classifier | true | Ask Haiku about unclear prompts |
haikuEffort | low | Effort for main-loop turns sent to Haiku; inherit uses the session's. Haiku keeps its own cache, so this never touches the home model's cache |
*Model | claude-*-5-5, claude-fable-5-1 | Model ids per tier (change them for Bedrock or Vertex) |
cacheTtlMinutes / cacheWriteMultiplier | 60 / 2 | Cache assumptions: 1-hour cache. Use 5 / 1.25 for the 5-minute cache |
horizonTurns | 3 | How many turns a downgrade is assumed to last |
prices | empty (built-in) | Override prices: tier=input/output/cacheRead in $ per million tokens, separated by ;, for example opus=4/20/0.2; haiku=0.1/0.5/0.01. Tiers left out keep the built-in price |
pricesAsOf | empty | YYYY-MM-DD date of your prices. /route and the session-start toast warn once prices are over 90 days old |
*Model id. Ids are matched by family name, so nothing else changes.prices and pricesAsOf, or update DEFAULT_PRICES and DEFAULT_PRICES_AS_OF in hooks/policy.ts. /route shows the prices in use and their age.HAIKU_MAIN_LOOP_MAX_CONTEXT in hooks/policy.ts.claude plugin test ., a few lab scenarios, and /reload-plugins.claude plugin install model-router --marketplace schwepps/claude-mods
Or at the prompt of a terminal session: /plugin install model-router --marketplace schwepps/claude-mods. To develop against a local checkout, run Claude Code with --plugin-dir <this folder>.
claude plugin validate . && claude plugin test .
tests/ holds the free, deterministic tests: the policy, plus multi-turn scenarios run against mocked models (tests/harness.ts).
lab/ is the live battle test. It runs real headless Claude Code sessions, each in a throwaway git repo, sends each turn after the previous one finishes, and checks which model answered every request. It spends real tokens: roughly $1–3 for the full set.
python3 lab/run.py
python3 lab/run.py -k pin -k subagent --workers 1 --keep
Scenarios live in lab/scenarios.json. Each turn sets the tiers allowed for the main loop and for subagents, a status fragment the router must show, and text the answer must include or exclude. Reports go to lab/reports/.
Every --plugin-dir copy of the mod shares one stats store, so lab runs show up in /route stats for those copies.
Explore may already default to a small model./cost prices those turns as an unknown model and overstates them; use /route for the router's figures. The build also assumes a 200K window for Haiku 5.5, so the main loop is never sent to Haiku above 120K context.claude --resume starts without them.haikuEffort), always to the same value, so Haiku's cache stays valid and the home model's is never touched. Pick the session's effort once (high for everyday coding); changing it mid-session invalidates the prompt cache.hooks/register.ts 556 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { DecisionEntry, RouterSession, TurnPlan } from '../types'
5import {
6 CLASSIFIER_SYSTEM,
7 DEFAULT_PRICES,
8 DEFAULT_PRICES_AS_OF,
9 STALE_PRICE_DAYS,
10 TIERS,
11 baselineCostOf,
12 classifierPrompt,
13 classifyPrompt,
14 contextOf,
15 costOf,
16 decideMain,
17 decideSubagent,
18 parseClassifierAnswer,
19 parseOverride,
20 parsePrices,
21 priceAgeDays,
22 rank,
23 tierOf,
24 userWordsOf,
25 withClassifier,
26} from './policy'
27import type { Classification, MainInputs, Settings, Tier, Usage } from './policy'
28import {
29 STATS_DAYS,
30 addSpend,
31 bumpDay,
32 dayKey,
33 describeSessions,
34 describeSpend,
35 lastDays,
36 logDecision,
37 money,
38 readDay,
39 serially,
40 shortSession,
41 sumSpend,
42} from './stats'
43
44type $ = EngineInterface
45
46const FRESH: RouterSession = {
47 pin: null,
48 boost: null,
49 lastTier: null,
50 lastContext: 0,
51 tierCache: {},
52 stepsEma: 4,
53 outputEma: 2000,
54 growthEma: 6000,
55 pendingOverride: null,
56 pendingText: null,
57 turn: null,
58 agents: {},
59 last: null,
60 label: null,
61}
62
63const session = atom({ plugin: 'model-router', key: 'session' } as const, FRESH)
64
65const UNKNOWN_CONTEXT = 30_000
66const BOOST_TURNS = 3
67const FAILURES_BEFORE_ESCALATION = 3
68const EMA = 0.3
69const MIN_CLASSIFIER_PROMPT = 20
70const WEEK_DAYS = 7
71const CLASSIFIER_TIMEOUT_MS = 5000
72
73function settingsFrom(o: PluginOptions): Settings {
74 const pick = <T extends string>(key: string, allowed: readonly T[], fallback: T): T => {
75 const v = o[key]
76 return typeof v === 'string' && (allowed as readonly string[]).includes(v) ? (v as T) : fallback
77 }
78 const text = (key: string, fallback: string) => {
79 const v = o[key]
80 return typeof v === 'string' && v.trim() !== '' ? v.trim() : fallback
81 }
82 const num = (key: string, fallback: number) => {
83 const v = o[key]
84 return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : fallback
85 }
86 return {
87 mainLoop: pick('mainLoop', ['auto', 'shadow', 'off'], 'auto'),
88 subagents: pick('subagents', ['auto', 'off'], 'auto'),
89 fableMode: pick('fableMode', ['manual', 'escalation', 'auto'], 'escalation'),
90 minTier: pick('minTier', ['haiku', 'sonnet', 'opus'], 'haiku'),
91 classifier: o.classifier !== false,
92 models: {
93 haiku: text('haikuModel', 'claude-haiku-5-5'),
94 sonnet: text('sonnetModel', 'claude-sonnet-5-5'),
95 opus: text('opusModel', 'claude-opus-5-5'),
96 fable: text('fableModel', 'claude-fable-5-1'),
97 },
98 cacheTtlMs: num('cacheTtlMinutes', 60) * 60_000,
99 cacheWriteMultiplier: num('cacheWriteMultiplier', 2),
100 horizonTurns: Math.round(num('horizonTurns', 3)),
101 haikuEffort: pick('haikuEffort', ['low', 'medium', 'inherit'], 'low'),
102 ...pricesFrom(text('prices', ''), text('pricesAsOf', '')),
103 }
104}
105
106function pricesFrom(prices: string, asOf: string): Pick<Settings, 'prices' | 'pricesAsOf' | 'priceProblems'> {
107 if (prices === '') return { prices: DEFAULT_PRICES, pricesAsOf: DEFAULT_PRICES_AS_OF, priceProblems: [] }
108 const parsed = parsePrices(prices)
109 const problems = [...parsed.problems]
110 if (priceAgeDays(asOf, 0) === null) problems.push('set pricesAsOf (YYYY-MM-DD) to the date of your prices')
111 return { prices: parsed.prices, pricesAsOf: asOf, priceProblems: problems }
112}
113
114/** What is wrong with the prices in use, or nothing; `now` in ms. */
115function priceWarnings(settings: Settings, now: number): string[] {
116 const age = priceAgeDays(settings.pricesAsOf, now)
117 const stale =
118 age !== null && age > STALE_PRICE_DAYS
119 ? [`prices are ${age} days old (as of ${settings.pricesAsOf}): check anthropic.com/pricing and set prices + pricesAsOf`]
120 : []
121 return [...settings.priceProblems, ...stale]
122}
123
124function describePrices(settings: Settings, now: number): string {
125 const age = priceAgeDays(settings.pricesAsOf, now)
126 const source = settings.prices === DEFAULT_PRICES ? 'built-in' : 'from your settings'
127 const table = TIERS.map(t => {
128 const p = settings.prices[t]
129 return `${t} ${p.input}/${p.output}/${p.cacheRead}`
130 }).join(' · ')
131 const when = age === null ? 'undated' : `as of ${settings.pricesAsOf}, ${age} days old`
132 return `Prices ($/MTok input/output/cache read, ${source}, ${when}): ${table}`
133}
134
135async function showStatus($: $, settings: Settings, tier: Tier | null, reason?: string) {
136 if (settings.mainLoop === 'off' && settings.subagents === 'off') return $.ui.status(undefined)
137 const day = await readDay($, await $.clock.now())
138 const saved = day.baseline - day.actual - day.overhead
139 const mode = settings.mainLoop === 'shadow' ? ' (shadow)' : ''
140 const head = tier ? `⇢ ${tier}${mode}` : `⇢ router${mode}`
141 const why = reason ? ` · ${reason.slice(0, 32)}` : ''
142 const tally = saved >= 0.005 ? ` · saved ~${money(saved)} today` : ''
143 $.ui.status(`${head}${why}${tally}`)
144}
145
146async function askClassifier($: $, settings: Settings, text: string): Promise<Tier | null> {
147 const r = await $.model.complete({
148 model: settings.models.haiku,
149 system: CLASSIFIER_SYSTEM,
150 prompt: classifierPrompt(text),
151 maxTokens: 8,
152 effort: 'low',
153 timeoutMs: CLASSIFIER_TIMEOUT_MS,
154 })
155 const overhead = costOf('haiku', r.usage, 1, settings.prices)
156 await bumpDay($, d => {
157 d.overhead += overhead
158 })
159 return r.isAnswered ? parseClassifierAnswer(r.text) : null
160}
161
162async function refine($: $, settings: Settings, text: string, c: Classification): Promise<Classification> {
163 if (c.confidence === 'high' || !settings.classifier || text.trim().length < MIN_CLASSIFIER_PROMPT) return c
164 try {
165 return withClassifier(c, await askClassifier($, settings, text))
166 } catch {
167 return c
168 }
169}
170
171/** The classifier answers haiku, sonnet or opus; skip it when none of those could move this turn. */
172function classifierCanChange(inputs: Omit<MainInputs, 'classification'>, heuristic: Classification): boolean {
173 if (heuristic.confidence === 'high') return false
174 const without = decideMain({ ...inputs, classification: heuristic }).tier
175 const answers: Tier[] = ['haiku', 'sonnet', 'opus']
176 return answers.some(
177 tier => decideMain({ ...inputs, classification: { tier, confidence: 'high', kind: 'classifier', reason: '' } }).tier !== without,
178 )
179}
180
181async function planTurn($: $, settings: Settings, turnId: string, home: Tier): Promise<TurnPlan> {
182 const s = await read($, session)
183 const now = await $.clock.now()
184 const text = s.pendingText?.turnId === turnId ? s.pendingText.text : ''
185 const words = userWordsOf(text)
186
187 const isSameHome = s.turn === null || s.turn.home === home
188 const current = isSameHome ? (s.lastTier ?? home) : home
189 const context = s.lastContext > 0 ? s.lastContext : ((await $.session.usage()).context.tokens ?? UNKNOWN_CONTEXT)
190 const inputs: Omit<MainInputs, 'classification'> = {
191 home,
192 current,
193 pin: s.pin,
194 boost: s.boost && s.boost.turnsLeft > 0 ? s.boost.tier : null,
195 settings,
196 cost: {
197 prices: settings.prices,
198 now,
199 context,
200 stepsPerTurn: s.stepsEma,
201 outputPerTurn: s.outputEma,
202 growthPerTurn: s.growthEma,
203 tierCache: s.tierCache,
204 settings,
205 },
206 }
207
208 let classification: Classification = s.pendingOverride
209 ? { tier: s.pendingOverride, confidence: 'high', kind: 'override', reason: `::${s.pendingOverride}` }
210 : classifyPrompt(text)
211 if (classifierCanChange(inputs, classification)) classification = await refine($, settings, words, classification)
212
213 const decision = decideMain({ ...inputs, classification })
214
215 const isApplied = settings.mainLoop === 'auto'
216 const tier = isApplied ? decision.tier : home
217 const plan: TurnPlan = { id: turnId, home, tier, steps: 0, output: 0, failures: 0, startContext: context }
218 const isEscalation = classification.kind === 'correction'
219 const sessionId = await $.session.id()
220 const entry: DecisionEntry = {
221 at: now,
222 session: sessionId,
223 scope: 'main',
224 from: decision.from,
225 to: decision.tier,
226 reason: decision.reason,
227 isApplied,
228 prompt: words.slice(0, 120),
229 stayCost: decision.stayCost,
230 switchCost: decision.switchCost,
231 }
232 await update($, session, cur => ({
233 ...cur,
234 turn: plan,
235 pendingOverride: null,
236 pendingText: null,
237 boost: isEscalation ? { tier: decision.tier, turnsLeft: BOOST_TURNS } : cur.boost,
238 last: entry,
239 label: cur.label?.session === sessionId || words === '' ? cur.label : { session: sessionId, text: words },
240 }))
241 await logDecision($, entry)
242 if (decision.tier !== decision.from && isApplied) {
243 await bumpDay($, d => {
244 d.switches += 1
245 d.escalations += isEscalation ? 1 : 0
246 })
247 }
248 await showStatus($, settings, decision.tier, decision.reason)
249 return plan
250}
251
252const labelOf = (s: RouterSession, sessionId: string) => (s.label?.session === sessionId ? s.label.text : '')
253
254async function accountMain($: $, settings: Settings, plan: TurnPlan, usage: Usage) {
255 const now = await $.clock.now()
256 const s = await read($, session)
257 const tier = plan.tier
258 const actual = costOf(tier, usage, settings.cacheWriteMultiplier, settings.prices)
259 const baseline =
260 tier === plan.home
261 ? actual
262 : baselineCostOf(plan.home, usage, s.lastContext, settings.cacheWriteMultiplier, settings.prices)
263 const context = contextOf(usage)
264 await update($, session, cur => ({
265 ...cur,
266 lastTier: tier,
267 lastContext: context,
268 tierCache: { ...cur.tierCache, [tier]: { at: now, context } },
269 turn:
270 cur.turn?.id === plan.id
271 ? { ...cur.turn, steps: cur.turn.steps + 1, output: cur.turn.output + usage.output_tokens }
272 : cur.turn,
273 }))
274 const sessionId = await $.session.id()
275 await bumpDay($, d => {
276 d.actual += actual
277 d.baseline += baseline
278 d.main[tier] += 1
279 addSpend(d, sessionId, labelOf(s, sessionId), tier, actual)
280 })
281}
282
283async function accountSubagent($: $, settings: Settings, agentId: string, model: string, usage: Usage) {
284 const s = await read($, session)
285 const route = s.agents[agentId]
286 const tier = tierOf(model)
287 if (!route || !tier) return
288 const actual = costOf(tier, usage, settings.cacheWriteMultiplier, settings.prices)
289 const baseline = costOf(route.baseline, usage, settings.cacheWriteMultiplier, settings.prices)
290 const sessionId = await $.session.id()
291 await bumpDay($, d => {
292 d.actual += actual
293 d.baseline += baseline
294 addSpend(d, sessionId, labelOf(s, sessionId), tier, actual)
295 })
296}
297
298async function describe($: $, settings: Settings, arg: string): Promise<string> {
299 const s = await read($, session)
300 const now = await $.clock.now()
301 const lines: string[] = []
302
303 if (arg === 'sessions') return describeSessions(await lastDays($, now, WEEK_DAYS))
304
305 if (arg === 'log') {
306 const log = ((await $.store.get('log')) as DecisionEntry[] | undefined) ?? []
307 if (log.length === 0) return 'Model router: no decisions yet.'
308 for (const d of log.slice(-20)) {
309 const time = new Date(d.at).toISOString().slice(11, 16)
310 const mark = d.isApplied ? '' : ' (shadow)'
311 const prompt = d.prompt ? ` “${d.prompt}”` : ''
312 const who = d.session ? ` [${shortSession(d.session)}]` : ''
313 lines.push(`${time}${who} ${d.scope.padEnd(8)} ${d.from} → ${d.to}${mark} ${d.reason}${prompt}`)
314 }
315 return lines.join('\n')
316 }
317
318 const escalation = s.boost ? `${s.boost.tier} for ${s.boost.turnsLeft} more turn(s)` : 'none'
319 lines.push(
320 `Model router: main loop ${settings.mainLoop}, subagents ${settings.subagents}, fable ${settings.fableMode}, floor ${settings.minTier}`,
321 `Home model: ${s.turn?.home ?? 'not seen yet'} · last turn on: ${s.lastTier ?? '-'} · pin: ${s.pin ?? 'none'} · escalation: ${escalation}`,
322 )
323 if (s.last) {
324 const est =
325 s.last.stayCost !== undefined && s.last.switchCost !== undefined
326 ? ` (next ${settings.horizonTurns} turns: stay ${money(s.last.stayCost)} vs switch ${money(s.last.switchCost)})`
327 : ''
328 lines.push(`Last decision: ${s.last.from} → ${s.last.to}: ${s.last.reason}${est}`)
329 }
330
331 if (await $.store.get(dayKey(now))) {
332 const day = await readDay($, now)
333 const saved = day.baseline - day.actual - day.overhead
334 const pct = day.baseline > 0 ? Math.round((saved / day.baseline) * 100) : 0
335 lines.push(
336 `Today (estimated at API list prices): ${money(day.actual + day.overhead)} spent, ${money(day.baseline)} without routing, saved ${money(saved)} (${pct}%)`,
337 ` spend by model: ${describeSpend(day.spend)}`,
338 ` main-loop requests: ${TIERS.map(t => `${t} ${day.main[t]}`).join(', ')} · subagents: ${TIERS.map(t => `${t} ${day.subagents[t]}`).join(', ')}`,
339 ` switches ${day.switches}, escalations ${day.escalations}, classifier ${money(day.overhead)}`,
340 )
341 }
342 const week = await lastDays($, now, WEEK_DAYS)
343 const baseline = week.reduce((sum, d) => sum + d.baseline, 0)
344 if (baseline > 0) {
345 const spent = week.reduce((sum, d) => sum + d.actual + d.overhead, 0)
346 lines.push(
347 `Last 7 days: saved ${money(baseline - spent)} of ${money(baseline)}`,
348 `Last 7 days by model: ${describeSpend(sumSpend(week))} · /route sessions for each session`,
349 )
350 }
351 lines.push(describePrices(settings, now), ...priceWarnings(settings, now).map(w => `⚠ ${w}`))
352 lines.push('Per prompt: start it with ::haiku, ::sonnet, ::opus or ::fable. Sent alone, ::opus pins and ::auto unpins.')
353 return lines.join('\n')
354}
355
356export const register: Register = (on, options) => {
357 const settings = settingsFrom(options)
358
359 on('session.start', async ($, e, next) => {
360 const started = await next(e)
361 await $.command.register({
362 name: 'route',
363 description: 'Model router: status and savings; /route haiku|sonnet|opus|fable pins, /route auto unpins, /route log, /route sessions',
364 argumentHint: '[auto|haiku|sonnet|opus|fable|log|sessions]',
365 })
366 await serially(async () => {
367 const cutoff = dayKey((await $.clock.now()) - STATS_DAYS * 86_400_000)
368 for (const key of await $.store.keys()) {
369 if (key.startsWith('stats:') && key < cutoff) await $.store.delete(key)
370 }
371 })
372 await showStatus($, settings, null)
373 const warnings = priceWarnings(settings, await $.clock.now())
374 if (warnings.length > 0) $.ui.toast(`Model router: ${warnings.join('; ')}`)
375 return started
376 })
377
378 on('prompt.submit', async ($, e, next) => {
379 const { tier, rest } = parseOverride(e.text)
380 if (tier === null) return next(e)
381
382 if (rest.trim() === '') {
383 const pin = tier === 'auto' ? null : tier
384 await update($, session, s => ({ ...s, pin }))
385 await showStatus($, settings, pin, pin ? 'pinned' : 'auto')
386 return { drop: pin ? `Model router: pinned to ${pin} for this session.` : 'Model router: back to automatic routing.' }
387 }
388
389 const isNewTurn = e.turnId === undefined
390 await update($, session, s => ({ ...s, pendingOverride: isNewTurn && tier !== 'auto' ? tier : null }))
391 return next({ ...e, text: rest })
392 }).catch(($, e, next) => next(e))
393
394 on('turn.start', async ($, e, next) => {
395 await update($, session, s => ({ ...s, pendingText: { turnId: e.turnId, text: e.text } }))
396 return next(e)
397 })
398
399 on('turn.step', async function* ($, e, next) {
400 if (e.agentId !== undefined) {
401 const result = yield* next(e)
402 if (result.usage) await accountSubagent($, settings, e.agentId, e.model, result.usage)
403 return result
404 }
405
406 const engineTier = tierOf(e.model)
407 if (settings.mainLoop === 'off' || engineTier === null) return yield* next(e)
408
409 const s = await read($, session)
410 let plan = s.turn?.id === e.turnId ? s.turn : null
411 if (plan === null) {
412 if (e.index > 0) return yield* next(e)
413 plan = await planTurn($, settings, e.turnId, engineTier)
414 }
415 if (engineTier !== plan.home) return yield* next(e)
416
417 const model = plan.tier === plan.home ? e.model : settings.models[plan.tier]
418 const effort =
419 plan.tier === 'haiku' && e.effort !== undefined && settings.haikuEffort !== 'inherit' ? settings.haikuEffort : e.effort
420 const isRewritten = model !== e.model || effort !== e.effort
421 const result = yield* next(isRewritten ? { ...e, model, effort } : e)
422 if (result.usage) await accountMain($, settings, plan, result.usage)
423 return result
424 })
425
426 on('tool.call', async ($, e, next) => {
427 const ran = await next(e)
428 if (e.agentId !== undefined || settings.mainLoop !== 'auto') return ran
429
430 const plan = (await read($, session)).turn
431 if (!plan || rank(plan.tier) >= rank(plan.home)) return ran
432
433 const failures = ran.isError === true ? plan.failures + 1 : 0
434 const isEscalating = failures >= FAILURES_BEFORE_ESCALATION
435 const tier = isEscalating ? plan.home : plan.tier
436 await update($, session, cur =>
437 cur.turn?.id === plan.id
438 ? {
439 ...cur,
440 turn: { ...cur.turn, failures: isEscalating ? 0 : failures, tier },
441 boost: isEscalating ? { tier, turnsLeft: BOOST_TURNS } : cur.boost,
442 }
443 : cur,
444 )
445 if (isEscalating) {
446 $.ui.toast(`Model router: ${plan.tier} kept failing, back to ${tier} for the rest of this turn`)
447 await bumpDay($, d => {
448 d.escalations += 1
449 })
450 await logDecision($, {
451 at: await $.clock.now(),
452 session: await $.session.id(),
453 scope: 'main',
454 from: plan.tier,
455 to: tier,
456 reason: `${FAILURES_BEFORE_ESCALATION} failed tool calls in a row`,
457 isApplied: true,
458 prompt: '',
459 })
460 }
461 return ran
462 }).catch(($, e, next) => next(e))
463
464 on('turn.complete', async ($, e, next) => {
465 if (e.agentId !== undefined) return next(e)
466 await update($, session, s => {
467 const plan = s.turn
468 if (!plan) return s
469 const growth = Math.max(0, s.lastContext - plan.startContext)
470 return {
471 ...s,
472 stepsEma: plan.steps > 0 ? s.stepsEma * (1 - EMA) + plan.steps * EMA : s.stepsEma,
473 outputEma: plan.steps > 0 ? s.outputEma * (1 - EMA) + plan.output * EMA : s.outputEma,
474 growthEma: plan.steps > 0 ? s.growthEma * (1 - EMA) + growth * EMA : s.growthEma,
475 boost: s.boost && s.boost.turnsLeft > 1 ? { ...s.boost, turnsLeft: s.boost.turnsLeft - 1 } : null,
476 }
477 })
478 return next(e)
479 })
480
481 on('session.compact', async ($, e, next) => {
482 const result = await next(e)
483 if (e.agentId === undefined) {
484 await update($, session, s => ({ ...s, tierCache: {}, lastContext: 0 }))
485 }
486 return result
487 })
488
489 on('session.end', async ($, e, next) => {
490 if (e.reason === 'clear') {
491 await update($, session, s => ({ ...FRESH, pin: s.pin }))
492 }
493 return next(e)
494 })
495
496 on('agent.spawn', async ($, e, next) => {
497 const parent = tierOf(e.parentModel)
498 if (settings.subagents === 'off' || parent === null) return next(e)
499
500 const isGeneral = e.subagentType === 'general-purpose' || e.subagentType === 'claude'
501 const classification =
502 isGeneral && !e.model ? await refine($, settings, e.prompt, classifyPrompt(e.prompt)) : undefined
503 const decision = decideSubagent({
504 subagentType: e.subagentType,
505 prompt: e.prompt,
506 explicitModel: e.model,
507 classification,
508 settings,
509 })
510 if (!decision) return next(e)
511
512 const started = await next({ ...e, model: settings.models[decision.tier] })
513 if (started.deny !== undefined) return started
514
515 const inherits = !e.model && (isGeneral || e.subagentType === 'Plan')
516 const baseline = inherits ? parent : decision.tier
517 const agentId = started.agentId
518 if (agentId) {
519 await update($, session, s => {
520 const kept = Object.entries(s.agents).slice(-49)
521 return { ...s, agents: { ...Object.fromEntries(kept), [agentId]: { tier: decision.tier, baseline } } }
522 })
523 }
524 await bumpDay($, d => {
525 d.subagents[decision.tier] += 1
526 })
527 await logDecision($, {
528 at: await $.clock.now(),
529 session: await $.session.id(),
530 scope: 'subagent',
531 from: baseline,
532 to: decision.tier,
533 reason: `${e.subagentType}: ${decision.reason}`,
534 isApplied: true,
535 prompt: e.description.slice(0, 120),
536 })
537 return started
538 }).catch(($, e, next) => next(e))
539
540 on('command.run', { command: 'route' }, async ($, e) => {
541 const arg = e.args.trim().toLowerCase()
542 if ((TIERS as readonly string[]).includes(arg)) {
543 const pin = arg as Tier
544 await update($, session, s => ({ ...s, pin }))
545 await showStatus($, settings, pin, 'pinned')
546 return { text: `Model router: pinned to ${pin} for this session. /route auto to undo.` }
547 }
548 if (arg === 'auto') {
549 await update($, session, s => ({ ...s, pin: null }))
550 await showStatus($, settings, null)
551 return { text: 'Model router: automatic routing.' }
552 }
553 return { text: await describe($, settings, arg) }
554 })
555}
556hooks/policy.ts 437 lines1import type { CacheMark, Tier } from '../types'
2
3export type { CacheMark, Tier }
4
5export const TIERS: readonly Tier[] = ['haiku', 'sonnet', 'opus', 'fable']
6
7export type Confidence = 'high' | 'low'
8
9export type Classification = {
10 tier: Tier | 'home' | 'current'
11 confidence: Confidence
12 kind: 'override' | 'continuation' | 'correction' | 'heuristic' | 'classifier' | 'default'
13 reason: string
14}
15
16export type Settings = {
17 mainLoop: 'auto' | 'shadow' | 'off'
18 subagents: 'auto' | 'off'
19 fableMode: 'manual' | 'escalation' | 'auto'
20 minTier: Tier
21 classifier: boolean
22 models: Record<Tier, string>
23 cacheTtlMs: number
24 cacheWriteMultiplier: number
25 horizonTurns: number
26 haikuEffort: 'low' | 'medium' | 'inherit'
27 prices: Prices
28 pricesAsOf: string
29 priceProblems: string[]
30}
31
32export type Price = { input: number; output: number; cacheRead: number }
33export type Prices = Record<Tier, Price>
34
35// USD per million tokens, Anthropic first-party list prices. The `prices` setting overrides them.
36export const DEFAULT_PRICES: Prices = {
37 haiku: { input: 0.1, output: 0.5, cacheRead: 0.01 },
38 sonnet: { input: 2, output: 10, cacheRead: 0.2 },
39 opus: { input: 4, output: 20, cacheRead: 0.2 },
40 fable: { input: 10, output: 50, cacheRead: 0.25 },
41}
42export const DEFAULT_PRICES_AS_OF = '2026-10-06'
43export const STALE_PRICE_DAYS = 90
44
45const PRICE_ENTRY = /^(haiku|sonnet|opus|fable)\s*=\s*([\d.]+)\s*\/\s*([\d.]+)\s*\/\s*([\d.]+)$/i
46const DATE = /^\d{4}-\d{2}-\d{2}$/
47
48/** Reads `haiku=0.1/0.5/0.01; opus=4/20/0.2` (input/output/cache read per MTok); tiers left out keep the defaults. */
49export function parsePrices(text: string): { prices: Prices; problems: string[] } {
50 const prices: Prices = { ...DEFAULT_PRICES }
51 const problems: string[] = []
52 for (const raw of text.split(/[;,\n]/)) {
53 const entry = raw.trim()
54 if (entry === '') continue
55 const m = PRICE_ENTRY.exec(entry)
56 const values = m ? [m[2], m[3], m[4]].map(Number) : []
57 if (!m?.[1] || values.some(v => !Number.isFinite(v) || v <= 0)) {
58 problems.push(`ignored price entry "${entry}" (expected tier=input/output/cacheRead)`)
59 continue
60 }
61 const [input = 0, output = 0, cacheRead = 0] = values
62 prices[m[1].toLowerCase() as Tier] = { input, output, cacheRead }
63 }
64 return { prices, problems }
65}
66
67/** Days since `asOf`, a YYYY-MM-DD date; null when it does not parse. */
68export function priceAgeDays(asOf: string, now: number): number | null {
69 if (!DATE.test(asOf)) return null
70 const at = Date.parse(`${asOf}T00:00:00Z`)
71 return Number.isNaN(at) ? null : Math.floor((now - at) / 86_400_000)
72}
73
74// Haiku 5.5 bills prompts over 100K tokens at 5x.
75const HAIKU_LONG_PROMPT = 100_000
76
77export const rank = (tier: Tier) => TIERS.indexOf(tier)
78export const maxTier = (a: Tier, b: Tier) => (rank(a) >= rank(b) ? a : b)
79export const nextTier = (tier: Tier): Tier => TIERS[Math.min(rank(tier) + 1, TIERS.length - 1)] ?? tier
80
81export function tierOf(model: string | undefined): Tier | null {
82 if (!model) return null
83 const id = model.toLowerCase()
84 if (id.includes('haiku')) return 'haiku'
85 if (id.includes('sonnet')) return 'sonnet'
86 if (id.includes('opus')) return 'opus'
87 if (id.includes('fable') || id.includes('mythos')) return 'fable'
88 return null
89}
90
91export function priceFor(tier: Tier, promptTokens: number, prices: Prices = DEFAULT_PRICES): Price {
92 const p = prices[tier]
93 if (tier === 'haiku' && promptTokens > HAIKU_LONG_PROMPT) {
94 return { input: p.input * 5, output: p.output * 5, cacheRead: p.cacheRead * 5 }
95 }
96 return p
97}
98
99export type Usage = {
100 input_tokens: number
101 output_tokens: number
102 cache_read_input_tokens: number
103 cache_creation_input_tokens: number
104}
105
106export const contextOf = (u: Usage) =>
107 u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
108
109export function costOf(tier: Tier, u: Usage, writeMultiplier: number, prices: Prices = DEFAULT_PRICES): number {
110 const p = priceFor(tier, contextOf(u), prices)
111 return (
112 (u.input_tokens * p.input +
113 u.cache_creation_input_tokens * p.input * writeMultiplier +
114 u.cache_read_input_tokens * p.cacheRead +
115 u.output_tokens * p.output) /
116 1e6
117 )
118}
119
120/**
121 * What the home model would have paid for a step that ran on another tier,
122 * assuming its cache was warm up to the previous step's context.
123 */
124export function baselineCostOf(
125 home: Tier,
126 u: Usage,
127 previousContext: number,
128 writeMultiplier: number,
129 prices: Prices = DEFAULT_PRICES,
130): number {
131 if (previousContext === 0) return costOf(home, u, writeMultiplier, prices)
132 const context = contextOf(u)
133 const fresh = Math.max(0, context - previousContext)
134 const p = priceFor(home, context, prices)
135 return (
136 ((context - fresh) * p.cacheRead + fresh * p.input * writeMultiplier + u.output_tokens * p.output) /
137 1e6
138 )
139}
140
141const OVERRIDE = /^\s*::(haiku|sonnet|opus|fable|auto)\b\s*/i
142
143export function parseOverride(text: string): { tier: Tier | 'auto' | null; rest: string } {
144 const m = OVERRIDE.exec(text)
145 if (!m?.[1]) return { tier: null, rest: text }
146 return { tier: m[1].toLowerCase() as Tier | 'auto', rest: text.slice(m[0].length) }
147}
148
149const ACK = String.raw`(y|yes|yep|yeah|ok|okay|sure|go|go ahead|go on|do it|continue|proceed|lgtm|ship it|approved?|sounds good|perfect|great|thanks|thank you|\d{1,2}|option \d{1,2}|[a-d])`
150const CONTINUATION = new RegExp(String.raw`^\s*${ACK}([\s,.!]+${ACK})*[\s.!]*$`, 'i')
151
152const APP_BLOCKS = [
153 'system-reminder',
154 'cross-session-message',
155 'side-sessions-event',
156 'ci-monitor-event',
157 'task-notification',
158 'agent-message',
159 'teammate-message',
160 'channel-message',
161 'artifact-view-context',
162]
163const APP_BLOCK = new RegExp(String.raw`<(${APP_BLOCKS.join('|')})\b[^>]*?(?:\/>|>[\s\S]*?<\/\1\s*>)`, 'gi')
164const COMMAND_NAME = /<command-name>/i
165const COMMAND_ARGS = /<command-args>([\s\S]*?)<\/command-args>/i
166
167/** What the person typed: blocks the app or other sessions add are dropped, a command keeps only its arguments. */
168export function userWordsOf(text: string): string {
169 const rest = text.replace(APP_BLOCK, '')
170 if (COMMAND_NAME.test(rest)) return (COMMAND_ARGS.exec(rest)?.[1] ?? '').trim()
171 return rest.trim()
172}
173
174const CORRECTION =
175 /\b(still (fails?|failing|broken|wrong|not working)|(doesn'?t|didn'?t|does not|did not) work|not working|that'?s (wrong|not right|incorrect|not what)|you broke|regress(ed|ion)|same error|try again|wrong (answer|approach|file)|not what i (asked|wanted|meant))\b/gi
176const DENIED = /\b(no|not|without|never|zero)\s+(a\s+|any\s+)?$/i
177
178function isCorrection(text: string): boolean {
179 for (const m of text.matchAll(CORRECTION)) {
180 const isDenied = /^regress/i.test(m[0]) && DENIED.test(text.slice(0, m.index))
181 if (!isDenied) return true
182 }
183 return false
184}
185
186type Rule = { pattern: RegExp; tier: Tier; weight: number; label: string }
187
188const RULES: Rule[] = [
189 { pattern: /\b(architect(ure)?|system design|design (a|the|our) |trade-?offs?)\b/i, tier: 'opus', weight: 3, label: 'design' },
190 { pattern: /\b(race condition|deadlock|concurren|memory leak|heisenbug|flaky|non-?deterministic)\w*/i, tier: 'opus', weight: 3, label: 'hard debugging' },
191 { pattern: /\b(security|vulnerab\w*|threat model|auth(n|z|entication|orization)?\b.*\b(bypass|flaw)|injection|xss|csrf)\b/i, tier: 'opus', weight: 3, label: 'security' },
192 { pattern: /\b(root cause|investigate|why (does|is|do|did|are)|figure out why)\b/i, tier: 'opus', weight: 2, label: 'investigation' },
193 { pattern: /\b(migrat(e|ion)|refactor (the|across|entire|whole|all)|rewrite (the|this) (module|service|system))\b/i, tier: 'opus', weight: 2, label: 'large change' },
194 { pattern: /\b(plan|spec|proposal|strategy|algorithm|prove|optimi[sz]e|performance (issue|problem|regression))\b/i, tier: 'opus', weight: 2, label: 'reasoning' },
195 { pattern: /\b(implement|build|add (a |an )?(feature|endpoint|function|method|field|flag|option|component|test)|write (a |the )?tests?|fix (the|this|a) (bug|error|issue|test))\b/i, tier: 'sonnet', weight: 2, label: 'implementation' },
196 { pattern: /\b(update|change|modify|extend|wire|hook up)\b/i, tier: 'sonnet', weight: 1, label: 'edit' },
197 { pattern: /^\s*(what('?s| is| are| does)|define|show me|list|print|where is|which file)\b/i, tier: 'haiku', weight: 2, label: 'lookup' },
198 { pattern: /\b(rename|typo|reformat|format (the|this)|sort imports|add (a )?comment|bump (the )?version|update (the )?changelog)\b/i, tier: 'haiku', weight: 3, label: 'mechanical edit' },
199 { pattern: /\bgit (status|log|diff|add|commit|push|pull|stash|checkout|branch)\b|^\s*commit\b|\bcommit (this|these|the changes|it)\b/i, tier: 'haiku', weight: 3, label: 'git op' },
200 { pattern: /^\s*(run|rerun|re-run) (the )?(tests?|build|lint|linter|formatter)\s*[.!]*\s*$/i, tier: 'haiku', weight: 3, label: 'run command' },
201]
202
203const LONG_PROMPT = 1500
204
205export function classifyPrompt(text: string): Classification {
206 if (text.trim() === '') {
207 return { tier: 'current', confidence: 'high', kind: 'continuation', reason: 'no new prompt' }
208 }
209 const trimmed = userWordsOf(text)
210 if (trimmed === '') {
211 return { tier: 'current', confidence: 'high', kind: 'continuation', reason: 'message from the app, a session or an agent' }
212 }
213 if (CONTINUATION.test(trimmed)) {
214 return { tier: 'current', confidence: 'high', kind: 'continuation', reason: 'short follow-up' }
215 }
216 if (isCorrection(trimmed)) {
217 return { tier: 'current', confidence: 'high', kind: 'correction', reason: 'previous answer rejected' }
218 }
219
220 const score: Record<Tier, number> = { haiku: 0, sonnet: 0, opus: 0, fable: 0 }
221 const labels: string[] = []
222 for (const rule of RULES) {
223 if (rule.pattern.test(trimmed)) {
224 score[rule.tier] += rule.weight
225 labels.push(rule.label)
226 }
227 }
228 if (trimmed.length > LONG_PROMPT) {
229 score.opus += 2
230 labels.push('long prompt')
231 }
232 if ((trimmed.match(/```/g) ?? []).length >= 4) {
233 score.opus += 1
234 labels.push('multiple code blocks')
235 }
236
237 const reason = labels.join(', ')
238 if (score.opus >= 2) {
239 const isFable = score.opus >= 6
240 return { tier: isFable ? 'fable' : 'opus', confidence: 'high', kind: 'heuristic', reason }
241 }
242 if (score.haiku >= 2 && score.sonnet === 0 && score.opus === 0 && trimmed.length < 400) {
243 return { tier: 'haiku', confidence: 'high', kind: 'heuristic', reason }
244 }
245 if (score.sonnet >= 2 && score.opus === 0) {
246 return { tier: 'sonnet', confidence: 'low', kind: 'heuristic', reason }
247 }
248 return { tier: 'home', confidence: 'low', kind: 'default', reason: reason || 'no clear signal' }
249}
250
251export const CLASSIFIER_SYSTEM =
252 'You route requests made to a coding agent to the least capable model that will still handle them at full quality. ' +
253 'Answer with exactly one word.\n' +
254 'haiku: trivial and mechanical — a lookup, a git operation, a rename, formatting, a short factual question.\n' +
255 'sonnet: a standard, well-scoped task — implement a function, write tests, a bounded bug fix, an edit across a few files.\n' +
256 'opus: hard or open-ended — architecture, hard debugging, security, a large refactor, ambiguous requirements, planning.'
257
258export function classifierPrompt(text: string): string {
259 return `<request>\n${text.slice(0, 4000)}\n</request>\nOne word: haiku, sonnet or opus.`
260}
261
262/** The classifier may raise a turn the keyword rules saw as implementation work, never drop it below sonnet. */
263export function withClassifier(heuristic: Classification, answer: Tier | null): Classification {
264 if (answer === null) return heuristic
265 const tier = heuristic.tier === 'sonnet' && answer === 'haiku' ? 'sonnet' : answer
266 return { tier, confidence: 'high', kind: 'classifier', reason: `classifier: ${answer}` }
267}
268
269export function parseClassifierAnswer(answer: string): Tier | null {
270 const m = /\b(haiku|sonnet|opus)\b/i.exec(answer)
271 return m?.[1] ? (m[1].toLowerCase() as Tier) : null
272}
273
274export type CostInputs = {
275 now: number
276 context: number
277 stepsPerTurn: number
278 outputPerTurn: number
279 growthPerTurn: number
280 tierCache: Partial<Record<Tier, CacheMark>>
281 settings: Pick<Settings, 'cacheTtlMs' | 'cacheWriteMultiplier' | 'horizonTurns'>
282 prices?: Prices
283}
284
285function warmTokens(tier: Tier, c: CostInputs): number {
286 const mark = c.tierCache[tier]
287 if (!mark || c.now - mark.at > c.settings.cacheTtlMs) return 0
288 return Math.min(c.context, mark.context)
289}
290
291function turnCost(tier: Tier, c: CostInputs, isFirst: boolean): number {
292 const p = priceFor(tier, c.context, c.prices)
293 const w = c.settings.cacheWriteMultiplier
294 const cold = isFirst ? c.context - warmTokens(tier, c) : c.growthPerTurn
295 const warm = c.context - cold
296 const laterSteps = Math.max(0, c.stepsPerTurn - 1) * c.context
297 return (cold * p.input * w + warm * p.cacheRead + laterSteps * p.cacheRead + c.outputPerTurn * p.output) / 1e6
298}
299
300export function horizonCost(tier: Tier, c: CostInputs): number {
301 let total = turnCost(tier, c, true)
302 for (let i = 1; i < c.settings.horizonTurns; i++) total += turnCost(tier, c, false)
303 return total
304}
305
306/** Cost of rewriting the detour's new tokens into the home model's cache on the way back. */
307export function returnCost(home: Tier, c: CostInputs): number {
308 const p = priceFor(home, c.context, c.prices)
309 return (c.settings.horizonTurns * c.growthPerTurn * p.input * c.settings.cacheWriteMultiplier) / 1e6
310}
311
312const SWITCH_MARGIN = 1.15
313
314// Claude Code assumes a 200K window for Haiku 5.5 (it does not know the model yet) and Haiku bills past 100K at 5x.
315const HAIKU_MAIN_LOOP_MAX_CONTEXT = 120_000
316
317export type Decision = {
318 tier: Tier
319 from: Tier
320 reason: string
321 stayCost?: number
322 switchCost?: number
323}
324
325export type MainInputs = {
326 classification: Classification
327 home: Tier
328 current: Tier
329 pin: Tier | null
330 boost: Tier | null
331 cost: CostInputs
332 settings: Pick<Settings, 'fableMode' | 'minTier'>
333}
334
335function clamp(tier: Tier, s: Pick<Settings, 'fableMode' | 'minTier'>, allowFable: boolean): Tier {
336 let t = maxTier(tier, s.minTier)
337 if (t === 'fable' && !allowFable) t = 'opus'
338 return t
339}
340
341export function decideMain(i: MainInputs): Decision {
342 const { classification: c, home, current, settings } = i
343 const from = current
344
345 if (i.pin) return { tier: i.pin, from, reason: `pinned to ${i.pin}` }
346 if (c.kind === 'override' && c.tier !== 'home' && c.tier !== 'current') {
347 return { tier: c.tier, from, reason: `::${c.tier} override` }
348 }
349
350 if (c.kind === 'correction') {
351 const up = rank(current) < rank(home) ? home : nextTier(current)
352 const isRepeated = i.boost !== null
353 const allow = settings.fableMode === 'auto' || (settings.fableMode === 'escalation' && isRepeated)
354 return { tier: clamp(up, settings, allow), from, reason: `escalated: ${c.reason}` }
355 }
356
357 let wanted: Tier =
358 c.tier === 'current' ? current : c.tier === 'home' ? home : c.tier
359 let reason = c.reason
360 if (c.confidence === 'low') wanted = maxTier(wanted, home)
361
362 if (i.boost && rank(i.boost) > rank(wanted)) {
363 wanted = i.boost
364 reason = `holding escalation (${reason})`
365 }
366
367 wanted = clamp(wanted, settings, settings.fableMode === 'auto' || i.boost === 'fable' || current === 'fable')
368
369 if (wanted === 'haiku' && i.cost.context > HAIKU_MAIN_LOOP_MAX_CONTEXT) {
370 wanted = maxTier('sonnet', settings.minTier)
371 reason = `${reason}; context too long for haiku`
372 }
373
374 if (rank(wanted) > rank(current)) return { tier: wanted, from, reason }
375 if (wanted === current) return { tier: current, from, reason }
376 if (rank(wanted) >= rank(home)) return { tier: wanted, from, reason: `back toward ${home}: ${reason}` }
377
378 const stayCost = horizonCost(current, i.cost)
379 const switchCost = horizonCost(wanted, i.cost) + returnCost(home, i.cost)
380 if (switchCost * SWITCH_MARGIN < stayCost) {
381 return { tier: wanted, from, reason, stayCost, switchCost }
382 }
383 return {
384 tier: current,
385 from,
386 reason: `kept ${current}: switching to ${wanted} costs more than it saves with this context`,
387 stayCost,
388 switchCost,
389 }
390}
391
392const SUBAGENT_TIERS: Record<string, Tier> = {
393 'Explore': 'haiku',
394 'claude-code-guide': 'haiku',
395 'statusline-setup': 'haiku',
396 'Plan': 'opus',
397}
398
399export type SubagentInputs = {
400 subagentType: string
401 prompt: string
402 explicitModel?: string
403 classification?: Classification
404 settings: Pick<Settings, 'fableMode' | 'minTier'>
405}
406
407export function decideSubagent(i: SubagentInputs): { tier: Tier; reason: string } | null {
408 const { subagentType: type, settings } = i
409 if (type === 'fork') return null
410
411 const explicit = tierOf(i.explicitModel)
412 if (explicit) {
413 if (explicit === 'fable' && settings.fableMode !== 'auto') {
414 return { tier: 'opus', reason: 'fable requested by the model; capped by fableMode' }
415 }
416 return null
417 }
418
419 const known = SUBAGENT_TIERS[type]
420 if (known) return { tier: clamp(known, settings, false), reason: `${type} agent` }
421
422 if (/security/i.test(type)) return { tier: clamp('opus', settings, false), reason: 'security agent' }
423 if (/review|architect/i.test(type)) return { tier: clamp('sonnet', settings, false), reason: 'review agent' }
424
425 const isGeneral = type === 'general-purpose' || type === 'claude'
426 if (!isGeneral) return null
427
428 const c = i.classification ?? classifyPrompt(i.prompt)
429 if (c.tier === 'opus' || c.tier === 'fable') {
430 return { tier: clamp(c.tier, settings, settings.fableMode === 'auto'), reason: `task: ${c.reason}` }
431 }
432 if (c.tier === 'haiku' && c.confidence === 'high') {
433 return { tier: clamp('haiku', settings, false), reason: `task: ${c.reason}` }
434 }
435 return { tier: clamp('sonnet', settings, false), reason: `scoped task (${c.reason})` }
436}
437hooks/stats.ts 141 lines1import type { EngineInterface } from 'claude-code'
2
3import type { DecisionEntry } from '../types'
4import { TIERS } from './policy'
5import type { Tier } from './policy'
6
7type $ = EngineInterface
8
9export type SpendByTier = Record<Tier, number>
10
11export type SessionSpend = { label: string; spend: SpendByTier }
12
13export type DayStats = {
14 actual: number
15 baseline: number
16 overhead: number
17 main: Record<Tier, number>
18 subagents: Record<Tier, number>
19 spend: SpendByTier
20 sessions: Record<string, SessionSpend>
21 switches: number
22 escalations: number
23}
24
25export const LOG_SIZE = 100
26export const STATS_DAYS = 30
27const DAY_MS = 86_400_000
28const SESSIONS_SHOWN = 10
29const LABEL_LENGTH = 60
30const SESSION_ID_LENGTH = 8
31
32export const emptyCounts = (): Record<Tier, number> => ({ haiku: 0, sonnet: 0, opus: 0, fable: 0 })
33
34export const emptyDay = (): DayStats => ({
35 actual: 0,
36 baseline: 0,
37 overhead: 0,
38 main: emptyCounts(),
39 subagents: emptyCounts(),
40 spend: emptyCounts(),
41 sessions: {},
42 switches: 0,
43 escalations: 0,
44})
45
46/** A stored day, filled out with fields added after it was written. */
47export function normalizeDay(stored: Partial<DayStats> | undefined): DayStats {
48 return { ...emptyDay(), ...stored }
49}
50
51export const dayKey = (ms: number) => `stats:${new Date(ms).toISOString().slice(0, 10)}`
52
53export const shortSession = (id: string) => id.slice(0, SESSION_ID_LENGTH)
54
55export function money(usd: number): string {
56 const abs = Math.abs(usd)
57 const text = `$${abs.toFixed(abs < 1 ? 3 : 2)}`
58 return usd <= -0.0005 ? `-${text}` : text
59}
60
61// Main-loop and subagent steps run concurrently; $.store has no versioning, so writes queue here.
62let storeQueue: Promise<unknown> = Promise.resolve()
63
64export function serially<T>(work: () => Promise<T>): Promise<T> {
65 const run = storeQueue.then(work, work)
66 storeQueue = run.catch(() => undefined)
67 return run
68}
69
70export async function readDay($: $, ms: number): Promise<DayStats> {
71 return normalizeDay((await $.store.get(dayKey(ms))) as Partial<DayStats> | undefined)
72}
73
74export function bumpDay($: $, fn: (d: DayStats) => void) {
75 return serially(async () => {
76 const now = await $.clock.now()
77 const day = await readDay($, now)
78 fn(day)
79 await $.store.set(dayKey(now), day)
80 })
81}
82
83export function logDecision($: $, entry: DecisionEntry) {
84 return serially(async () => {
85 const log = ((await $.store.get('log')) as DecisionEntry[] | undefined) ?? []
86 await $.store.set('log', [...log, entry].slice(-LOG_SIZE))
87 })
88}
89
90export function addSpend(day: DayStats, session: string, label: string, tier: Tier, usd: number) {
91 day.spend[tier] += usd
92 const record = day.sessions[session] ?? { label: '', spend: emptyCounts() }
93 record.spend[tier] += usd
94 if (record.label === '' && label !== '') record.label = label.slice(0, LABEL_LENGTH)
95 day.sessions[session] = record
96}
97
98export const totalOf = (spend: SpendByTier) => TIERS.reduce((sum, t) => sum + spend[t], 0)
99
100/** `haiku $0.001 · opus $0.120`, tiers with no spend left out. */
101export function describeSpend(spend: SpendByTier): string {
102 const parts = TIERS.filter(t => spend[t] > 0).map(t => `${t} ${money(spend[t])}`)
103 return parts.length > 0 ? parts.join(' · ') : 'none'
104}
105
106export async function lastDays($: $, now: number, count: number): Promise<DayStats[]> {
107 const days: DayStats[] = []
108 for (let i = 0; i < count; i++) {
109 const stored = await $.store.get(dayKey(now - i * DAY_MS))
110 if (stored) days.push(normalizeDay(stored as Partial<DayStats>))
111 }
112 return days
113}
114
115export function sumSpend(days: DayStats[]): SpendByTier {
116 const spend = emptyCounts()
117 for (const day of days) for (const t of TIERS) spend[t] += day.spend[t]
118 return spend
119}
120
121export function describeSessions(days: DayStats[]): string {
122 const merged: Record<string, SessionSpend> = {}
123 for (const day of days) {
124 for (const [id, s] of Object.entries(day.sessions)) {
125 const into = merged[id] ?? { label: '', spend: emptyCounts() }
126 for (const t of TIERS) into.spend[t] += s.spend[t]
127 if (into.label === '') into.label = s.label
128 merged[id] = into
129 }
130 }
131 const rows = Object.entries(merged)
132 .sort(([, a], [, b]) => totalOf(b.spend) - totalOf(a.spend))
133 .slice(0, SESSIONS_SHOWN)
134 if (rows.length === 0) return 'Model router: no session spend recorded yet.'
135 const lines = rows.map(([id, s]) => {
136 const label = s.label ? ` “${s.label}”` : ''
137 return ` ${shortSession(id).padEnd(SESSION_ID_LENGTH)} ${money(totalOf(s.spend)).padStart(8)} ${describeSpend(s.spend)}${label}`
138 })
139 return ['Sessions, last 7 days (estimated at API list prices, most expensive first):', ...lines].join('\n')
140}
141types/index.d.ts 52 lines1export type Tier = 'haiku' | 'sonnet' | 'opus' | 'fable'
2
3export type CacheMark = { at: number; context: number }
4
5export type TurnPlan = {
6 id: string
7 home: Tier
8 tier: Tier
9 steps: number
10 output: number
11 failures: number
12 startContext: number
13}
14
15export type DecisionEntry = {
16 at: number
17 session?: string
18 scope: 'main' | 'subagent'
19 from: Tier
20 to: Tier
21 reason: string
22 isApplied: boolean
23 prompt: string
24 stayCost?: number
25 switchCost?: number
26}
27
28export type AgentRoute = { tier: Tier; baseline: Tier }
29
30export type RouterSession = {
31 pin: Tier | null
32 boost: { tier: Tier; turnsLeft: number } | null
33 lastTier: Tier | null
34 lastContext: number
35 tierCache: Partial<Record<Tier, CacheMark>>
36 stepsEma: number
37 outputEma: number
38 growthEma: number
39 pendingOverride: Tier | null
40 pendingText: { turnId: string; text: string } | null
41 turn: TurnPlan | null
42 agents: Record<string, AgentRoute>
43 last: DecisionEntry | null
44 label: { session: string; text: string } | null
45}
46
47declare module 'claude-code' {
48 interface PluginState {
49 'model-router': { session: RouterSession }
50 }
51}
52