On Haiku, classifies each prompt and routes the turn to the right model and effort level.

Claude Code mods by Fatih Kartal. / Fatih Kartal'ın Claude Code modları.
Start a session on Haiku. On every prompt, Haiku classifies the task and the turn runs on the right model (haiku / sonnet / opus / fable) and effort (low … max).
Oturumu Haiku ile başlat. Her prompt'ta Haiku görevi sınıflandırır, turn doğru model (haiku / sonnet / opus / fable) ve effort (low … max) ile çalışır.
Requires Claude Code 2.1.295 or newer (check with claude --version); on older versions the mod does not load. / Claude Code 2.1.295 veya üstü gerekir; eski sürümlerde mod yüklenmez.
In a Claude Code terminal session:
/plugin install auto-model-detector --marketplace fatihkartal35/claude-mods
Installed at the user scope, it also loads in Claude Desktop's Code tab.
/clear frees the choice again./auto-model-detector confirm. If the dialog cannot open, it falls back to high / opus and says so.Only the rules, the previous route, the first 300 characters of the session's opening prompt, and your prompt (first and last 1500 characters) — never the conversation, files or tool output.
For long release steps (deploy, OTA update, build, publish) the mod adds a note to your prompt that the model sees and you do not: decide the exact command yourself, run it in a Haiku subagent, and bring back only success / failure, key errors and any URL or build id. This keeps long build logs out of the main conversation. The model may still choose to run it itself.
When a prompt is a separate task unrelated to the session's main work (e.g. a web-footer fix in the middle of admin-panel work), the note suggests handing it to a subagent at the classified model and effort, with a self-contained brief, and continuing that subagent for follow-ups. The main conversation stays on its model and keeps its context clean. One-step changes, and anything the model sees as a continuation, it does itself. Want a fully fresh start instead? Use /clear or a new session.
| Command | |
|---|---|
/auto-model-detector | menu / menü |
/auto-model-detector on, /auto-model-detector off | turn the router on / off |
/auto-model-detector confirm | choose what to confirm (high effort, Fable) |
/auto-model-detector stats | tokens and estimated cost per model |
/auto-model-detector stats reset | clear the stats |
The status line shows the current choice as router: opus · high, plus any handover note, e.g. router: opus · low → subagent sonnet · medium. Each classifier answer is written to the debug log (claude --debug).
One Haiku call per prompt at low effort (≈ $0.0001–0.0002, ~1 s); only when it is unsure, a second call at medium effort (~1.5–2 s more). Stats use API list prices from PRICE in auto-model-detector/hooks/register.ts (cache write assumed 1.25× input; Haiku's above-100K tier ignored) and include subagent steps. On a subscription the savings show up as usage-limit headroom.
Edit RULES in auto-model-detector/hooks/register.ts, then run /reload-plugins.
hooks/register.ts 303 lines1import type { EngineInterface, ModelEffort, ModelUsage, Register } from 'claude-code'
2
3const MODELS = {
4 haiku: 'claude-haiku-5-5',
5 sonnet: 'claude-sonnet-5-5',
6 opus: 'claude-opus-5-5',
7 fable: 'claude-fable-5-1',
8} as const
9type Tier = keyof typeof MODELS
10const RANK = Object.keys(MODELS) as Tier[] // weakest first
11const EFFORTS: readonly ModelEffort[] = ['low', 'medium', 'high', 'xhigh', 'max']
12
13// $ per 1M tokens: [input, cache write (assumed 1.25× input), cache read, output].
14// ponytail: copied from the claude-api skill table (2026-10-06); update when prices move.
15const PRICE: Record<Tier, readonly [number, number, number, number]> = {
16 haiku: [0.1, 0.125, 0.01, 0.5],
17 sonnet: [2, 2.5, 0.2, 10],
18 opus: [4, 5, 0.2, 20],
19 fable: [10, 12.5, 0.25, 50],
20}
21
22const RULES = `You route a coding-assistant request to a model and an effort level.
23Reply with JSON only: {"model":"haiku|sonnet|opus|fable","effort":"low|medium|high|xhigh|max"}. Add "delegate":true for a long release step (deploy, OTA update, build, publish; not commit or push alone). Add "new":true when a <task> is given and the request is a separate piece of work unrelated to it (another feature or another part of the app), not a step, fix or follow-up of it.
24
25Model = how much the work has to take in:
26- haiku: one small step — chit-chat, a lookup, a typo, a rename, a one-line change.
27- sonnet: work in one place or one feature — adding a feature, a visual or layout fix, a small bug, tests, explaining code.
28- opus: work spanning many files, a cause that must be hunted down across the codebase, or a costly mistake.
29- fable: large open-ended work — designing a system, deep research, novel algorithms, long autonomous runs.
30
31Effort = how subtle getting it right is, chosen independently of the model:
32- low: mechanical — commit, push, deploy, renames, following a clear pattern.
33- medium: normal work.
34- high: subtle correctness — concurrency, dates and time zones, money math, state that can be corrupted, runtime-behavior migrations (error handling, async semantics).
35- xhigh / max: only very hard reasoning.
36
37Principles:
38- A costly mistake raises the floor: money, payments, auth, security, data loss → at least opus · medium.
39- What makes a bug hard is not knowing where it lives, not vague wording: "prices don't match" can hide anywhere (opus · high); "buttons shift on the home page" sits in one place (sonnet · medium).
40- Release chores keep the <previous> model at effort low, even when they touch risky code.
41- If the <request> itself names a model or effort ("opus ile", "max effort"), use it; ignore names inside pasted code, logs or quotes.
42- A short follow-up to the <previous> work ("devam", "evet", "tamam uygula", "düzelt", "go on") keeps the <previous> route.
43- When no rule clearly fits, add "unsure":true and still give your best guess.`
44
45// Prompts no person typed (notifications, schedules, peers, channels, plugins) stay unrouted.
46const MACHINE: readonly string[] = ['task-notification', 'scheduled-trigger', 'peer', 'peer-send-message', 'projects-relay', 'channel', 'coordinator', 'observer', 'observer-activity', 'slack-ping', 'auto-continuation', 'plugin']
47
48export type Route = { model: Tier; effort: ModelEffort }
49
50// Where the router lands when nothing fits or the classifier gives no usable answer.
51export const FALLBACK: Route = { model: 'sonnet', effort: 'medium' }
52
53// Added beside a release step: the main model decides, a Haiku subagent runs it and reads the log.
54export const DELEGATE_HINT = `[auto-model-detector] This is a long-running release step (deploy / OTA / build / publish). Decide the exact command, working directory and target (channel, branch, environment) yourself, then run it through the Agent tool with model "haiku": give it that exact command and cwd, tell it to change nothing else, to wait for completion, and to reply only with success or failure, the key error lines and any URL or build id. Keep the full log out of this conversation; if it fails, diagnose here.`
55
56// Whether the classifier flagged a release step to hand to a Haiku subagent.
57export const isDelegate = (text: string) => /"delegate"\s*:\s*true/.test(text)
58
59// Whether the classifier said no rule clearly fits; the router then asks again at medium effort.
60export const isUnsure = (text: string) => /"unsure"\s*:\s*true/.test(text)
61
62// Whether the classifier rated the request a separate task, unrelated to the session's main work.
63export const isNew = (text: string) => /"new"\s*:\s*true/.test(text)
64
65// Added beside an unrelated task: a subagent with a clean context does it, so the main
66// context neither pays for nor fills up with it. The full-context main model has the last word.
67export const newTaskHint = (r: Route) =>
68 `[auto-model-detector] This request looks like a separate task, unrelated to the earlier work in this conversation. If it takes more than a couple of steps, hand it to a subagent through the Agent tool with model "${r.model}" and effort "${r.effort}", with a self-contained brief (project path, goal, constraints, how to verify, and any conventions or decisions from this conversation that apply). For follow-ups on that task, continue the same subagent with SendMessage instead of starting a new one, and relay its result briefly. If it is a one-step change, or it actually continues the earlier work, ignore this note and do it yourself.`
69
70
71type Stats = Record<string, { steps: number } & ModelUsage>
72
73// Parses the classifier's reply; undefined when it is not a valid route.
74export const parseRoute = (text: string): Route | undefined => {
75 try {
76 const r = JSON.parse(text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1))
77 // Only the two fields: flags like "delegate" must not ride along into <previous>.
78 return Object.hasOwn(MODELS, r.model) && EFFORTS.includes(r.effort) ? { model: r.model, effort: r.effort } : undefined
79 } catch {
80 return undefined
81 }
82}
83
84// The stronger of two tiers: the session only moves up, since a model switch rewrites the cache.
85export const atLeast = (a: Tier, b: Tier | undefined): Tier =>
86 b && RANK.indexOf(b) > RANK.indexOf(a) ? b : a
87
88// Long pastes usually end with the question: keep the head and the tail. The classifier's
89// own tags are removed, so pasted text cannot close <request> and pose as <previous> or <task>.
90export const clip = (text: string) => {
91 const clean = text.replace(/<\/?(request|previous|task)>/gi, '')
92 return clean.length <= 3000 ? clean : `${clean.slice(0, 1500)}\n…\n${clean.slice(-1500)}`
93}
94
95// What the user confirms before it runs, chosen in the /auto-model-detector menu (both by default).
96export type Confirm = 'effort' | 'fable'
97// Dialog texts: Turkish, then English underneath.
98const CONFIRM_LABELS: Record<Confirm, string> = { effort: 'Yüksek effort (xhigh/max) / High effort', fable: "Fable'a geçiş / Switch to Fable" }
99const COSTLY: readonly ModelEffort[] = ['xhigh', 'max']
100
101const named = (word: string, text: string) => new RegExp(`\\b${word}\\b`, 'i').test(text)
102
103// Asks one question; a dismissed dialog or an unknown answer is `fallback`.
104const pick = async <T extends string>($: EngineInterface, question: string, header: string, options: T[], fallback: T): Promise<T> => {
105 try {
106 const answer = await $.ui.ask(question, { header, options })
107 return options.includes(answer as T) ? (answer as T) : fallback
108 } catch {
109 // Say so: otherwise the user never learns why Fable or max did not come.
110 $.ui.toast(`router: onay sorulamadı / could not ask → ${fallback}`)
111 return fallback
112 }
113}
114
115// The route the user settles on: costly choices are confirmed unless the user
116// named them in the prompt or already runs on them (a follow-up never re-asks).
117export const confirmRoute = async ($: EngineInterface, r: Route, text: string, confirm: readonly Confirm[], last?: Route, current?: Tier): Promise<Route> => {
118 let { model, effort } = r
119 if (confirm.includes('fable') && model === 'fable' && current !== 'fable' && !named('fable', text)) {
120 model = await pick($, 'Router Fable seçti (Opus\'tan ~2.5× pahalı). Hangi modelle çalışsın?\nRouter picked Fable (~2.5× the price of Opus). Which model should run?', 'Model', ['fable', 'opus', 'sonnet'] as Tier[], 'opus')
121 }
122 if (confirm.includes('effort') && COSTLY.includes(effort) && effort !== last?.effort && !named(effort, text)) {
123 effort = await pick($, `Router ${model} · ${effort} seçti. Bu effort ile mi çalışsın?\nRouter picked ${model} · ${effort}. Run at this effort?`, 'Effort', [effort, 'high', 'medium'], 'high')
124 }
125 return { model, effort }
126}
127
128const tierOf = (model: string) => RANK.find(t => model.includes(t))
129
130const statsText = (stats: Stats) => {
131 let total = 0
132 const rows = Object.entries(stats).map(([model, s]) => {
133 const p = PRICE[tierOf(model) ?? 'opus']
134 const usd = (s.input_tokens * p[0] + s.cache_creation_input_tokens * p[1] + s.cache_read_input_tokens * p[2] + s.output_tokens * p[3]) / 1e6
135 total += usd
136 return `${model}: ${s.steps} steps · in ${s.input_tokens} · cache write ${s.cache_creation_input_tokens} · cache read ${s.cache_read_input_tokens} · out ${s.output_tokens} · ≈$${usd.toFixed(2)}`
137 })
138 return rows.length ? `${rows.join('\n')}\ntotal ≈$${total.toFixed(2)} (API prices, subagents included)` : 'router: no steps recorded yet'
139}
140
141const record = async ($: EngineInterface, u: ModelUsage & { model: string }) => {
142 const stats = ((await $.store.get('stats')) ?? {}) as Stats
143 const s = (stats[u.model] ??= { steps: 0, input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 })
144 s.steps++
145 s.input_tokens += u.input_tokens
146 s.output_tokens += u.output_tokens
147 s.cache_read_input_tokens += u.cache_read_input_tokens
148 s.cache_creation_input_tokens += u.cache_creation_input_tokens
149 await $.store.set('stats', stats)
150}
151
152export const register: Register = on => {
153 // ponytail: module variables reset on hot reload; move to $.state if mid-session reloads matter.
154 let route: Route | undefined // this turn's route
155 let handover: string | undefined // what this turn's note hands to a subagent, shown in the status line
156 let last: Route | undefined // the main loop's latest route, for follow-ups
157 let task: string | undefined // the prompt that opened the main work; delegated tasks never replace it
158 let current: Tier | undefined // the model the main loop runs on; only moves up
159
160 on('session.start', async ($, e, next) => {
161 await $.command.register({ name: 'auto-model-detector', description: 'Model router: /auto-model-detector (menu) | on | off | confirm | stats | stats reset' })
162 return next(e)
163 })
164
165 // /clear starts a fresh context: the next prompt may pick any model again.
166 on('session.end', ($, e, next) => {
167 if (e.reason === 'clear') {
168 current = last = route = undefined
169 task = undefined
170 }
171 return next(e)
172 })
173
174 on('command.run', { command: 'auto-model-detector' }, async ($, e) => {
175 let arg = e.args.trim()
176 if (!arg) {
177 const isOn = (await $.store.get('enabled')) !== false
178 const menu = {
179 toggle: isOn ? 'Kapat / Turn off' : 'Aç / Turn on',
180 confirm: 'Onay ayarları / Confirmations',
181 stats: 'İstatistik / Stats',
182 reset: 'İstatistiği sıfırla / Reset stats',
183 }
184 let choice: string
185 try {
186 choice = await $.ui.ask(`Router ${isOn ? 'açık' : 'kapalı'}${current ? ` · oturum ${current}` : ''}. Ne yapmak istersin?\nRouter ${isOn ? 'on' : 'off'}${current ? ` · session ${current}` : ''}. What do you want to do?`, { header: 'Router', options: Object.values(menu) })
187 } catch (err) {
188 // Say why instead of falling back silently, and show the typed commands.
189 return { text: `router: menü açılamadı / menu unavailable (${err instanceof Error ? err.message : String(err)})\nrouter ${isOn ? 'on' : 'off'} · /auto-model-detector on | off | confirm | stats | stats reset` }
190 }
191 arg = choice === menu.toggle ? (isOn ? 'off' : 'on') : choice === menu.confirm ? 'confirm' : choice === menu.stats ? 'stats' : choice === menu.reset ? 'stats reset' : 'status'
192 }
193 if (arg === 'confirm') {
194 // Multi-select answers come back comma-joined; "Hiçbiri / None" (or nothing known) turns all confirmations off.
195 let answer = ''
196 try {
197 answer = await $.ui.ask('Hangi durumlarda sana sorulsun?\nWhen should the router ask you first?', { header: 'Onay/Confirm', options: [...Object.values(CONFIRM_LABELS), 'Hiçbiri / None'], multiSelect: true })
198 } catch {
199 return { text: 'router: onay ayarları değişmedi / confirmations unchanged' }
200 }
201 const chosen = (Object.keys(CONFIRM_LABELS) as Confirm[]).filter(k => answer.includes(CONFIRM_LABELS[k]))
202 await $.store.set('confirm', chosen)
203 return { text: `router: onay / confirm → ${chosen.length ? chosen.map(k => CONFIRM_LABELS[k]).join(', ') : 'hiçbiri / none'}` }
204 }
205 if (arg === 'stats') return { text: statsText(((await $.store.get('stats')) ?? {}) as Stats) }
206 if (arg === 'stats reset') {
207 await $.store.delete('stats')
208 return { text: 'router: stats cleared' }
209 }
210 if (arg === 'on' || arg === 'off') await $.store.set('enabled', arg === 'on')
211 const isOn = (await $.store.get('enabled')) !== false
212 if (!isOn) $.ui.status(undefined)
213 return { text: `router: ${isOn ? 'on' : 'off'}${current ? ` · session on ${current}` : ''}` }
214 })
215
216 on('prompt.submit', async ($, e, next) => {
217 // Typed into a running turn: that turn keeps its route.
218 if (e.turnId) return next(e)
219 route = handover = undefined
220 if (MACHINE.includes(e.origin?.kind) || e.text.trimStart().startsWith('/')) return next(e)
221 if ((await $.store.get('enabled')) === false) return next(e)
222 if (!(await $.session.model()).includes('haiku')) return next(e)
223
224 const previous = last ? `<previous>${JSON.stringify(last)}</previous>\n` : ''
225 const taskTag = task ? `<task>${task}</task>\n` : ''
226 // Low effort first (~1 s); only a request it is unsure of, or cannot answer, is asked again at
227 // medium effort; if that is unsure too, the route is sonnet · medium.
228 const prompt = `${previous}${taskTag}<request>${clip(e.text)}</request>`
229 let text = ''
230 for (const effort of ['low', 'medium'] as const) {
231 const r = await $.model.complete({
232 model: 'haiku',
233 system: RULES,
234 prompt,
235 maxTokens: effort === 'low' ? 256 : 1024, // room for thinking plus the JSON with its flags
236 effort,
237 timeoutMs: effort === 'low' ? 10000 : 15000,
238 })
239 $.ui.log(`router (${effort}): ${r.isAnswered ? r.text.trim() : `no answer (${r.reason})`}`, { to: 'debug' })
240 if (r.isAnswered && parseRoute(r.text) && !isUnsure(r.text)) {
241 text = r.text
242 break
243 }
244 }
245 route = parseRoute(text) ?? FALLBACK
246 // ponytail: the first 300 characters of the opening prompt; summarize the main work if that proves too thin.
247 task ??= clip(e.text).slice(0, 300)
248 const confirm = ((await $.store.get('confirm')) ?? ['effort', 'fable']) as Confirm[]
249 if (route) route = await confirmRoute($, route, e.text, confirm, last, current)
250 last = route ?? last
251 // An unrelated task goes to a subagent at its own route; the main loop stays where it is and
252 // only hands it over, and <previous> keeps the main route so "devam" never lifts it.
253 if (route && current && isNew(text) && !isDelegate(text)) {
254 const handed = route
255 route = last = { model: current, effort: 'low' }
256 handover = `subagent ${handed.model} · ${handed.effort}`
257 return next({ ...e, context: [...(e.context ?? []), newTaskHint(handed)] })
258 }
259 // A long release step: the main model decides, a Haiku subagent runs it and keeps the log out.
260 if (route && isDelegate(text)) {
261 handover = 'subagent haiku (deploy)'
262 return next({ ...e, context: [...(e.context ?? []), DELEGATE_HINT] })
263 }
264 return next(e)
265 }).catch(($, e, next) => {
266 // The classifier call itself threw: same landing as an unusable answer.
267 route ??= FALLBACK
268 return next(e)
269 })
270
271 // A general-purpose subagent runs on the session's routed model, capped at opus.
272 // ponytail: typed subagents (Explore, Plan...) keep their own model; route them too if needed.
273 on('agent.spawn', async ($, e, next) => {
274 if (!current || e.model || e.subagentType !== 'general-purpose' || !e.parentModel.includes('haiku')) return next(e)
275 return next({ ...e, model: MODELS[RANK.indexOf(current) > RANK.indexOf('opus') ? 'opus' : current] })
276 }).catch(($, e, next) => next(e))
277
278 // Main loop only, and only while the session itself is on Haiku: pick another
279 // model in the app and the router steps aside. Every step's usage is recorded.
280 on('turn.step', async function* ($, e, next) {
281 let sent = e
282 if (!e.agentId && e.model.includes('haiku') && (route || current)) {
283 const tier = atLeast(route?.model ?? 'haiku', current)
284 if (tier !== current) {
285 // The first step on a stronger model writes the whole context to its cache once.
286 if (current) {
287 const { context } = await $.session.usage()
288 $.ui.toast(`router: ${current} → ${tier} (cache write ≈$${(((context.tokens ?? 0) * PRICE[tier][1]) / 1e6).toFixed(2)})`)
289 }
290 current = tier
291 }
292 sent = { ...e, model: MODELS[tier], effort: route?.effort ?? e.effort }
293 $.ui.status(`router: ${tier} · ${sent.effort ?? 'default'}${handover ? ` → ${handover}` : ''}`)
294 } else if (!e.agentId) {
295 $.ui.status(undefined)
296 }
297
298 const result = yield* next(sent)
299 if (result.usage) await record($, result.usage)
300 return result
301 })
302}
303