질문 크기에 따라 메인 대화의 모델(haiku·sonnet·opus)을 턴마다 자동으로 고른다

질문 크기에 따라 Claude Code 메인 대화의 모델(haiku · sonnet · opus)을 질문마다 자동으로 고르는 mod입니다.
/plugin install auto-model --marketplace leeruji/claude-auto-model
!opus, !sonnet, !haiku: 그 질문 하나만 해당 모델로/route: 상태와 최근 판정 10개 보기/route off, /route on: 자동 선택 끄기 · 켜기/route pin <opus|sonnet|haiku>: 한 모델로 고정hooks/register.ts 162 lines1import type { EngineInterface, Register } from 'claude-code'
2
3export type Tier = 'haiku' | 'sonnet' | 'opus'
4type Mode = { kind: 'auto' } | { kind: 'off' } | { kind: 'pin'; tier: Tier }
5type Entry = { at: string; tier: Tier; why: string; text: string }
6
7const RANK: Record<Tier, number> = { haiku: 0, sonnet: 1, opus: 2 }
8const MODEL: Record<Tier, string> = {
9 haiku: 'claude-haiku-5-5',
10 sonnet: 'claude-sonnet-5-5',
11 opus: 'claude-opus-5-5',
12}
13// 이만큼 쉬면 prompt cache 가 식어서, 모델을 내려도 다시 읽는 손해가 없다
14const COLD_MS = 5 * 60_000
15// 가벼운 판정이 이만큼 이어져야 내린다 (캐시를 자주 버리지 않으려고)
16const DOWN_AFTER = 2
17
18const BIG = /설계|구조|아키텍처|리팩터|마이그레이션|전체|기획|계획|원인|디버그|분석/
19const WORK = /고쳐|수정|만들|구현|추가|짜 ?줘|바꿔|작성|테스트|커밋|```/
20const OVERRIDE = /^!(opus|sonnet|haiku)\b\s*/
21
22// shortcut: 규칙만으로 판정, /route 기록에서 !지정이 자주 보이면 haiku 분류를 붙인다
23export function classify(text: string): [Tier, string] {
24 if (BIG.test(text)) return ['opus', '큰 작업 낱말']
25 if (text.length > 1500) return ['opus', '긴 글']
26 if (text.length < 80 && !WORK.test(text)) return ['haiku', '짧은 질문']
27 return ['sonnet', '보통 작업']
28}
29
30// 올리기는 바로, 내리기는 가벼운 판정이 이어지거나 캐시가 식었을 때만
31export function decide(
32 cur: Tier | undefined,
33 want: Tier,
34 streak: number,
35 idleMs: number,
36): { tier: Tier; streak: number } {
37 if (cur === undefined || RANK[want] >= RANK[cur]) return { tier: want, streak: 0 }
38 if (idleMs > COLD_MS || streak + 1 >= DOWN_AFTER) return { tier: want, streak: 0 }
39 return { tier: cur, streak: streak + 1 }
40}
41
42async function getMode($: EngineInterface): Promise<Mode> {
43 return ((await $.store.get('mode')) as Mode | undefined) ?? { kind: 'auto' }
44}
45
46export const register: Register = on => {
47 // 다시 로드되면 처음부터: 첫 턴은 판정대로 간다
48 let current: Tier | undefined
49 let streak = 0
50 let lastEnd = 0
51 let seenModel: string | undefined
52 let paused = false
53
54 on('session.start', async ($, e, next) => {
55 await $.command.register({
56 name: 'route',
57 description: '자동 모델 선택: 상태·기록 보기, on | off | pin <opus|sonnet|haiku>',
58 })
59 return next(e)
60 })
61
62 on('prompt.submit', async ($, e, next) => {
63 // 사람이 친 메시지만 판정한다 (데스크톱 앱은 sdk 로 들어온다)
64 const isTyped = ['composer', 'bridge', 'sdk'].includes(e.origin.kind)
65 if (e.command || e.isMeta || !isTyped) return next(e)
66
67 // 루지가 앱에서 직접 모델을 바꿨으면 그 선택을 존중해 쉰다
68 const model = await $.session.model()
69 if (seenModel !== undefined && model !== seenModel && !paused) {
70 paused = true
71 $.ui.toast('앱에서 모델을 바꿔서 자동 선택을 쉬어요. /route on 으로 다시 켜요')
72 }
73 seenModel = model
74
75 const mode = await getMode($)
76 if (paused || mode.kind === 'off') {
77 current = undefined
78 $.ui.status(undefined)
79 return next(e)
80 }
81
82 const forced = OVERRIDE.exec(e.text)
83 const text = forced ? e.text.slice(forced[0].length) : e.text
84 const before = current
85 let label: string
86 let why: string
87
88 if (forced) {
89 current = forced[1] as Tier
90 label = '지정'
91 why = '!지정'
92 } else if (mode.kind === 'pin') {
93 current = mode.tier
94 label = '고정'
95 why = '고정'
96 } else {
97 const [want, reason] = classify(text)
98 const idle = lastEnd === 0 ? 0 : (await $.clock.now()) - lastEnd
99 const d = decide(current, want, streak, idle)
100 current = d.tier
101 streak = d.streak
102 label = '자동'
103 why = d.tier === want ? reason : `${reason}, 아직 ${d.tier} 유지`
104 }
105
106 $.ui.status(`🤖 ${current}로 답함 (${label}) · 설정은 ${await $.session.model()}`)
107 if (before !== undefined && before !== current) {
108 $.ui.toast(`${why} → ${current}로 ${RANK[current] > RANK[before] ? '올렸어요' : '내렸어요'}`)
109 }
110
111 const log = ((await $.store.get('log')) as Entry[] | undefined) ?? []
112 const entry: Entry = { at: new Date(await $.clock.now()).toISOString(), tier: current, why, text: text.slice(0, 40) }
113 await $.store.set('log', [...log, entry].slice(-100))
114
115 return next(forced ? { ...e, text } : e)
116 }).catch(($, e, next) => next(e)) // 판정이 실패해도 메시지는 그대로 나간다
117
118 on('turn.complete', async ($, e, next) => {
119 if (!e.agentId) lastEnd = await $.clock.now()
120 return next(e)
121 })
122
123 // 메인 대화 요청만 바꾸고, 서브에이전트는 원래 모델 그대로
124 on('turn.step', async function* ($, e, next) {
125 if (e.agentId || current === undefined) return yield* next(e)
126 return yield* next({ ...e, model: MODEL[current] })
127 })
128
129 on('command.run', { command: 'route' }, async ($, e) => {
130 const [arg, tier] = e.args.trim().split(/\s+/)
131
132 if (arg === 'on' || arg === 'unpin') {
133 paused = false
134 await $.store.set('mode', { kind: 'auto' })
135 return { text: '자동 모델 선택을 켰어요. 다음 메시지부터 적용돼요.' }
136 }
137 if (arg === 'off') {
138 await $.store.set('mode', { kind: 'off' })
139 current = undefined
140 $.ui.status(undefined)
141 return { text: '자동 모델 선택을 껐어요. 앱에서 고른 모델 그대로 가요.' }
142 }
143 if (arg === 'pin') {
144 if (tier !== 'opus' && tier !== 'sonnet' && tier !== 'haiku') {
145 return { text: '/route pin opus | sonnet | haiku 중 하나로 써 주세요.' }
146 }
147 paused = false
148 await $.store.set('mode', { kind: 'pin', tier })
149 return { text: `${tier}로 고정했어요. /route on 으로 자동으로 돌아가요.` }
150 }
151
152 const mode = await getMode($)
153 const state = paused ? '쉬는 중 (앱에서 모델을 바꿈)'
154 : mode.kind === 'off' ? '꺼짐'
155 : mode.kind === 'pin' ? `${mode.tier} 고정`
156 : '자동'
157 const log = ((await $.store.get('log')) as Entry[] | undefined) ?? []
158 const rows = log.slice(-10).map(x => `${x.at.slice(5, 16).replace('T', ' ')} ${x.tier.padEnd(6)} ${x.why} · ${x.text}`)
159 return { text: [`상태: ${state}${current ? ` · 지금 ${current}` : ''}`, ...rows].join('\n') }
160 })
161}
162