Reads each prompt and switches to the smallest Claude model that can handle it, so simple questions don't burn your plan. Toggle with /automodel.

Auto-picks the smallest model that fits each prompt. Two parts: a Claude Code mod (below) and a Codex CLI wrapper and hook (jump to Codex).
A Claude Code mod that reads each prompt and switches to the smallest Claude model that can handle it, so simple questions don't burn through your plan.
On every prompt you type, a local heuristic (no API call, no tokens) picks a tier:
| Tier | Typical prompts |
|---|---|
| Haiku | Short factual questions, "explain X", very short prompts |
| Sonnet | Ordinary coding and editing work (default) |
| Opus | Long or code-heavy prompts with hard-task keywords (architecture, refactor, debug, migrate…), or very long prompts |
Guardrails to avoid thrashing the prompt cache:
| Command | Does |
|---|---|
/automodel | Toggle on/off |
/automodel on / off | Enable / disable |
/automodel status | Settings and the last decision |
/automodel why | Why the last prompt got its model |
/automodel stats | Prompts routed per model, switch count |
/automodel pin <tier> / unpin | Always use one model |
/automodel floor <tier> | Never go below this tier |
/automodel max <tier> | Never go above this tier |
/automodel reset | Clear settings and stats |
Prefix a prompt with @haiku, @sonnet or @opus to force a model for that one prompt (the prefix is stripped).
Settings and stats persist across sessions.
/plugin marketplace add jitenruparelia-rgb/auto-model
/plugin install auto-model --marketplace jitenruparelia-rgb/auto-model
Or for development: claude --plugin-dir /path/to/auto-model.
The routing rules live in hooks/decide.ts. Tests: claude plugin test .
Codex hooks can add context or block a prompt but cannot change the model, so a Codex port works in two ways. Both use the same routing rules as the Claude mod (hooks/decide.ts). Requires Node >= 22.18.
codex-auto wrapper (actually switches the model)Classifies your prompt, then runs codex -m <model> ...:
node codex/bin/codex-auto.mjs "what does git rebase do?"
node codex/bin/codex-auto.mjs exec "refactor the auth module"
node codex/bin/codex-auto.mjs --dry-run exec "what is a mutex?" # show the choice, don't run
Alias it, e.g. alias codex='node /path/to/auto-model/codex/bin/codex-auto.mjs', or npm link for a codex-auto command. It leaves Codex alone when you pass -m, or start interactively with no prompt. Prefix a prompt with @fast, @mid or @max to force a tier (the prefix is stripped). --why prints its decision. It chooses the model when Codex starts, so it cannot switch mid-session.
UserPromptSubmit hook (nudges, inside a session)Tells you when /model <other> would suit the prompt better. Add it to ~/.codex/hooks.json (see codex/hooks.example.json; use the absolute path), then review and trust it with /hooks in Codex.
Optional ~/.config/auto-model/codex.json (or the file named by AUTO_MODEL_CONFIG):
{
"models": { "haiku": "gpt-6-luna", "sonnet": "gpt-6.1-sol", "opus": "gpt-6-astra" },
"pin": null,
"floor": "haiku",
"ceiling": "opus",
"enabled": true
}
AUTO_MODEL_DISABLE=1 turns it off. The defaults are the model ids on the Codex models page at the time of writing; change them if yours differ.
Tests: npm test.
MIT
hooks/register.tsx 163 lines1import type { Register } from 'claude-code'
2import { DEFAULTS, DOWNGRADE_AFTER, TIERS, decide, rank } from './decide'
3import type { Settings, Tier } from './decide'
4
5type Stats = { haiku: number; sonnet: number; opus: number; switches: number }
6type Last = { tier: Tier; reason: string; prompt: string }
7const NO_STATS: Stats = { haiku: 0, sonnet: 0, opus: 0, switches: 0 }
8
9const isTier = (s: string | undefined): s is Tier => TIERS.includes(s as Tier)
10const tierOf = (model: string): Tier | null => TIERS.find(t => model.toLowerCase().includes(t)) ?? null
11
12const HELP = `Auto Model: picks haiku / sonnet / opus per prompt.
13 /automodel toggle on/off
14 /automodel on|off enable / disable
15 /automodel status settings and what it last decided
16 /automodel why why the last prompt got its model
17 /automodel stats prompts routed per model
18 /automodel pin <tier> always use haiku|sonnet|opus (/automodel unpin)
19 /automodel floor <tier> never go below this tier
20 /automodel max <tier> never go above this tier
21 /automodel debug show the model row and last switch error
22 /automodel reset clear settings and stats
23Prefix a prompt with @haiku, @sonnet or @opus to force one for that prompt.`
24
25export const register: Register = on => {
26 const load = async ($: any): Promise<Settings> => ({ ...DEFAULTS, ...((await $.store.get('settings')) as object | undefined) })
27 const save = ($: any, s: Settings) => $.store.set('settings', s)
28
29 /** The /config row that holds the model, with the value it takes for a tier. */
30 const modelRow = async ($: any) => {
31 const rows = (await $.config.list()) as { key: string; label: string; kind: string; value: unknown; options?: string[]; isLocked: boolean }[]
32 return rows.find(r => r.key === 'model') ?? rows.find(r => /model/i.test(r.key) && !/fast|effort/i.test(r.key))
33 }
34 const switchTo = async ($: any, tier: Tier): Promise<string | null> => {
35 const row = await modelRow($)
36 if (!row) return 'no model row in /config'
37 if (row.isLocked) return 'model is locked by policy'
38 const value = row.options?.find(o => o.toLowerCase().includes(tier)) ?? tier
39 const r = await $.config.set({ key: row.key, value })
40 return r.deny === undefined ? null : String(r.deny)
41 }
42
43 on('session.start', async ($, e, next) => {
44 await $.command.register({
45 name: 'automodel',
46 description: 'Toggle Auto Model, or pin / limit the models it picks',
47 argumentHint: '[on|off|status|why|stats|pin <tier>|unpin|floor <tier>|max <tier>|reset]',
48 immediate: true,
49 })
50 return next(e)
51 })
52
53 on('command.run', { command: 'automodel' }, async ($, e) => {
54 const [cmd = '', arg] = e.args.trim().toLowerCase().split(/\s+/)
55 const s = await load($)
56 const say = (text: string) => ({ text })
57
58 switch (cmd) {
59 case '':
60 s.enabled = !s.enabled
61 await save($, s)
62 $.ui.status(s.enabled ? undefined : 'auto-model off')
63 return say(`Auto Model ${s.enabled ? 'on' : 'off'}.`)
64 case 'on':
65 case 'off':
66 s.enabled = cmd === 'on'
67 await save($, s)
68 $.ui.status(s.enabled ? undefined : 'auto-model off')
69 return say(`Auto Model ${cmd}.`)
70 case 'pin':
71 case 'floor':
72 case 'max': {
73 if (!isTier(arg)) return say(`Usage: /automodel ${cmd} <haiku|sonnet|opus>`)
74 if (cmd === 'pin') s.pin = arg
75 else if (cmd === 'floor') s.floor = arg
76 else s.ceiling = arg
77 if (rank(s.floor) > rank(s.ceiling)) return say('Floor cannot be above max.')
78 await save($, s)
79 return say(cmd === 'pin' ? `Pinned to ${arg}.` : `${cmd} set to ${arg}.`)
80 }
81 case 'unpin':
82 s.pin = null
83 await save($, s)
84 return say('Unpinned.')
85 case 'status': {
86 const last = (await $.store.get('last')) as Last | undefined
87 return say(
88 `Auto Model ${s.enabled ? 'on' : 'off'} · pin: ${s.pin ?? 'none'} · range: ${s.floor}–${s.ceiling}` +
89 (last ? `\nLast: ${last.tier} (${last.reason})` : ''),
90 )
91 }
92 case 'why': {
93 const last = (await $.store.get('last')) as Last | undefined
94 return say(last ? `"${last.prompt}" → ${last.tier}: ${last.reason}` : 'Nothing routed yet.')
95 }
96 case 'stats': {
97 const st = { ...NO_STATS, ...((await $.store.get('stats')) as object | undefined) }
98 const total = st.haiku + st.sonnet + st.opus
99 const pct = (n: number) => (total ? Math.round((100 * n) / total) : 0)
100 return say(
101 `Routed ${total} prompts: haiku ${st.haiku} (${pct(st.haiku)}%), sonnet ${st.sonnet} (${pct(st.sonnet)}%), opus ${st.opus} (${pct(st.opus)}%) · ${st.switches} model switches`,
102 )
103 }
104 case 'debug': {
105 const rows = ((await $.config.list()) as any[]).filter(r => /model/i.test(r.key + r.label))
106 const cur = await $.session.model()
107 return say(
108 `session model: ${cur} (tier ${tierOf(cur) ?? 'none'})\nlast switch error: ${(await $.store.get('lastError')) ?? 'none'}\nmodel rows: ` +
109 (rows.length ? rows.map(r => `${r.key} [${r.kind}] = ${JSON.stringify(r.value)} options=${JSON.stringify(r.options ?? null)} locked=${r.isLocked}`).join('; ') : 'none found'),
110 )
111 }
112 case 'reset':
113 await $.store.delete('settings')
114 await $.store.delete('stats')
115 await $.store.delete('last')
116 await $.store.delete('lowStreak')
117 return say('Auto Model settings and stats cleared.')
118 default:
119 return say(HELP)
120 }
121 })
122
123 on('prompt.submit', async ($, e, next) => {
124 // Only the person's own prompts pick a model; slash commands pass through.
125 if ((e.origin.kind !== 'composer' && e.origin.kind !== 'bridge') || e.text.trim().startsWith('/')) return next(e)
126 const s = await load($)
127 if (!s.enabled) return next(e)
128
129 const current = tierOf(await $.session.model())
130 const d = decide(e.text, s, current)
131
132 let target = d.tier
133 let streak = Number((await $.store.get('lowStreak')) ?? 0)
134 const isOverride = d.reason.endsWith('override') || d.reason === 'pinned'
135 if (current && !isOverride && rank(target) < rank(current)) {
136 streak += 1
137 if (streak < DOWNGRADE_AFTER) target = current // hold; don't thrash the cache
138 } else {
139 streak = 0
140 }
141 await $.store.set('lowStreak', streak >= DOWNGRADE_AFTER ? 0 : streak)
142
143 let switched = false
144 if (target !== current) {
145 const err = await switchTo($, target).catch((x: unknown) => `error: ${String(x)}`)
146 switched = err === null
147 if (!switched) {
148 $.ui.status(`auto-model: could not switch to ${target} (${err.slice(0, 60)})`)
149 await $.store.set('lastError', err)
150 }
151 }
152 if (target === current || switched) $.ui.status(`auto-model: ${target}${switched ? ' ↻' : ''}`)
153
154 const st = { ...NO_STATS, ...((await $.store.get('stats')) as object | undefined) } as Stats
155 st[target] += 1
156 if (switched) st.switches += 1
157 await $.store.set('stats', st)
158 await $.store.set('last', { tier: target, reason: d.reason, prompt: d.text.trim().slice(0, 60) } satisfies Last)
159
160 return next(d.text === e.text ? e : { ...e, text: d.text })
161 }).catch(($, e, next) => next(e))
162}
163hooks/decide.ts 62 lines1export type Tier = 'haiku' | 'sonnet' | 'opus'
2export const TIERS: readonly Tier[] = ['haiku', 'sonnet', 'opus']
3export const rank = (t: Tier): number => TIERS.indexOf(t)
4
5export type Settings = {
6 enabled: boolean
7 /** Always use this tier, ignoring the prompt. */
8 pin: Tier | null
9 /** Never go below / above these. */
10 floor: Tier
11 ceiling: Tier
12}
13export const DEFAULTS: Settings = { enabled: true, pin: null, floor: 'haiku', ceiling: 'opus' }
14
15export type Decision = { tier: Tier; reason: string; text: string }
16
17const HARD =
18 /\b(architect(ure)?|design (a|the) system|refactor|migrat(e|ion)|debug|root cause|race condition|deadlock|security (review|audit)|performance|optimi[sz]e|prove|algorithm|trade-?offs?|from scratch|end-to-end|across (the )?(codebase|repo)|implement (a|an|the) \w+|rewrite|concurrency|distributed)\b/i
19const EASY =
20 /^(what|who|when|where|why|how (do|does|to)|explain|define|summari[sz]e|translate|rename|fix (the )?typo|list|show|tell me|is |are |can you (tell|show|explain))\b/i
21const FOLLOW_UP = /^(yes|yep|yeah|no|ok(ay)?|sure|thanks?|thank you|go( ahead| on)?|continue|do it|proceed|lgtm|looks good|y|n)\b[\s.!]*$/i
22const ALIASES: Record<string, Tier> = { haiku: 'haiku', sonnet: 'sonnet', opus: 'opus', fast: 'haiku', mid: 'sonnet', max: 'opus' }
23const OVERRIDE = /^@(haiku|sonnet|opus|fast|mid|max)\b\s*/i
24const CODE_FENCE = /```/
25
26/** Cheap local heuristic: no tokens spent deciding. */
27export function classify(text: string): { tier: Tier; reason: string } {
28 const t = text.trim()
29 const words = t.split(/\s+/).filter(Boolean).length
30 const hard = HARD.test(t)
31 const code = CODE_FENCE.test(t)
32 if (hard && (words > 40 || code)) return { tier: 'opus', reason: 'long or code-heavy hard task' }
33 if (words > 250) return { tier: 'opus', reason: 'very long prompt' }
34 if (hard) return { tier: 'sonnet', reason: 'hard-task keywords, short prompt' }
35 if (words <= 25 && !code && EASY.test(t)) return { tier: 'haiku', reason: 'short factual question' }
36 if (words <= 8 && !code) return { tier: 'haiku', reason: 'very short prompt' }
37 return { tier: 'sonnet', reason: 'ordinary work' }
38}
39
40const clamp = (t: Tier, s: Settings): Tier =>
41 TIERS[Math.min(rank(s.ceiling), Math.max(rank(s.floor), rank(t)))]!
42
43/**
44 * Picks the tier for a prompt. `current` is the tier now in use (null if the
45 * session is on a model outside the three tiers).
46 */
47export function decide(text: string, s: Settings, current: Tier | null): Decision {
48 const m = OVERRIDE.exec(text)
49 if (m) {
50 const name = m[1]!.toLowerCase()
51 return { tier: ALIASES[name]!, reason: `@${name} override`, text: text.slice(m[0].length) }
52 }
53 if (s.pin) return { tier: s.pin, reason: 'pinned', text }
54 if (current && FOLLOW_UP.test(text.trim()))
55 return { tier: clamp(current, s), reason: 'follow-up, keeping model', text }
56 const c = classify(text)
57 return { tier: clamp(c.tier, s), reason: c.reason, text }
58}
59
60/** Switching models re-reads the context uncached: upgrade at once, downgrade after 2 lower prompts in a row. */
61export const DOWNGRADE_AFTER = 2
62