Runs simple prompts on Haiku and keeps the session's model for the hard ones, to save cost

<img src="docs/banner.svg" alt="model-router: simple prompts on Haiku, hard ones on your model" width="100%">
A Claude Code mod that runs simple prompts on Haiku and keeps the model you picked with
/modelfor the hard ones, to save cost.
Before each turn, Haiku reads your prompt and answers one word: SIMPLE (it can handle it) or COMPLEX (it needs the stronger model, e.g. Opus). A simple turn runs entirely on Haiku; a complex one runs on your model, unchanged. The status line shows which one is answering.
you type: explain what ls -la does in the terminal, briefly
router: SIMPLE → status line: ⚡ haiku
you type: debug why the session token expires after the refresh, it touches auth and the API client
router: COMPLEX → status line: 🧠 opus
The images in this README are illustrations of the mod's output, drawn with the exact text it uses; they are not screen captures.
Most sessions mix hard work (debugging, refactors, design) with small questions (what does this flag do, rename this, fix this typo). Running the small ones on Opus costs far more than they need. Switching by hand with /model every few turns is tedious, so nobody does it.
This mod makes the choice for you, per turn, with a one-word classification on Haiku that costs about $0.00002. It only ever routes down: it never picks a model stronger than the one you chose.
At the prompt of a Claude Code terminal session:
/plugin install model-router --marketplace webdiego/model-router
What you will see:
Add marketplace? naming github:webdiego/model-router: answer y.Installed model-router. Plugin is now active. The mod runs from the next prompt; there are no options to set.From a shell:
claude plugin install model-router --marketplace webdiego/model-router
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with webdiego/model-router, then Add plugin.
To remove it, from a shell:
claude plugin uninstall model-router
The mod is a single hooks module, hooks/register.tsx, with five hooks.
turn.start: classificationFires once per turn, before the model is called.
e.agentId set) and turns with no typed text (continuations) are skipped.maxTokens: 16 (MAX_TOKENS in hooks/classify.ts; at 5 the reply was cut to SIMPL), effort: 'low' and a 4 s timeout.isCheap() and isFollow() read the reply: only a reply that starts with SIMPLE or FOLLOW counts. Anything else (COMPLEX, an empty reply, "I think SIMPLE") keeps the strong model.FOLLOW means the prompt only answers the previous turn ("yes", "go on", "the second one"), so it takes that turn's model: a go-ahead for Opus's plan stays on Opus, an "ok" after a Haiku answer stays on Haiku. No switch, no cache re-written. A follow-up as the first prompt of a session runs on your model..catch passes the turn on untouched) all keep your model: the mod never downgrades by accident.Every prompt is classified, short ones too.
Haiku is 40 times cheaper than Opus per token, but each model has its own prompt cache, and the context (Claude Code's system prompt, the conversation, the files read) can be tens of thousands of tokens. Re-writing 50,000 tokens into Opus's cache costs $0.25, more than a dozen Haiku turns save. So cheaper() prices the turn both ways from what the session has measured:
| On your model | On Haiku | |
|---|---|---|
| Context | read from its cache if used in the last 5 minutes, else re-written | read from Haiku's cache if warm, else written |
| Output | the average output of past turns, at its price | the same, at Haiku's price |
| Penalty | if your model's cache is warm now but will have expired by the next turn (the average gap between turns), re-writing it then |
The cheaper side wins. In practice:
🧠 opus (keeps its cache);The context size is the last request's tokens (input, cache reads and writes, output); output and gap are moving averages, starting at 500 tokens and one minute. Claude Code caches for 5 minutes or 1 hour and does not tell a mod which; the mod assumes 5 (CACHE_TTL_MS). If your cache lasts an hour, that only makes it keep Opus a little more often than needed: it can miss savings, never cost more.
turn.step: running the turn on HaikuEach step of a remembered turn (every model call in it, tool loops included) is passed on with model: 'haiku'. Other turns and subagents are passed on unchanged, so they use the session's model.
The hook also reads each step's stop chunk, which carries the tokens the API reported, to measure the savings.
turn.complete: cleanupThe turn's id is forgotten, and its output tokens update the average the cost check uses.
session.end: reset/clear ends the session without reloading the mod, so the switch counter, the last choice, the savings, the cache timings and the warning are reset here.
ui.render on AbovePrompt: the switch warningDraws the band above the prompt while the warning flag is on, unless a survey is showing there.
The instruction Haiku gets, verbatim:
You route prompts for a coding assistant. Reply with one word.
SIMPLE: a factual question, explaining a short snippet, a rename, a typo, a one-line or one-file trivial edit, formatting, a shell one-liner, translating text.
COMPLEX: debugging, architecture or design, multi-file changes, refactors, new features, security, performance, anything ambiguous.
FOLLOW: a reply that only makes sense with the previous message and asks for nothing new: yes, no, ok, go on, do it, the second one.
When unsure, reply COMPLEX.
Goes to Haiku (SIMPLE) | Stays on your model (COMPLEX) | Keeps the last turn's model (FOLLOW) |
|---|---|---|
"what does git rebase --onto do?" | "this test fails only in CI, find out why" | "yes, do it" |
"rename userId to accountId in this file" | "split this module into a service and a repository" | "go on" |
| "fix the typo in the README title" | "add OAuth login" | "the second one" |
"a one-liner to count lines in all .ts files" | "is this endpoint safe against SQL injection?" | "sì, procedi" |
| "translate this paragraph to English" | "migrate the project from Jest to Vitest" | "no, use the other option" |
| Shown | Meaning |
|---|---|
⚡ haiku | this turn runs on Haiku |
🧠 <model> | this turn runs on the session's model, as /model set it (e.g. 🧠 opus) |
🧠 <model> (keeps its cache) | the prompt was simple, but running it on Haiku would have let your model's cache expire (why) |
· saved $0.42 (61%) | what the router saved in this session so far (how it is measured) |
Each model keeps its own prompt cache. Every time the router switches model, the whole conversation is written into the other model's cache again, which costs tokens and can eat the savings in a long session.
After 3 switches in a session, a yellow band appears above the prompt:
Model switched 3 times: each switch rebuilds the prompt cache. [New session] [Dismiss]
/clear: a fresh session starts with an empty cache and the counter at zero.The band's flag lives in plugin state (showSwitchWarning, typed in types/index.d.ts).
Two measurements answer two different questions: how much does it actually save (live, on your real sessions) and how well does Haiku route (offline, on a fixed set of prompts).
Every model call of the session is priced twice, from the tokens the API reported in the step's stop chunk:
The status line shows baseline − spent and the share of the baseline it is. The switch cost is in the measure: after a switch, the model that takes over writes the whole context into its cache, and spent pays for those writes; in the baseline that first step is priced as cache reads, since with no switch the cache would have stayed warm.
Prices, in $ per million tokens (PRICES in hooks/register.tsx; a cache write is 1.25× input):
| Model family | Input | Output | Cache read |
|---|---|---|---|
| Opus | 4.00 | 20.00 | 0.20 |
| Sonnet | 2.00 | 10.00 | 0.20 |
| Haiku | 0.10 | 0.50 | 0.01 |
| Fable / Mythos | 10.00 | 50.00 | 0.25 |
What the baseline cannot know:
bench/run.ts sends every prompt of bench/prompts.json (48 prompts, English and Italian, each labelled SIMPLE, COMPLEX or FOLLOW) to Claude Haiku 5.5 with the mod's own instruction and reply parsers (all imported from hooks/classify.ts), and reports:
SIMPLE or FOLLOW (a follow-up of a Haiku turn), or a follow-up answered SIMPLE (a go-ahead for Opus's plan sent to Haiku);Each misrouted prompt is printed with Haiku's reply. Run it with an API key (the 48 calls cost well under a cent):
cd bench
npm install
ANTHROPIC_API_KEY=sk-ant-... npm start
Last run (2026-10-24, Claude Haiku 5.5), before FOLLOW and its 10 follow-up prompts were added; not yet re-run:
prompts 40
routed right 40 (100%)
sent to Haiku wrong 0 (0%) quality risk: a hard task answered by Haiku
kept strong wrong 0 (0%) missed savings only
latency p50 / p95 843 ms / 1553 ms
classifier cost $0.000022 per prompt, $0.0009 total
The first run, at max_tokens: 5, routed only 50% right: every simple prompt came back as SIMPL, cut by the limit, so nothing ever went to Haiku. That is why the limit is now 16.
It calls the API directly with thinking off, while the mod goes through Claude Code's own model.complete, so latency in a session can differ. Add your own prompts to prompts.json to test the cases you care about.
model-router/
├── .claude-plugin/
│ ├── plugin.json # mod manifest: name, version, description, icon
│ ├── icon.png # 1024×1024 plugin icon
│ └── marketplace.json # makes this repo installable with one line
├── bench/
│ ├── run.ts # offline classifier benchmark
│ ├── prompts.json # labelled prompts it routes
│ └── package.json # the Anthropic SDK, for the benchmark only
├── docs/ # logo, banner and README illustrations (SVG)
├── hooks/
│ ├── hooks.json # points the engine at register.tsx
│ ├── classify.ts # the classifier prompt (ASK) and isCheap(), shared with the benchmark
│ ├── register.tsx # the mod: prices, cost(), hooks, the band
│ └── register.test.ts # unit and integration tests
├── types/
│ └── index.d.ts # state contract: the band's showSwitchWarning flag
├── tsconfig.json # extends the types the engine writes on load
├── LICENSE
└── README.md
Exported so they can be tested on their own:
isCheap(reply: string): boolean, isFollow(reply: string): boolean (classify.ts): the reply parserscost(usage: ModelUsage, model: string, warm?: boolean): number (register.tsx): the dollars a call cost; warm prices cache writes as readscheaper(moment: Moment, strong: string): boolean (register.tsx): whether a simple turn costs less on Haiku, both caches countedRun them with:
claude plugin test .
The runner executes every *.test.ts / *.test.tsx in an environment like the one the mod's hooks run in, importing its kit from claude-code/testing. Nothing sits beneath the mod in a test: small hooks registered with on(...) stand in for the engine (answering model.complete with scripted replies, session.model with claude-opus-5-5, each turn.step with the tokens the test chooses, and drawing nothing for ui.render), and mock.clock holds the time still until the test moves it.
Current result:
hooks/register.test.ts:
(pass) the band shows after three switches and Dismiss hides it
(pass) a Haiku turn shows what it saved against the session model
(pass) a simple prompt stays on Opus when its cache would expire before the next turn
(pass) a follow-up stays on the model of the turn it answers
(pass) a follow-up as the first prompt runs on the session model
(pass) weighs both caches before sending a simple turn to Haiku
(pass) prices tokens per model, cache writes as reads when warm
(pass) parses the router reply, defaulting to the strong model
8 pass
0 fail
a simple prompt stays on Opus when its cache would expire before the next turn runs a complex turn on Opus over a 50,000-token context, moves the clock 4.5 minutes, then sends a simple prompt. Both steps reach the engine on claude-opus-5-5, and the status line reads 🧠 claude-opus-5-5 (keeps its cache).
weighs both caches before sending a simple turn to Haiku checks cheaper() at fixed moments (500 output tokens, one minute between turns):
| Context | Opus cache last used | Haiku cache last used | Goes to |
|---|---|---|---|
| 50,000 | 1 min ago | never | Haiku |
| 50,000 | 4.5 min ago | 30 s ago | Opus |
| 1,000 | 4.5 min ago | never | Haiku |
| 50,000 | 10 min ago | never | Haiku |
| any, session model not priced | Haiku |
a Haiku turn shows what it saved against the session model runs a simple turn whose step reports 10,000 cache-write tokens and 1,000 output tokens. It checks the step reached the engine with model: 'haiku' and the status line reads ⚡ haiku · saved $0.02 (92%): $0.00175 on Haiku against $0.022 on a warm Opus.
prices tokens per model, cache writes as reads when warm checks cost(): a million Opus input tokens cost $4, a million Haiku output tokens $0.50, a million Opus cache writes $5 (or $0.20 when warm), and an unknown model $0.
the band shows after three switches and Dismiss hides it runs three turns through the real hook chain with Haiku answering SIMPLE, COMPLEX, SIMPLE, and mounts the AbovePrompt band on the terminal surface:
a follow-up stays on the model of the turn it answers runs four turns, Haiku answering SIMPLE, FOLLOW, COMPLEX, FOLLOW: the steps run on Haiku, Haiku, Opus, Opus. a follow-up as the first prompt runs on the session model checks a FOLLOW with no turn before it runs on Opus.
parses the router reply, defaulting to the strong model checks isCheap() and isFollow():
| Reply | Result |
|---|---|
SIMPLE | Haiku |
simple. | Haiku |
FOLLOW, follow | the last turn's model |
COMPLEX | strong model |
I think SIMPLE, not FOLLOW | strong model |
| (empty) | strong model |
Not covered: the New session button, and turn.step actually swapping the model on a live session (check it by hand with the status line).
Run Claude Code with the mod loaded straight from this folder:
claude --plugin-dir .
Validate the manifest and the module the way the engine will read them:
claude plugin validate .
Type-check (the engine writes the types into .claude-plugin/types/ the first time it loads the mod):
npx tsc -p .
To see a switch by hand: send a simple prompt, then a complex one, then a simple one, watching the status line; the band appears after the third.
Everything lives in hooks/register.tsx, except the classifier's prompt and token limit, in hooks/classify.ts.
CHEAP ('haiku').SIMPLE: / COMPLEX: lines of ASK. Keep "When unsure, reply COMPLEX" so mistakes cost money, not quality.SWITCH_WARN_AT (3).PRICES (input, output, cache read per million tokens, matched by model family).CACHE_TTL_MS to 60 * 60_000, so simple prompts stop staying on your model to keep a cache that was not about to expire.timeoutMs (4000) in the turn.start hook./clear and on restart.Will it ever use a model stronger than mine? No. It only routes down to Haiku; a complex turn uses exactly the model /model set.
My /model is already Haiku. Does it do anything? Every turn runs on Haiku either way; the classification is wasted. Switch the mod off.
What happens if Haiku is down or slow? After 4 s, or on any error, the turn runs on your model as if the mod were not there.
Does it send my prompt anywhere new? Only to Haiku, through the same Claude Code connection your session already uses.
MIT © 2026 webdiego
hooks/register.tsx 149 lines1import { atom, read, update } from 'claude-code'
2import type { ModelUsage, Register } from 'claude-code'
3import { ASK, MAX_TOKENS, isCheap, isFollow } from './classify'
4
5const CHEAP = 'haiku'
6// Each model has its own prompt cache, so every switch re-writes the context into the other cache.
7const SWITCH_WARN_AT = 3
8// Claude Code caches for 5 minutes or 1 hour and does not tell a mod which; assuming the shorter only ever costs missed savings.
9const CACHE_TTL_MS = 5 * 60_000
10
11// $ per million tokens (input, output, cache read) of the 5.5 generation; a cache write costs 1.25× input.
12// shortcut: matched by family, so older versions (Opus 4.x at $5/$25) are priced as 5.5; match exact ids if that matters.
13const PRICES: [RegExp, number, number, number][] = [
14 [/fable|mythos/i, 10, 50, 0.25],
15 [/opus/i, 4, 20, 0.2],
16 [/sonnet/i, 2, 10, 0.2],
17 [/haiku/i, 0.1, 0.5, 0.01],
18]
19
20const price = (model: string) => PRICES.find(([re]) => re.test(model))
21
22const showSwitchWarning = atom({ plugin: 'model-router', key: 'showSwitchWarning' } as const, false)
23
24// warm: price cache writes as reads, as for a model whose cache no switch ever cooled. Unknown models cost 0.
25export const cost = (u: ModelUsage, model: string, warm = false) => {
26 const p = price(model)
27 if (!p) return 0
28 const [, input, output, read] = p
29 const write = warm ? read : input * 1.25
30 return (u.input_tokens * input + u.output_tokens * output + u.cache_read_input_tokens * read + u.cache_creation_input_tokens * write) / 1e6
31}
32
33// What the session looks like when a simple prompt arrives: tokens the next request re-sends, the expected output,
34// the usual time between turns, and when each model's cache was last used (ms since the epoch).
35export type Moment = { context: number; output: number; gapMs: number; strongAt: number; cheapAt: number; now: number }
36
37// Whether a simple turn costs less on the cheap model, counting what the switch does to both prompt caches.
38export const cheaper = (m: Moment, strong: string) => {
39 const s = price(strong)
40 const c = price(CHEAP)
41 if (!s || !c) return true
42 const warm = (at: number) => m.now - at < CACHE_TTL_MS
43 const context = ([, input, , read]: typeof s, isWarm: boolean) => m.context * (isWarm ? read : input * 1.25)
44 // Not using the strong model now lets its cache expire before the next turn, which may need it back.
45 const expires = warm(m.strongAt) && m.now + m.gapMs - m.strongAt >= CACHE_TTL_MS
46 const onStrong = context(s, warm(m.strongAt)) + m.output * s[2]
47 const onCheap = context(c, warm(m.cheapAt)) + m.output * c[2] + (expires ? m.context * (s[1] * 1.25 - s[3]) : 0)
48 return onCheap < onStrong
49}
50
51export const register: Register = on => {
52 const cheapTurns = new Set<string>()
53 const switchedTurns = new Set<string>()
54 let lastWasCheap = false
55 let switches = 0
56 // What the session cost with the router (classifier included), and what the same steps would have cost on the session's model.
57 let spent = 0
58 let baseline = 0
59 let label = ''
60 // What cheaper() weighs, learned from the session: the last request's size, averages of output and gap, each cache's last use.
61 let context = 0
62 let avgOutput = 500
63 let avgGapMs = 60_000
64 let turnOutput = 0
65 let lastStartAt: number | undefined
66 let strongAt = -Infinity
67 let cheapAt = -Infinity
68
69 const saved = () => baseline > 0 ? ` · saved $${(baseline - spent).toFixed(2)} (${Math.round((1 - spent / baseline) * 100)}%)` : ''
70
71 on('turn.start', async ($, e, next) => {
72 if (e.agentId === undefined && e.text.trim() !== '') {
73 const now = await $.clock.now()
74 if (lastStartAt !== undefined) avgGapMs = 0.7 * avgGapMs + 0.3 * (now - lastStartAt)
75 lastStartAt = now
76 const strong = await $.session.model()
77 const r = await $.model.complete({ model: CHEAP, prompt: `${ASK}${e.text.slice(0, 4000)}\n>>>`, maxTokens: MAX_TOKENS, effort: 'low', timeoutMs: 4000 })
78 if (r.isAnswered) spent += cost(r.usage, CHEAP)
79 // Any failure keeps the session's model: never downgrade by accident. A follow-up keeps the model it answers.
80 const simple = r.isAnswered && (isFollow(r.text) ? lastWasCheap : isCheap(r.text))
81 const cheap = simple && cheaper({ context, output: avgOutput, gapMs: avgGapMs, strongAt, cheapAt, now }, strong)
82 if (cheap !== lastWasCheap) {
83 switchedTurns.add(e.turnId)
84 if (++switches === SWITCH_WARN_AT) await update($, showSwitchWarning, () => true)
85 }
86 lastWasCheap = cheap
87 if (lastWasCheap) cheapTurns.add(e.turnId)
88 label = lastWasCheap ? `⚡ ${CHEAP}` : `🧠 ${strong}${simple ? ' (keeps its cache)' : ''}`
89 $.ui.status(label + saved())
90 }
91 return next(e)
92 }).catch(($, e, next) => next(e))
93
94 on('turn.step', async function* ($, e, next) {
95 if (e.agentId !== undefined) return yield* next(e)
96 const stream = next(cheapTurns.has(e.turnId) ? { ...e, model: CHEAP } : e)
97 for await (const chunk of stream) {
98 if (chunk.kind === 'stop' && chunk.usage) {
99 const u = chunk.usage
100 spent += cost(u, u.model)
101 // e.model is the session's model; without the router its cache would have stayed warm through a switch.
102 baseline += cost(u, e.model, switchedTurns.has(e.turnId) && e.index === 0)
103 if (u.model.includes(CHEAP)) cheapAt = await $.clock.now()
104 else strongAt = await $.clock.now()
105 context = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens
106 turnOutput += u.output_tokens
107 $.ui.status(label + saved())
108 }
109 yield chunk
110 }
111 return stream.result
112 })
113
114 // /clear ends the session without reloading the mod, so the count starts over here.
115 on('session.end', async ($, e, next) => {
116 switches = 0
117 lastWasCheap = false
118 spent = 0
119 baseline = 0
120 context = 0
121 lastStartAt = undefined
122 strongAt = -Infinity
123 cheapAt = -Infinity
124 await update($, showSwitchWarning, () => false)
125 return next(e)
126 })
127
128 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
129 if (e.props.hasSurvey || !(await read($, showSwitchWarning))) return next(e)
130 const { Box, Button, Text } = $.ui.resolve(e)
131 return (
132 <Box>
133 <Text color="yellow">Model switched {SWITCH_WARN_AT} times: each switch rebuilds the prompt cache. </Text>
134 <Button key="clear" label="New session" onPress={() => $.command.run({ command: 'clear' })} />
135 <Text> </Text>
136 <Button key="dismiss" label="Dismiss" onPress={() => update($, showSwitchWarning, () => false)} />
137 </Box>
138 )
139 })
140
141 on('turn.complete', ($, e, next) => {
142 cheapTurns.delete(e.turnId)
143 switchedTurns.delete(e.turnId)
144 if (turnOutput > 0) avgOutput = 0.7 * avgOutput + 0.3 * turnOutput
145 turnOutput = 0
146 return next(e)
147 })
148}
149hooks/classify.ts 18 lines1// Shared by the mod and bench/run.ts, so the benchmark measures the exact prompt the mod sends.
2export const ASK = `You route prompts for a coding assistant. Reply with one word.
3SIMPLE: a factual question, explaining a short snippet, a rename, a typo, a one-line or one-file trivial edit, formatting, a shell one-liner, translating text.
4COMPLEX: debugging, architecture or design, multi-file changes, refactors, new features, security, performance, anything ambiguous.
5FOLLOW: a reply that only makes sense with the previous message and asks for nothing new: yes, no, ok, go on, do it, the second one.
6When unsure, reply COMPLEX.
7
8Prompt:
9<<<
10`
11
12// At 5 tokens Haiku's reply came back cut to "SIMPL", so every simple prompt stayed on the strong model.
13export const MAX_TOKENS = 16
14
15export const isCheap = (reply: string) => /^\s*SIMPLE\b/i.test(reply)
16
17export const isFollow = (reply: string) => /^\s*FOLLOW\b/i.test(reply)
18types/index.d.ts 8 lines1export type ShowSwitchWarning = boolean
2
3declare module 'claude-code' {
4 interface PluginState {
5 'model-router': { showSwitchWarning: ShowSwitchWarning }
6 }
7}
8