SLOPSHOPPER

model-router

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

commandtoaststatuspromptmodeltimer
v0.1.0no licenseupdated 2026-10-19Suciones/model-router
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 › /router ⎿ model-router: Model router: on, multi-part prompts split, classifier hybrid ⎿ model-router: Tiers: fast → claude-haiku-5-5, balanced → claude-sonnet-5-5, deep → claude-opus-5-5, max (!max) → claude-fabl ⎿ model-router: Last tier: balanced ⎿ model-router: This session: ⎿ model-router: 1 prompts routed, 0 split, 0 model requests ⎿ model-router: cost ≈ $0.0000 vs ≈ $0.0000 on the session model: saved ≈ $0.0000 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ model-router: ⇄ router on
README

model-router

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.

Contents

  1. Install
  2. Quick start
  3. Tutorial
  4. How it works
  5. Settings
  6. Commands and prompt prefixes
  7. Limits and honest caveats
  8. Customising
  9. Troubleshooting

Install

You need Claude Code 2.1.295 or newer. The mod API is early access and may change between releases.

Option A: install from a repository (recommended)

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.

Option B: run it straight from the folder

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.

Check that it's running

The status line shows ⇄ router on. Type /router and you should get a status summary.


Quick start

  1. Install it.
  2. Work as you normally would.
  3. Watch the status line change, for example ⇄ Haiku · question or ⇄ Opus · build.
  4. Type /router any time to see how much it has saved.
  5. If it picks wrong, start the prompt with !opus, !sonnet or !haiku to force a model for that one prompt.

Tutorial

1. A simple question goes to Haiku

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.

2. A code change goes to Sonnet

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.

3. A big build goes to Opus

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.

4. A mixed prompt is split

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:

  • Claude answers part 1 on Haiku and is told to do only that part.
  • When that turn ends, the mod sends part 2 as a follow-up message, which runs on its own model.

The follow-up shows in the transcript as a message sent by the model-router plugin. That is expected.

5. One job described over several sentences is not split

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.

6. See the decision without spending anything

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

7. Override the router

You typeResult
!haiku what time zone is Tokyo inHaiku, 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 ....

8. Check your savings

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


How it works

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

Classification

Each piece of a prompt gets a category and a score.

Category comes from wording:

CategoryLooks likeBase 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 traces3.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:

  • up for a long prompt (+1, +1.5, +2), broad scope words like "entire", "codebase", "production-ready" (+1.5 each), hard topics like concurrency, security or caching (+1 each, only for work, never for a question about them), code or a stack trace in the prompt (+0.5), three or more files named (+1), three or more listed steps (+1)
  • down for "simple", "quick", "just", "tiny", "typo", and so on (−1 each)

Tier comes from the final score:

ScoreTierDefault modelEffort
under 2fastclaude-haiku-5-5low
2 to under 5.5balancedclaude-sonnet-5-5medium
5.5 and updeepclaude-opus-5-5high
(override only)maxclaude-fable-5-1high

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.

Splitting

A prompt is split only when all of these hold:

  • the setting Multi-part prompts is sequential
  • it has between 2 and 4 parts after merging
  • at least one part needs a cheaper tier than the hardest part
  • you did not use !nosplit or a forced model

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

Mid-turn escalation

A cheap model can be wrong about being cheap. During a turn, the mod moves it up one tier for its remaining requests when:

  • a fast turn starts editing files, or passes 5 steps
  • a balanced turn passes 14 steps
  • any turn hits the output token limit
  • you type a message mid-turn that scores harder than the current plan

Cache-aware switching

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.

What it never touches

  • Subagents keep their own models.
  • Slash commands (/…) are not routed.
  • Prompts that arrive from tools, schedules or other sessions are not classified, only ones you type (or send through the SDK or Remote Control).
  • If the mod itself throws, your prompt goes through unchanged on your session model.

Settings

Set during install, or later in the plugin config menu. Defaults in bold.

SettingValuesWhat it does
fastModelclaude-haiku-5-5Model for the fast tier
balancedModelclaude-sonnet-5-5Model for the balanced tier
deepModelclaude-opus-5-5Model for the deep tier
maxModelclaude-fable-5-1Used only with !max or !fable
classifierhybrid, heuristichybrid asks Haiku about uncertain pieces
splitsequential, offRun multi-part prompts part by part, or whole
setEfforttrue, falseSet low, medium or high effort per tier
escalatetrue, falseAllow mid-turn tier upgrades
cacheAwaretrue, falseStay 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.


Commands and prompt prefixes

CommandDoes
/routerStatus, tiers, last tier, savings this session and across sessions
/router on / offTurn routing on or off (off: every request uses your session model)
/router split on / offSplit multi-part prompts, or keep them whole
/router explain <prompt>Show how a prompt would be classified and split, sending nothing
/router resetClear the savings counters
/router helpList 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.


Limits and honest caveats

  • The classifier is a heuristic. It reads wording, not intent. It will sometimes put a hard job on a cheap model; the mid-turn escalation and the ! prefixes are the safety nets. Check /router explain on your own typical prompts and tune.
  • Savings are estimates. They use public first-party API prices and the usage numbers Claude Code reports. Subscription plans (Pro, Max) meter usage differently, and Bedrock or Vertex bill differently, so treat the figures as relative, not as an invoice.
  • It overrides your model picker while it is on. /router off returns control to you.
  • A cheaper model can cost more overall if it needs several retries. The escalation rules limit this, but they do not remove it.
  • Split parts lose a little continuity. Each part is a separate turn in the same conversation, so context is shared, but part 2 sees part 1's answer rather than the original single request.
  • Early-access API. Future Claude Code releases may change the mod interface and require updates here.

Customising

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.


Troubleshooting

SymptomCause and fix
No ⇄ in the status lineThe 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 ranThe earlier turn was interrupted, or you typed a new prompt, so the queue was cleared
"model not found" or access errorA configured model isn't available on your account. Change that tier's model in settings
Wrong tier for a certain kind of promptRun /router explain on it and adjust the rules in classify.ts
/plugin install says the command is not availableYou're in the desktop app's Code tab. Run it from a terminal session instead
Source 4 files
hooks/register.ts 450 lines
1import { 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}
450
hooks/classify.ts 374 lines
1// 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}
374
hooks/cost.ts 97 lines
1// 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}
97
types/index.d.ts 72 lines
1// 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