SLOPSHOPPER

auto-model-detector

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

newcommandtoaststatuspromptmodel
v0.3.3no licenseupdated 2026-10-11fatihkartal35/claude-mods/auto-model-detector
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · auto-model-detector
› 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 › /auto-model-detector ⎿ auto-model-detector: router: off ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Claude Code mods by Fatih Kartal. / Fatih Kartal'ın Claude Code modları.

auto-model-detector

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.

Install / Kurulum

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.

How it routes / Nasıl yönlendirir

  • The first prompt picks the model; afterwards the model only moves up (a model switch rewrites the prompt cache). Effort changes freely every turn. /clear frees the choice again.
  • Money, payments, auth, security → at least opus · medium. Vague bug reports → opus · high.
  • Commit, push, deploy, OTA → effort low, model unchanged.
  • Short follow-ups ("devam", "go on") keep the previous route.
  • Naming a model or effort in the prompt ("opus ile", "max effort") wins.
  • Before xhigh/max effort or a switch to Fable, it asks you. Both are on by default; change them with /auto-model-detector confirm. If the dialog cannot open, it falls back to high / opus and says so.
  • When the quick (low-effort) answer is unsure, Haiku is asked again at medium effort; if that is unsure too, or there is no usable answer, it lands on sonnet · medium (never below the session's current model).
  • Does nothing unless the session's own model is Haiku.
  • General-purpose subagents follow the session's model (capped at opus); typed subagents (Explore, Plan …) keep their own model.

What the classifier sees / Sınıflandırıcıya ne gider

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.

Hidden notes to the model / Modele görünmeyen notlar

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.

Commands / Komutlar

Command
/auto-model-detectormenu / menü
/auto-model-detector on, /auto-model-detector offturn the router on / off
/auto-model-detector confirmchoose what to confirm (high effort, Fable)
/auto-model-detector statstokens and estimated cost per model
/auto-model-detector stats resetclear 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).

Cost / Maliyet

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.

Customize / Özelleştir

Edit RULES in auto-model-detector/hooks/register.ts, then run /reload-plugins.

Source 1 files
hooks/register.ts 303 lines
1import 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