Routes each prompt, and each part of a multi-part prompt, to Haiku, Sonnet or Opus by complexity to save credits and time

A Claude Code mod that picks the model for every prompt by how hard the prompt is, so questions run on Haiku, focused code changes on Sonnet, and big builds on Opus. When one message holds several jobs, such as a question followed by a build, it splits them and runs each part on the model it needs.
What's the difference between Zustand and Redux? Then refactor my store to use Zustand.
part 1 question → Haiku (cheap, fast)
part 2 refactor → Sonnet (starts automatically when part 1 finishes)
You keep working as normal. The mod switches the model for you, shows which one it picked in the status line, and keeps a running estimate of what it saved.
You need Claude Code 2.1.295 or newer. The mod API is early access and may change between releases.
Put this folder in a GitHub repository, then type this at the prompt of a terminal Claude Code session:
/plugin install model-router --marketplace Suciones/model-router
Answer y to "Add marketplace?", pick the user scope with Enter, and set the options on the next screen (the defaults are fine). You'll see Installed model-router. Plugin is now active. and it runs in that session and every later one.
/plugin install is typed into a terminal session. The app's Code tab does not offer it, but a mod installed at the user scope from a terminal also loads in the app.
If you were sent the folder or the zip, unzip it and start Claude Code with:
claude --plugin-dir /path/to/model-router
Use the real path to the unzipped model-router folder. This loads the mod for that session only.
The status line shows ⇄ router on. Type /router and you should get a status summary.
⇄ Haiku · question or ⇄ Opus · build./router any time to see how much it has saved.!opus, !sonnet or !haiku to force a model for that one prompt.What does git rebase do?
The status line reads ⇄ Haiku · question. Haiku answers quickly at low effort, for a small fraction of what Opus would cost.
Rename getUser to fetchUser across api.ts, db.ts and routes.ts
This is an edit that touches a few files: ⇄ Sonnet · edit, medium effort.
Build a full-stack SaaS dashboard with auth, billing and real-time charts
Words like "full-stack", "auth" and "real-time" make the prompt large and hard: ⇄ Opus · build, high effort.
What's the difference between SQLite and Postgres? Then build me a todo app with the better one.
A notice appears: Router split this prompt: 1) Haiku · question 2) Sonnet · build. Then:
The follow-up shows in the transcript as a message sent by the model-router plugin. That is expected.
A long description of a single thing to build stays one prompt on one model. The mod only splits when the parts are different kinds of work (asking versus doing) and at least one part is cheaper than the rest. Splitting a build in the middle would lose context and save nothing.
/router explain Fix the typo in the README. Then explain how our caching layer works.
This prints how the prompt would be split and routed, using the local rules only. Nothing is sent to a model.
| You type | Result |
|---|---|
!haiku what time zone is Tokyo in | Haiku, whatever the wording |
!sonnet ... | Sonnet |
!opus ... | Opus |
!fable ... (or !max) | The max tier model, off by default |
!nosplit <text> | Keep the prompt whole, on its hardest part's model |
The prefix is removed before Claude sees the prompt. Prefixes can be combined: !opus !nosplit ....
/router
Model router: on, multi-part prompts split, classifier hybrid
Tiers: fast → claude-haiku-5-5, balanced → claude-sonnet-5-5, deep → claude-opus-5-5, ...
This session:
14 prompts routed, 3 split, 61 model requests
cost ≈ $0.41 vs ≈ $1.87 on the session model: saved ≈ $1.46 (78%)
The comparison is against what the same requests would have cost on whatever model your session was using.
The mod hooks six points of a Claude Code session.
you type a prompt
│
▼
prompt.submit ──► 1. strip !overrides
2. split into pieces (list items, paragraphs, sentences, "..., then ...")
3. classify each piece
4. merge neighbours of the same kind (asking vs doing)
5. if parts differ in difficulty: queue parts 2..n, tell Claude to do only part 1
│
▼
turn.start ─────► remember the plan for this turn
│
▼
turn.step ──────► for every model request in the turn:
(each request) swap in the planned model, set effort,
choose to stay put if the cache makes that cheaper,
record real token usage and cost,
move the turn up a tier if it is struggling
│
▼
turn.complete ──► if parts are queued, send the next one as its own turn
Each piece of a prompt gets a category and a score.
Category comes from wording:
| Category | Looks like | Base score |
|---|---|---|
| chat | "thanks!", "hello" | 0 |
| follow-up | "yes", "go ahead", "continue" (inherits the previous tier) | 0 |
| question | "what", "why", "how", "explain" | 1 |
| lookup | "find", "where is", "list", "summarize" | 1.5 |
| edit | "rename", "update", "fix the typo", "add a comment" | 2.5 |
| debug | "bug", "error", "doesn't work", stack traces | 3.5 |
| review | "review", "refactor", "optimize", "audit" | 4 |
| build | "build", "implement", "create" plus an app, feature, API... | 4.5 |
| design | "architecture", "migrate", "trade-offs", "from scratch" | 6.5 |
Adjustments then move the score:
Tier comes from the final score:
| Score | Tier | Default model | Effort |
|---|---|---|---|
| under 2 | fast | claude-haiku-5-5 | low |
| 2 to under 5.5 | balanced | claude-sonnet-5-5 | medium |
| 5.5 and up | deep | claude-opus-5-5 | high |
| (override only) | max | claude-fable-5-1 | high |
Questions, lookups and chat are capped below the deep tier by wording alone. A forced ! prefix is the only way to reach max.
When the rules land close to a boundary (low confidence) and the classifier setting is hybrid, the mod asks Haiku to pick between three descriptions. That call is tiny, costing a fraction of a cent, and only happens for uncertain pieces. With heuristic, nothing leaves your machine except the prompt itself.
A prompt is split only when all of these hold:
Multi-part prompts is sequential!nosplit or a forced modelPart 1 runs inside your normal turn, with a hidden note telling Claude to handle only part 1 and listing the rest. When the turn ends cleanly, the mod sends part 2, and so on. If the turn is interrupted or errors, the remaining parts are dropped rather than run unattended. If you type a new prompt while parts are queued, the queued parts are dropped and you get a notice.
A cheap model can be wrong about being cheap. During a turn, the mod moves it up one tier for its remaining requests when:
Every model keeps its own prompt cache of the conversation, valid for about 5 minutes. Switching to a different model re-reads the whole conversation uncached, which on a long conversation can cost more than the cheaper model saves. Before a downward switch the mod estimates both costs and stays on the current model when staying is cheaper, shown as (cache kept) in the status line. Upward switches always happen. Turn this off with the cacheAware setting.
/…) are not routed.Set during install, or later in the plugin config menu. Defaults in bold.
| Setting | Values | What it does |
|---|---|---|
fastModel | claude-haiku-5-5 | Model for the fast tier |
balancedModel | claude-sonnet-5-5 | Model for the balanced tier |
deepModel | claude-opus-5-5 | Model for the deep tier |
maxModel | claude-fable-5-1 | Used only with !max or !fable |
classifier | hybrid, heuristic | hybrid asks Haiku about uncertain pieces |
split | sequential, off | Run multi-part prompts part by part, or whole |
setEffort | true, false | Set low, medium or high effort per tier |
escalate | true, false | Allow mid-turn tier upgrades |
cacheAware | true, false | Stay on a warm cache instead of switching down |
Model names can be changed to any model your account can use, for example to pin older model versions or to put Sonnet in the deep tier if you want to save more.
| Command | Does |
|---|---|
/router | Status, tiers, last tier, savings this session and across sessions |
/router on / off | Turn routing on or off (off: every request uses your session model) |
/router split on / off | Split multi-part prompts, or keep them whole |
/router explain <prompt> | Show how a prompt would be classified and split, sending nothing |
/router reset | Clear the savings counters |
/router help | List of the above |
on, off and split are remembered across sessions. The prefixes !haiku, !sonnet, !opus, !fable, !max, !fast, !balanced, !deep, !nosplit and !whole work at the start of a prompt.
! prefixes are the safety nets. Check /router explain on your own typical prompts and tune./router off returns control to you.Everything lives in three files under hooks/:
classify.ts holds the wording rules, base scores, tier thresholds (FAST_BELOW, DEEP_FROM) and the splitting logic. To change what counts as "hard", edit the regular expressions and the BASE table at the top.cost.ts holds the price table. Update it when prices change.register.ts holds the hooks, the /router command, escalation limits and the effort mapping.After editing, run the checks:
claude plugin validate /path/to/model-router
claude plugin test /path/to/model-router
tests/router.test.ts covers the classifier and the end-to-end routing. Add a case for every misroute you find.
| Symptom | Cause and fix |
|---|---|
No ⇄ in the status line | The mod isn't loaded. Check /plugin or restart with --plugin-dir |
| Everything goes to one model | /router says off, or you used a ! prefix, or the cache-aware rule is keeping the model. Try cacheAware off |
| A part never ran | The earlier turn was interrupted, or you typed a new prompt, so the queue was cleared |
| "model not found" or access error | A configured model isn't available on your account. Change that tier's model in settings |
| Wrong tier for a certain kind of prompt | Run /router explain on it and adjust the rules in classify.ts |
/plugin install says the command is not available | You're in the desktop app's Code tab. Run it from a terminal session instead |
hooks/register.ts 450 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register, TurnStepResult } from 'claude-code'
3
4import type { QueuedPart, RouterSettings, RouterStats, Tier, TurnRoute } from '../types'
5import { maxTier, plan, rank } from './classify'
6import type { PartPlan, Plan } from './classify'
7import { chooseSwitch, costOf, family, formatUsd, promptTokens } from './cost'
8
9const NAME = 'model-router'
10
11const EMPTY_STATS: RouterStats = { prompts: 0, splits: 0, steps: 0, cost: 0, baseline: 0, byModel: {} }
12
13const settings = atom({ plugin: 'model-router', key: 'settings' } as const, {
14 isEnabled: true,
15 split: 'sequential',
16})
17const turns = atom({ plugin: 'model-router', key: 'turns' } as const, {})
18const pending = atom({ plugin: 'model-router', key: 'pending' } as const, null)
19const queue = atom({ plugin: 'model-router', key: 'queue' } as const, [])
20const cache = atom({ plugin: 'model-router', key: 'cache' } as const, { contextTokens: 0 })
21const lastTier = atom({ plugin: 'model-router', key: 'lastTier' } as const, null)
22const stats = atom({ plugin: 'model-router', key: 'stats' } as const, EMPTY_STATS)
23
24const EFFORT: Record<Tier, 'low' | 'medium' | 'high'> = {
25 fast: 'low',
26 balanced: 'medium',
27 deep: 'high',
28 max: 'high',
29}
30
31// A rough per-request output size, for the cache-aware switch estimate
32const EXPECTED_OUTPUT: Record<Tier, number> = { fast: 400, balanced: 1500, deep: 3000, max: 4000 }
33
34const EDITING_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
35
36const CLASSIFIER_LABELS: Record<'fast' | 'balanced' | 'deep', string> = {
37 fast: 'simple: a question, explanation, lookup or small talk a small fast model answers well',
38 balanced: 'moderate: a focused code change, bug fix or small feature in a few files',
39 deep: 'complex: a large build, multi-file feature, hard debugging, or architecture and design work',
40}
41
42type Config = {
43 models: Record<Tier, string>
44 classifier: 'hybrid' | 'heuristic'
45 split: RouterSettings['split']
46 setEffort: boolean
47 escalate: boolean
48 cacheAware: boolean
49}
50
51function configOf(options: PluginOptions): Config {
52 const str = (key: string, fallback: string) => {
53 const v = options[key]
54 return typeof v === 'string' && v.trim() ? v.trim() : fallback
55 }
56 const bool = (key: string, fallback: boolean) => {
57 const v = options[key]
58 return typeof v === 'boolean' ? v : fallback
59 }
60 return {
61 models: {
62 fast: str('fastModel', 'claude-haiku-5-5'),
63 balanced: str('balancedModel', 'claude-sonnet-5-5'),
64 deep: str('deepModel', 'claude-opus-5-5'),
65 max: str('maxModel', 'claude-fable-5-1'),
66 },
67 classifier: str('classifier', 'hybrid') === 'heuristic' ? 'heuristic' : 'hybrid',
68 split: str('split', 'sequential') === 'off' ? 'off' : 'sequential',
69 setEffort: bool('setEffort', true),
70 escalate: bool('escalate', true),
71 cacheAware: bool('cacheAware', true),
72 }
73}
74
75function label(model: string): string {
76 const f = family(model)
77 return f.charAt(0).toUpperCase() + f.slice(1)
78}
79
80function routeOf(part: PartPlan | QueuedPart, index?: number, total?: number): TurnRoute {
81 return {
82 tier: part.tier,
83 category: part.category,
84 reasons: part.reasons,
85 ...(index !== undefined && total !== undefined && total > 1 ? { part: { index, total } } : {}),
86 }
87}
88
89function clip(text: string, max = 160): string {
90 const one = text.replace(/\s+/g, ' ').trim()
91 return one.length > max ? `${one.slice(0, max - 1)}…` : one
92}
93
94function nextTier(tier: Tier): Tier {
95 return tier === 'fast' ? 'balanced' : 'deep'
96}
97
98// Why a turn should move up a tier after this step, if it should.
99function escalation(route: TurnRoute, index: number, result: TurnStepResult): string | undefined {
100 if (rank(route.tier) >= rank('deep')) return undefined
101 if (result.stopReason === 'max_tokens') return 'ran out of output tokens'
102 if (route.tier === 'fast' && result.toolUses.some(use => EDITING_TOOLS.has(use.name))) {
103 return 'started editing files'
104 }
105 if (route.tier === 'fast' && index >= 5) return 'took more than 5 steps'
106 if (route.tier === 'balanced' && index >= 14) return 'took more than 14 steps'
107 return undefined
108}
109
110// Asks the small model about the parts the rules were unsure of.
111async function refine($: EngineInterface, p: Plan, model: string): Promise<Plan> {
112 const parts = await Promise.all(
113 p.parts.map(async part => {
114 if (part.confidence >= 0.34 || part.category === 'follow-up') return part
115 const labels = Object.values(CLASSIFIER_LABELS)
116 const answer = await $.model
117 .classify(`Classify how hard this coding-assistant request is:\n\n${part.text.slice(0, 4000)}`, labels, {
118 model,
119 })
120 .catch(() => undefined)
121 const tier = (Object.keys(CLASSIFIER_LABELS) as Array<keyof typeof CLASSIFIER_LABELS>).find(
122 t => CLASSIFIER_LABELS[t] === answer,
123 )
124 if (!tier) return part
125 return { ...part, tier, confidence: 1, reasons: [...part.reasons, `Haiku classifier said ${tier}`] }
126 }),
127 )
128 const tier = parts.reduce<Tier>((t, part) => maxTier(t, part.tier), 'fast')
129 return { ...p, parts, tier }
130}
131
132async function addStats($: EngineInterface, change: (s: RouterStats) => RouterStats): Promise<void> {
133 await update($, stats, change)
134 const lifetime = ((await $.store.get('lifetime')) as RouterStats | undefined) ?? EMPTY_STATS
135 await $.store.set('lifetime', change(lifetime))
136}
137
138function describeStats(s: RouterStats): string[] {
139 const saved = s.baseline - s.cost
140 const lines = [
141 `${s.prompts} prompts routed, ${s.splits} split, ${s.steps} model requests`,
142 `cost ≈ ${formatUsd(s.cost)} vs ≈ ${formatUsd(s.baseline)} on the session model: saved ≈ ${formatUsd(saved)}${
143 s.baseline > 0 ? ` (${Math.round((saved / s.baseline) * 100)}%)` : ''
144 }`,
145 ]
146 for (const [model, tally] of Object.entries(s.byModel)) {
147 lines.push(` ${model}: ${tally.steps} requests, ≈ ${formatUsd(tally.cost)}`)
148 }
149 return lines
150}
151
152function describePlan(p: Plan, config: Config): string[] {
153 const lines: string[] = []
154 if (p.parts.length > 1) lines.push(`Split into ${p.parts.length} turns:`)
155 p.parts.forEach((part, i) => {
156 const model = config.models[part.tier]
157 const head = p.parts.length > 1 ? `${i + 1}. ` : ''
158 lines.push(`${head}${label(model)} (${model}, ${part.tier}) · ${part.category} · score ${part.score}`)
159 lines.push(` ${part.reasons.join('; ')}`)
160 if (p.parts.length > 1) lines.push(` "${clip(part.text, 100)}"`)
161 })
162 return lines
163}
164
165export const register: Register = (on, options) => {
166 const config = configOf(options)
167
168 on('session.start', async ($, e, next) => {
169 await $.command.register({
170 name: 'router',
171 description: 'Model router: status, on, off, split on|off, explain <prompt>, reset',
172 argumentHint: '[status|on|off|split on|off|explain <prompt>|reset]',
173 })
174 const saved = (await $.store.get('settings')) as RouterSettings | undefined
175 await update($, settings, () => saved ?? { isEnabled: true, split: config.split })
176 $.ui.status(saved?.isEnabled === false ? '⇄ router off' : '⇄ router on')
177
178 return next(e)
179 })
180
181 on('command.run', { command: 'router' }, async ($, e) => {
182 const [verb = 'status', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
183 const current = await read($, settings)
184 const save = async (s: RouterSettings) => {
185 await update($, settings, () => s)
186 await $.store.set('settings', s)
187 $.ui.status(s.isEnabled ? '⇄ router on' : '⇄ router off')
188 }
189
190 switch (verb.toLowerCase()) {
191 case 'on':
192 await save({ ...current, isEnabled: true })
193 return { text: 'Model router on: prompts are routed by complexity.' }
194 case 'off':
195 await save({ ...current, isEnabled: false })
196 await update($, queue, () => [])
197 return { text: 'Model router off: every request uses the session model.' }
198 case 'split': {
199 const split = rest[0] === 'off' ? 'off' : 'sequential'
200 await save({ ...current, split })
201 return {
202 text:
203 split === 'off'
204 ? 'Multi-part prompts now run in one turn on the model their hardest part needs.'
205 : 'Multi-part prompts now run part by part, each on its own model.',
206 }
207 }
208 case 'explain': {
209 const text = e.args.trim().slice('explain'.length).trim()
210 if (!text) return { text: 'Usage: /router explain <a prompt to classify>' }
211 const p = plan(text, { inherited: (await read($, lastTier)) ?? undefined })
212 return { text: ['Dry run (rules only, nothing sent):', ...describePlan(p, config)].join('\n') }
213 }
214 case 'reset':
215 await update($, stats, () => EMPTY_STATS)
216 await $.store.set('lifetime', EMPTY_STATS)
217 return { text: 'Router stats reset.' }
218 case 'help':
219 return {
220 text: [
221 '/router status and savings',
222 '/router on | off turn routing on or off',
223 '/router split on | off run multi-part prompts part by part, or whole',
224 '/router explain <text> show how a prompt would be routed',
225 '/router reset clear the savings counters',
226 'Prompt prefixes: !haiku !sonnet !opus !fable force a model for one prompt; !nosplit keeps it whole.',
227 ].join('\n'),
228 }
229 default: {
230 const s = await read($, stats)
231 const lifetime = ((await $.store.get('lifetime')) as RouterStats | undefined) ?? EMPTY_STATS
232 const last = await read($, lastTier)
233 return {
234 text: [
235 `Model router: ${current.isEnabled ? 'on' : 'off'}, multi-part prompts ${
236 current.split === 'sequential' ? 'split' : 'kept whole'
237 }, classifier ${config.classifier}`,
238 `Tiers: fast → ${config.models.fast}, balanced → ${config.models.balanced}, deep → ${config.models.deep}, max (!max) → ${config.models.max}`,
239 `Last tier: ${last ?? 'none yet'}`,
240 'This session:',
241 ...describeStats(s).map(l => ` ${l}`),
242 'All sessions:',
243 ...describeStats(lifetime).map(l => ` ${l}`),
244 'Costs are estimates from first-party API prices; /router help for commands.',
245 ].join('\n'),
246 }
247 }
248 }
249 })
250
251 on('prompt.submit', async ($, e, next) => {
252 const current = await read($, settings)
253 if (!current.isEnabled) return next(e)
254
255 // A part this plugin queued earlier: route it as planned
256 if (e.origin.kind === 'plugin' && e.origin.name === NAME) {
257 const [head, ...rest] = await read($, queue)
258 if (head) {
259 await update($, queue, () => rest)
260 await update($, pending, () => routeOf(head, head.index, head.total))
261 }
262 return next(e)
263 }
264
265 const isPerson = e.origin.kind === 'composer' || e.origin.kind === 'bridge' || e.origin.kind === 'sdk'
266 if (!isPerson || e.text.trimStart().startsWith('/')) return next(e)
267
268 // A new prompt from the person replaces any parts still waiting
269 const waiting = (await read($, queue)).length
270 if (waiting > 0) {
271 await update($, queue, () => [])
272 $.ui.toast(`Router: dropped ${waiting} queued part${waiting === 1 ? '' : 's'} for your new prompt`)
273 }
274
275 let p = plan(e.text, { inherited: (await read($, lastTier)) ?? undefined })
276 if (config.classifier === 'hybrid' && !p.forced) p = await refine($, p, config.models.fast)
277 const text = p.text.trim() ? p.text : e.text
278
279 // Typed into a running turn: raise that turn's tier if this asks for more
280 if (e.turnId) {
281 const turnId = e.turnId
282 await update($, turns, all => {
283 const route = all[turnId]
284 if (!route || rank(route.tier) >= rank(p.tier)) return all
285 return { ...all, [turnId]: { ...route, tier: p.tier, escalated: 'a message typed mid-turn asked for more' } }
286 })
287 return next({ ...e, text })
288 }
289
290 await addStats($, s => ({ ...s, prompts: s.prompts + 1 }))
291 const [first, ...rest] = p.parts
292 const shouldSplit = current.split === 'sequential' && !p.noSplit && first !== undefined && rest.length > 0
293
294 if (!shouldSplit || !first) {
295 const lead = first ?? p.parts[0]
296 await update($, pending, () => ({
297 tier: p.tier,
298 category: lead?.category ?? 'context',
299 reasons: lead?.reasons ?? [],
300 }))
301 const result = await next({ ...e, text })
302 if (result.drop !== undefined) await update($, pending, () => null)
303 return result
304 }
305
306 const total = p.parts.length
307 await update($, queue, () =>
308 rest.map((part, i) => ({
309 text: part.text,
310 tier: part.tier,
311 category: part.category,
312 reasons: part.reasons,
313 index: i + 2,
314 total,
315 })),
316 )
317 await update($, pending, () => routeOf(first, 1, total))
318 await addStats($, s => ({ ...s, splits: s.splits + 1 }))
319 $.ui.toast(
320 `Router split this prompt: ${p.parts
321 .map((part, i) => `${i + 1}) ${label(config.models[part.tier])} · ${part.category}`)
322 .join(' ')}`,
323 )
324
325 const instruction = [
326 `[model-router] The user's message has ${total} parts, and each runs as its own turn on the model it needs.`,
327 `In this turn handle ONLY part 1: "${first.text}"`,
328 'Do not start the later parts; they arrive as follow-up messages right after this turn:',
329 ...rest.map((part, i) => `- part ${i + 2}: "${clip(part.text, 300)}"`),
330 ].join('\n')
331 const result = await next({ ...e, text, context: [...(e.context ?? []), instruction] })
332 if (result.drop !== undefined) {
333 await update($, pending, () => null)
334 await update($, queue, () => [])
335 }
336 return result
337 }).catch(($, e, next) => next(e)) // never hold a prompt back over a router fault
338
339 on('turn.start', async ($, e, next) => {
340 const route = await read($, pending)
341 if (route) {
342 await update($, pending, () => null)
343 await update($, turns, all => {
344 // keep the map small: a turn's entry is removed at its end anyway
345 const kept = Object.entries(all).slice(-20)
346 return { ...Object.fromEntries(kept), [e.turnId]: route }
347 })
348 }
349 return next(e)
350 })
351
352 on('turn.step', async function* ($, e, next) {
353 if (e.agentId) return yield* next(e)
354 const current = await read($, settings)
355 const route = (await read($, turns))[e.turnId]
356 if (!current.isEnabled || !route) return yield* next(e)
357
358 const target = config.models[route.tier]
359 const before = await read($, cache)
360 const choice = config.cacheAware
361 ? chooseSwitch({
362 current: before.model,
363 target,
364 contextTokens: before.contextTokens,
365 expectedOutput: EXPECTED_OUTPUT[route.tier],
366 lastAt: before.at,
367 now: await $.clock.now(),
368 })
369 : { model: target }
370
371 const part = route.part ? ` · part ${route.part.index}/${route.part.total}` : ''
372 const kept = choice.keptBecause ? ' (cache kept)' : ''
373 $.ui.status(`⇄ ${label(choice.model)} · ${route.category}${part}${kept}`)
374
375 const step = config.setEffort ? { ...e, model: choice.model, effort: EFFORT[route.tier] } : { ...e, model: choice.model }
376 const result = yield* next(step)
377
378 if (result.usage) {
379 const usage = result.usage
380 const answered = usage.model || choice.model
381 const cost = costOf(answered, usage)
382 const baseline = costOf(e.model, usage)
383 await addStats($, s => {
384 const tally = s.byModel[answered] ?? { steps: 0, cost: 0 }
385 return {
386 ...s,
387 steps: s.steps + 1,
388 cost: s.cost + cost,
389 baseline: s.baseline + baseline,
390 byModel: { ...s.byModel, [answered]: { steps: tally.steps + 1, cost: tally.cost + cost } },
391 }
392 })
393 const at = await $.clock.now()
394 await update($, cache, () => ({
395 model: choice.model,
396 at,
397 contextTokens: promptTokens(usage) + usage.output_tokens,
398 }))
399 }
400
401 if (config.escalate) {
402 const why = escalation(route, e.index, result)
403 if (why) {
404 const tier = nextTier(route.tier)
405 await update($, turns, all => {
406 const r = all[e.turnId]
407 return r ? { ...all, [e.turnId]: { ...r, tier, escalated: why } } : all
408 })
409 $.ui.toast(`Router: ${label(choice.model)} ${why}, moving this turn to ${label(config.models[tier])}`)
410 }
411 }
412 return result
413 })
414
415 on('turn.complete', async ($, e, next) => {
416 const result = await next(e)
417 if (e.agentId) return result
418
419 const route = (await read($, turns))[e.turnId]
420 if (route) {
421 await update($, lastTier, () => route.tier)
422 await update($, turns, all => {
423 const { [e.turnId]: _, ...rest } = all
424 return rest
425 })
426 }
427
428 const waiting = await read($, queue)
429 const head = waiting[0]
430 if (head && e.reason !== 'answer') {
431 await update($, queue, () => [])
432 $.ui.toast(`Router: the turn stopped, so ${waiting.length} remaining part${waiting.length === 1 ? ' was' : 's were'} not run`)
433 } else if (head) {
434 // after this turn has ended, so the part runs as a turn of its own
435 $.clock.after(0, () => {
436 void $.prompt
437 .submit({
438 text: `Continue with part ${head.index} of ${head.total} of the user's last message (the model router gave it its own turn): "${head.text}"`,
439 })
440 .catch(() => update($, queue, () => []))
441 })
442 } else {
443 const s = await read($, stats)
444 const saved = s.baseline - s.cost
445 $.ui.status(saved > 0 ? `⇄ router · saved ≈ ${formatUsd(saved)} this session` : '⇄ router on')
446 }
447 return result
448 })
449}
450hooks/classify.ts 374 lines1// Prompt classification and segmentation: pure functions, no `$`, so the
2// tests and the `/router explain` command can run them directly.
3
4import type { Category, Tier } from '../types'
5
6export type { Category, Tier }
7
8export const TIERS: readonly Tier[] = ['fast', 'balanced', 'deep', 'max']
9
10export type PartPlan = {
11 text: string
12 category: Category
13 tier: Tier
14 score: number
15 // 0..1: how far the score sits from the nearest tier boundary
16 confidence: number
17 reasons: string[]
18}
19
20export type Plan = {
21 parts: PartPlan[]
22 // the highest tier over all parts
23 tier: Tier
24 forced?: Tier
25 noSplit: boolean
26 // the prompt with any `!opus`-style override stripped
27 text: string
28}
29
30const BASE: Record<Category, number> = {
31 chat: 0,
32 question: 1,
33 lookup: 1.5,
34 edit: 2.5,
35 debug: 3.5,
36 review: 4,
37 build: 4.5,
38 design: 6.5,
39 'follow-up': 0,
40 context: 0,
41}
42
43// fast < 2 <= balanced < 5.5 <= deep; `max` is reached only by override
44const FAST_BELOW = 2
45const DEEP_FROM = 5.5
46
47const OVERRIDES: Record<string, Tier> = {
48 haiku: 'fast',
49 fast: 'fast',
50 cheap: 'fast',
51 sonnet: 'balanced',
52 balanced: 'balanced',
53 opus: 'deep',
54 deep: 'deep',
55 fable: 'max',
56 max: 'max',
57}
58
59const REQUEST_PREFIX =
60 /^(?:(?:can|could|would|will) you(?: please)?|please|pls|i (?:want|need|would like|'d like)(?: you)? to|help me(?: to)?|let'?s|go ahead and|try to)\s+/i
61
62const RE = {
63 chat: /^(?:hi|hello|hey|yo|thanks|thank you|thx|cool|nice|great|awesome|good (?:morning|night)|bye)\b/i,
64 followUp:
65 /^(?:yes|yep|yeah|yup|ok(?:ay)?|sure|go ahead|do it|continue|proceed|sounds good|lgtm|ship it|keep going|go on|go for it|approved?|that works|perfect|same (?:for|with)|now do|do the same|again|retry|try again)\b/i,
66 design:
67 /\b(?:architect\w*|system design|design (?:a|an|the) (?:system|architecture|schema|api|database)|trade-?offs?|migrat\w+|scalab\w+|from scratch|end-to-end|rewrite (?:the|it|everything)|overhaul|re-?architect\w*|plan (?:out|the) (?:architecture|system|migration))\b/i,
68 buildVerb:
69 /\b(?:build|implement|develop|scaffold|create|make|write|generate|set ?up|integrate|port|convert)\b/i,
70 buildObject:
71 /\b(?:app|application|feature|component|page|screen|api|endpoint|service|server|backend|frontend|function|class|module|script|mod|plugin|extension|website|site|landing|dashboard|tool|game|cli|bot|database|schema|test(?:s| suite)?|pipeline|workflow|integration|system|library|sdk|form|ui|crud)\b/i,
72 strongBuild: /\b(?:build|implement|develop|scaffold)\b/i,
73 edit: /\b(?:rename|change|update|tweak|adjust|replace|remove|delete|move|bump|fix (?:the |a )?typo|reformat|format|reword|translate|add (?:a |an |the )?(?:comment|log|line|field|button|link|import|prop|option|flag|column|test))\b/i,
74 debug:
75 /\bwhy (?:won'?t|can'?t|isn'?t|doesn'?t|don'?t|didn'?t|aren'?t|is (?:\w+ ){0,4}not|does (?:\w+ ){0,4}not)\b|\bwhy (?:does|is|do|are|did)\b.*\b(?:wrong|fail\w*|reject\w*|slow|hang\w*|break\w*|empty|null|undefined|crash\w*)\b|\b(?:bug|error|exception|crash\w*|fail\w*|broken|doesn'?t work|does not work|not working|stack ?trace|traceback|undefined is not|null pointer|segfault|regression|debug\w*|fix(?:es|ed|ing)?)\b/i,
76 review:
77 /\b(?:review|audit|analy[sz]e|optimi[sz]e|refactor\w*|security|performance|improve the|clean ?up)\b/i,
78 lookup:
79 /\b(?:find|search|where (?:is|are|do|does)|locate|list|show me|grep|which files?|summari[sz]e|read (?:the|this)|look up|check (?:if|whether|what))\b/i,
80 question:
81 /^(?:what|why|how|when|where|who|whom|which|is|are|am|was|were|can|could|should|does|do|did|will|would|has|have)\b|\?\s*$|\b(?:explain|difference between|meaning of|tell me|what'?s the|is there a|eli5)\b/i,
82 scope:
83 /\b(?:entire|whole|all (?:the )?files|across (?:the|all)|codebase|multiple|several|every|full(?:-stack| stack)?|complete|production(?:-ready)?|robust|scalable|end-to-end|from scratch|architecture|complex|comprehensive|thorough)\b/gi,
84 hard: /\b(?:concurren\w*|race condition|memory leak|deadlock|distributed|security|auth\w*|encrypt\w*|performance|algorithm\w*|optimi[sz]\w*|migration|state machine|real-?time|async\w*|multi-?thread\w*|caching|cache invalidation|consensus|compiler|parser)\b/gi,
85 simple:
86 /\b(?:simple|quick(?:ly)?|small|tiny|just|only|minor|brief(?:ly)?|short|one-?line(?:r)?|typo|trivial|basic)\b/gi,
87 file: /\b[\w./-]+\.(?:tsx?|jsx?|mjs|cjs|py|go|rs|rb|java|kt|swift|c|cc|cpp|h|hpp|cs|php|css|scss|html|md|json|ya?ml|toml|sql|sh)\b/gi,
88 codeFence: /```/,
89 stackTrace: /(?:^\s+at .+\(.+:\d+(?::\d+)?\)|Traceback \(most recent call last\)|^\w*(?:Error|Exception):)/m,
90 listItem: /^\s*(?:\d+[.)]|[-*•])\s+/,
91}
92
93function count(re: RegExp, text: string): number {
94 return (text.match(re) ?? []).length
95}
96
97function words(text: string): number {
98 return text.trim().split(/\s+/).filter(Boolean).length
99}
100
101export function tierOf(score: number): Tier {
102 if (score < FAST_BELOW) return 'fast'
103 if (score < DEEP_FROM) return 'balanced'
104 return 'deep'
105}
106
107export function maxTier(a: Tier, b: Tier): Tier {
108 return TIERS.indexOf(a) >= TIERS.indexOf(b) ? a : b
109}
110
111export function rank(tier: Tier): number {
112 return TIERS.indexOf(tier)
113}
114
115function categorize(raw: string): { category: Category; reason: string } {
116 const text = raw.trim()
117 const n = words(text)
118 const isRequest = REQUEST_PREFIX.test(text)
119 const body = text.replace(REQUEST_PREFIX, '')
120
121 if (n <= 12 && RE.followUp.test(text)) {
122 return { category: 'follow-up', reason: 'short follow-up: inherits the last tier' }
123 }
124 if (n <= 6 && RE.chat.test(text) && !RE.buildVerb.test(text)) {
125 return { category: 'chat', reason: 'small talk' }
126 }
127 if (RE.design.test(body)) {
128 return { category: 'design', reason: 'architecture / design / migration' }
129 }
130 const asksHow = !isRequest && /^(?:how|what|why|which|should)\b/i.test(body)
131 if (!asksHow && RE.buildVerb.test(body) && (RE.buildObject.test(body) || RE.strongBuild.test(body))) {
132 return { category: 'build', reason: 'build / implement request' }
133 }
134 if (/\bfix (?:the |a |this |that )?typos?\b/i.test(body)) {
135 return { category: 'edit', reason: 'small edit' }
136 }
137 if (RE.debug.test(body) || RE.stackTrace.test(raw)) {
138 return { category: 'debug', reason: 'debugging' }
139 }
140 if (!asksHow && RE.review.test(body)) {
141 return { category: 'review', reason: 'review / refactor / optimise' }
142 }
143 if (!asksHow && RE.edit.test(body)) {
144 return { category: 'edit', reason: 'small edit' }
145 }
146 if (RE.lookup.test(body)) {
147 return { category: 'lookup', reason: 'lookup / read-only' }
148 }
149 if (RE.question.test(text) || asksHow) {
150 return { category: 'question', reason: 'question' }
151 }
152 if (isRequest || RE.buildVerb.test(body)) {
153 return { category: 'edit', reason: 'generic request' }
154 }
155 return { category: 'context', reason: 'no task of its own' }
156}
157
158export function classifyPart(text: string, inherited?: Tier): PartPlan {
159 const { category, reason } = categorize(text)
160 const reasons = [reason]
161 let score = BASE[category]
162
163 const n = words(text)
164 if (n > 300) {
165 score += 2
166 reasons.push('very long prompt')
167 } else if (n > 120) {
168 score += 1.5
169 reasons.push('long prompt')
170 } else if (n > 40) {
171 score += 1
172 reasons.push('detailed prompt')
173 }
174
175 const scope = Math.min(2, count(RE.scope, text))
176 if (scope > 0) {
177 score += scope * 1.5
178 reasons.push('large scope')
179 }
180 const isAsking = category === 'question' || category === 'lookup' || category === 'chat'
181 // a hard topic makes work harder, not a question about it
182 const hard = isAsking ? 0 : Math.min(2, count(RE.hard, text))
183 if (hard > 0) {
184 score += hard
185 reasons.push('hard domain')
186 }
187 const simple = Math.min(2, count(RE.simple, text))
188 if (simple > 0) {
189 score -= simple
190 reasons.push('marked simple')
191 }
192 if (RE.codeFence.test(text)) {
193 score += 0.5
194 reasons.push('includes code')
195 }
196 if (RE.stackTrace.test(text)) {
197 score += 0.5
198 reasons.push('includes a stack trace')
199 }
200 const files = new Set((text.match(RE.file) ?? []).map(f => f.toLowerCase())).size
201 if (files >= 3) {
202 score += 1
203 reasons.push(`${files} files named`)
204 }
205 const steps = text.split('\n').filter(line => RE.listItem.test(line)).length
206 if (steps >= 3) {
207 score += 1
208 reasons.push(`${steps} listed steps`)
209 }
210
211 // The question-like categories never escalate past balanced on wording alone
212 if (isAsking) {
213 score = Math.min(score, DEEP_FROM - 0.5)
214 }
215 score = Math.max(0, Math.round(score * 10) / 10)
216
217 if (category === 'follow-up' && inherited) {
218 return { text, category, tier: inherited, score, confidence: 1, reasons }
219 }
220
221 const distance = Math.min(Math.abs(score - FAST_BELOW), Math.abs(score - DEEP_FROM))
222 const confidence = Math.min(1, distance / 1.5)
223 const isDefaulted = category === 'context' || reason === 'generic request'
224
225 return {
226 text,
227 category,
228 tier: tierOf(score),
229 score,
230 confidence: isDefaulted ? Math.min(confidence, 0.3) : confidence,
231 reasons,
232 }
233}
234
235const CONNECTOR =
236 /(?:[,;]\s*(?:and\s+)?|\s+and\s+)(?=(?:then|after that|afterwards|once (?:that'?s|it'?s|you'?re) done|finally)\b)/i
237
238// Splits a prompt into the pieces a person would number: list items,
239// paragraphs, sentences, and "..., then ..." clauses. Code fences stay whole.
240export function segment(text: string): string[] {
241 const fences: string[] = []
242 const masked = text.replace(/```[\s\S]*?(?:```|$)/g, block => {
243 fences.push(block)
244 return `\u0000${fences.length - 1}\u0000`
245 })
246 const restore = (s: string) => s.replace(/\u0000(\d+)\u0000/g, (_, i) => fences[Number(i)] ?? '')
247
248 const blocks: string[] = []
249 let paragraph: string[] = []
250 const flush = () => {
251 if (paragraph.length > 0) blocks.push(paragraph.join(' '))
252 paragraph = []
253 }
254 for (const line of masked.split('\n')) {
255 if (line.trim() === '') {
256 flush()
257 } else if (RE.listItem.test(line)) {
258 flush()
259 blocks.push(line.replace(RE.listItem, ''))
260 } else {
261 paragraph.push(line.trim())
262 }
263 }
264 flush()
265
266 const pieces: string[] = []
267 for (const block of blocks) {
268 for (const sentence of block.split(/(?<=[.?!])\s+(?=[A-Z\u0000])/)) {
269 for (const clause of sentence.split(CONNECTOR)) {
270 const cleaned = clause
271 .replace(/^(?:and\s+)?(?:then|after that|afterwards|finally)\b[,:]?\s*/i, '')
272 .trim()
273 if (cleaned) pieces.push(restore(cleaned))
274 }
275 }
276 }
277 return pieces
278}
279
280function parseOverride(text: string): { text: string; forced?: Tier; noSplit: boolean } {
281 let rest = text
282 let forced: Tier | undefined
283 let noSplit = false
284 for (;;) {
285 const m = rest.match(/^\s*!([a-z]+)\b\s*/i)
286 if (!m) break
287 const word = (m[1] ?? '').toLowerCase()
288 if (word === 'nosplit' || word === 'whole') {
289 noSplit = true
290 } else if (OVERRIDES[word]) {
291 forced = OVERRIDES[word]
292 } else {
293 break
294 }
295 rest = rest.slice(m[0].length)
296 }
297 return { text: rest, forced, noSplit }
298}
299
300export type PlanOptions = {
301 inherited?: Tier
302 maxParts?: number
303}
304
305export function plan(input: string, options: PlanOptions = {}): Plan {
306 const { text, forced, noSplit } = parseOverride(input)
307 const maxParts = options.maxParts ?? 4
308
309 if (forced) {
310 const part: PartPlan = {
311 text,
312 category: 'context',
313 tier: forced,
314 score: 0,
315 confidence: 1,
316 reasons: [`forced with !${forced}`],
317 }
318 return { parts: [part], tier: forced, forced, noSplit: true, text }
319 }
320
321 const pieces = segment(text).map(piece => classifyPart(piece, options.inherited))
322
323 // Context sentences ("I have a React app.") join the piece after them; a
324 // trailing one joins the piece before it.
325 const attached: PartPlan[] = []
326 let carry: string[] = []
327 for (const piece of pieces) {
328 if (piece.category === 'context') {
329 carry.push(piece.text)
330 continue
331 }
332 attached.push(carry.length > 0 ? { ...piece, text: [...carry, piece.text].join(' ') } : piece)
333 carry = []
334 }
335 const last = attached[attached.length - 1]
336 if (carry.length > 0 && last) {
337 attached[attached.length - 1] = { ...last, text: [last.text, ...carry].join(' ') }
338 }
339
340 // Neighbouring pieces of one kind (asking, or doing) are one task: a build
341 // described over five sentences is one part, scored as a whole.
342 const groups: PartPlan[][] = []
343 for (const piece of attached) {
344 const group = groups[groups.length - 1]
345 const head = group?.[0]
346 if (group && head && kindOf(head) === kindOf(piece)) group.push(piece)
347 else groups.push([piece])
348 }
349 const parts = groups.map(group => {
350 if (group.length === 1 && group[0]) return group[0]
351 const whole = classifyPart(group.map(p => p.text).join('\n'), options.inherited)
352 const top = group.reduce((a, b) => (rank(b.tier) > rank(a.tier) ? b : a))
353 const tier = maxTier(whole.tier, top.tier)
354 return { ...whole, tier, category: rank(top.tier) >= rank(whole.tier) ? top.category : whole.category }
355 })
356
357 const top = parts.reduce<Tier>((t, p) => maxTier(t, p.tier), 'fast')
358 const isWorthSplitting =
359 parts.length > 1 && parts.length <= maxParts && parts.some(p => rank(p.tier) < rank(top))
360 if (noSplit || !isWorthSplitting) {
361 const whole = classifyPart(text, options.inherited)
362 const tier = maxTier(whole.tier, top)
363 const category = parts.find(p => p.tier === tier)?.category ?? whole.category
364 return { parts: [{ ...whole, tier, category }], tier, noSplit: true, text }
365 }
366 return { parts, tier: top, noSplit: false, text }
367}
368
369const ASKING: readonly Category[] = ['chat', 'question', 'lookup']
370
371function kindOf(part: PartPlan): 'asking' | 'doing' {
372 return ASKING.includes(part.category) ? 'asking' : 'doing'
373}
374hooks/cost.ts 97 lines1// What a request costs on each model family, and whether switching away from
2// a model whose prompt cache is warm is worth it. Prices are USD per million
3// tokens, Anthropic first-party rates for the 5.5 generation (cached
4// 2026-10-06); Bedrock and Vertex bill differently, so treat them as relative.
5
6import type { ModelUsage } from 'claude-code'
7
8export type Price = { input: number; output: number; cacheRead: number; cacheWrite: number }
9
10const HAIKU: Price = { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }
11// Haiku 5.5 bills prompts past 100K tokens at the higher rate
12const HAIKU_LONG: Price = { input: 0.5, output: 2.5, cacheRead: 0.05, cacheWrite: 0.625 }
13const SONNET: Price = { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }
14const OPUS: Price = { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5 }
15const FABLE: Price = { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 }
16
17// A prompt cache entry lives five minutes; leave a margin
18export const CACHE_WARM_MS = 4.5 * 60 * 1000
19
20export function family(model: string): 'haiku' | 'sonnet' | 'opus' | 'fable' {
21 const m = model.toLowerCase()
22 if (m.includes('haiku')) return 'haiku'
23 if (m.includes('sonnet')) return 'sonnet'
24 if (m.includes('fable') || m.includes('mythos')) return 'fable'
25 return 'opus'
26}
27
28export function priceOf(model: string, promptTokens = 0): Price {
29 switch (family(model)) {
30 case 'haiku':
31 return promptTokens > 100_000 ? HAIKU_LONG : HAIKU
32 case 'sonnet':
33 return SONNET
34 case 'fable':
35 return FABLE
36 default:
37 return OPUS
38 }
39}
40
41export function promptTokens(usage: ModelUsage): number {
42 return usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
43}
44
45export function costOf(model: string, usage: ModelUsage): number {
46 const p = priceOf(model, promptTokens(usage))
47 return (
48 (usage.input_tokens * p.input +
49 usage.output_tokens * p.output +
50 usage.cache_read_input_tokens * p.cacheRead +
51 usage.cache_creation_input_tokens * p.cacheWrite) /
52 1_000_000
53 )
54}
55
56export type Switch = {
57 // the model to send the request to
58 model: string
59 // why the target was not used, when it was not
60 keptBecause?: string
61}
62
63// Decides between moving to `target` and staying on `current` for the next
64// request. Moving up in capability always happens. Moving down is skipped when
65// the conversation is long and `current` still holds it in cache, so that
66// re-reading it uncached on the cheaper model would cost more than staying.
67export function chooseSwitch(input: {
68 current: string | undefined
69 target: string
70 contextTokens: number
71 expectedOutput: number
72 lastAt: number | undefined
73 now: number
74}): Switch {
75 const { current, target, contextTokens, expectedOutput, lastAt, now } = input
76 if (!current || current === target || lastAt === undefined) return { model: target }
77 if (now - lastAt > CACHE_WARM_MS) return { model: target }
78
79 const from = priceOf(current, contextTokens)
80 const to = priceOf(target, contextTokens)
81 if (to.output >= from.output) return { model: target }
82
83 const stay = (contextTokens * from.cacheRead + expectedOutput * from.output) / 1_000_000
84 const move = (contextTokens * to.cacheWrite + expectedOutput * to.output) / 1_000_000
85 if (move < stay) return { model: target }
86 return {
87 model: current,
88 keptBecause: `cache warm on ${current}: staying costs ~$${stay.toFixed(4)}, switching ~$${move.toFixed(4)}`,
89 }
90}
91
92export function formatUsd(usd: number): string {
93 if (usd >= 1) return `$${usd.toFixed(2)}`
94 if (usd >= 0.01) return `$${usd.toFixed(3)}`
95 return `$${usd.toFixed(4)}`
96}
97types/index.d.ts 72 lines1// The model-router's type contract: the values it keeps in `$.state`.
2
3export type Tier = 'fast' | 'balanced' | 'deep' | 'max'
4
5export type Category =
6 | 'chat'
7 | 'question'
8 | 'lookup'
9 | 'edit'
10 | 'debug'
11 | 'review'
12 | 'build'
13 | 'design'
14 | 'follow-up'
15 | 'context'
16
17export type SplitMode = 'sequential' | 'off'
18
19export type RouterSettings = { isEnabled: boolean; split: SplitMode }
20
21// How one turn is routed; its tier may rise while the turn runs.
22export type TurnRoute = {
23 tier: Tier
24 category: Category
25 reasons: string[]
26 part?: { index: number; total: number }
27 escalated?: string
28}
29
30// A part of a split prompt still waiting for its own turn.
31export type QueuedPart = {
32 text: string
33 tier: Tier
34 category: Category
35 reasons: string[]
36 index: number
37 total: number
38}
39
40// The model the last main-loop request went to, and how large the prompt was:
41// what the cache-aware switch decision reads.
42export type CacheState = { model?: string; at?: number; contextTokens: number }
43
44export type ModelTally = { steps: number; cost: number }
45
46export type RouterStats = {
47 prompts: number
48 splits: number
49 steps: number
50 cost: number
51 // what the same requests would have cost on the session's own model
52 baseline: number
53 byModel: Record<string, ModelTally>
54}
55
56// `Tier` inside the block below would name the engine's own plugin tier
57export type RouteTier = Tier
58
59declare module 'claude-code' {
60 interface PluginState {
61 'model-router': {
62 settings: RouterSettings
63 turns: Record<string, TurnRoute>
64 pending: TurnRoute | null
65 queue: QueuedPart[]
66 cache: CacheState
67 lastTier: RouteTier | null
68 stats: RouterStats
69 }
70 }
71}
72