SLOPSHOPPER

model-router

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

guardcommandtoaststatuspromptmodel
v0.2.0no licenseupdated 2026-10-21schwepps/claude-mods/model-picker-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · model-router
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /route ⎿ model-router: Model router: main loop auto, subagents auto, fable escalation, floor haiku ⎿ model-router: Home model: not seen yet · last turn on: - · pin: none · escalation: none ⎿ model-router: Prices ($/MTok input/output/cache read, built-in, as of 2026-10-06, -362 days old): haiku 0.1/0.5/0.01 · sonne ⎿ model-router: Per prompt: start it with ::haiku, ::sonnet, ::opus or ::fable. Sent alone, ::opus pins and ::auto unpins. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ model-router: ⇢ router
README

model-router

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.

How it decides

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.

  1. Subagents (the biggest saving). A subagent starts with an empty context, so changing its model has no prompt-cache cost.
  2. Explore, claude-code-guide and statusline-setup run on Haiku.
  3. Plan and security reviewers run on Opus. Other reviewers run on Sonnet.
  4. general-purpose agents are sized from their task.
  5. Forks, agents that already name a model, and other plugins' agents are left alone.
  6. Main loop: cheaper models only when it pays. Prompt caches belong to one model. On a long conversation, switching models means re-writing the whole context into the new model's cache. A cost model compares two options over the next few turns: staying, or switching and later coming back. It switches only when switching is cheaper.
  7. Opus → Haiku usually pays for a trivial turn, such as git status, a rename or a lookup.
  8. Opus → Sonnet rarely pays mid-session. Opus 5.5 and Sonnet 5.5 cache reads cost the same, so only output tokens get cheaper. It does pay at the start of a session or right after compaction.
  9. Quality is asymmetric. Moving to a stronger model needs no cost check. Moving to a cheaper one needs a confident classification and a passing cost check.
  10. Unclear prompts stay on home.
  11. Follow-ups ("yes", "go ahead", "2") stay on the current model.
  12. Escalation.
  13. "Still failing", "that's wrong" and similar replies undo a downgrade. If you were already on home, they move up one tier for three turns. Fable is the exception: with the default fableMode, it takes a second rejection while the first is still held.
  14. "No regression" and "not a regression" are not rejections.
  15. Three failed tool calls in a row on a downgraded model return the rest of the turn to home.
  16. Classification. Only the words you typed are classified. Blocks the app adds (system reminders, messages from other sessions, CI and task notices, subagent reports) are dropped first; a turn made only of them stays on the current model. For a slash command, only the text after the command counts. Keyword rules run first. When the rules are unsure, and only when the answer could change the decision, the router asks Haiku 5.5 (about $0.0003 per call).

If any part of the router fails, the turn runs on your /model unchanged.

Use

InputEffect
::haiku fix the typo in READMEOne 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.
/routeStatus, last decision with its cost estimate, today's and this week's savings and spend by model.
/route logThe last 20 routing decisions, each with its session.
/route sessionsThe 10 most expensive sessions of the last 7 days, with spend by model.
/route sonnet · /route autoPin or unpin.

The status line shows the current tier, the reason for it, and today's estimated savings.

Settings

Change these in the config menu, or under pluginConfigs.model-router in settings.

OptionDefault
mainLoopautoshadow logs decisions but changes nothing; off disables main-loop routing
subagentsautooff leaves subagents alone
fableModeescalationescalation: ::fable, or a second rejection in a row; manual: only ::fable; auto: very hard prompts and the first rejection too
minTierhaikuFloor: the router never picks a model below this
classifiertrueAsk Haiku about unclear prompts
haikuEffortlowEffort 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
*Modelclaude-*-5-5, claude-fable-5-1Model ids per tier (change them for Bedrock or Vertex)
cacheTtlMinutes / cacheWriteMultiplier60 / 2Cache assumptions: 1-hour cache. Use 5 / 1.25 for the 5-minute cache
horizonTurns3How many turns a downgrade is assumed to last
pricesempty (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
pricesAsOfemptyYYYY-MM-DD date of your prices. /route and the session-start toast warn once prices are over 90 days old

When new models or prices arrive

  • New version of a family (Opus 6, Haiku 6): set the *Model id. Ids are matched by family name, so nothing else changes.
  • New prices: set 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.
  • Claude Code learns Haiku 5.5: raise or remove HAIKU_MAIN_LOOP_MAX_CONTEXT in hooks/policy.ts.
  • A new family or tier: code change (tiers, prices, settings).
  • Then run claude plugin test ., a few lab scenarios, and /reload-plugins.

Install for every session

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>.

Develop and test

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.

Limits

  • Savings are estimates at API list prices. The baseline assumes the home model's cache would have been warm. On a subscription, read them as relative usage, not dollars.
  • Subagent savings are counted only for agent types that would otherwise inherit your model. Explore may already default to a small model.
  • This Claude Code build does not know Haiku 5.5 yet. Its own /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.
  • Pins and held escalations live in the session process; claude --resume starts without them.
  • Effort is changed only on Haiku detours (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.
  • Going to a cheaper model drops the stronger model's earlier reasoning blocks for that turn only. They come back when the conversation returns to that model.
Source 4 files
hooks/register.ts 556 lines
1import { 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}
556
hooks/policy.ts 437 lines
1import 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}
437
hooks/stats.ts 141 lines
1import 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}
141
types/index.d.ts 52 lines
1export 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