Routes each model request from turn.step: subagent steps to haiku, a short first prompt to low effort, and /route <model|auto> to pin the main loop. Writes…

turn.step を使って、モデルへのリクエストを 1 回ずつ振り分ける Mod。
| # | 条件 | 書き換え |
|---|---|---|
| 1 | e.agentId がある(サブエージェントのループ) | model を claude-haiku-4-5-20251001 に |
| 2 | メインループで e.index === 0、直前のプロンプトが 80 文字未満 | effort を low に |
| 3 | /route <model> で上書きが設定済み | メインループの model をその値に |
ルール 3 の上書きは $.store に残るので、次のセッションでも効く。/route auto で解除。
claude --plugin-dir C:/Users/lifes/kasegu/mods/model-router
グローバル設定の env に CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 が必要。
turn.step が 1 回走るたびに PROOF-steps.log に 1 行追記される。
index=0 | loop=a16bed91c0e2520c2 | rule=subagent | model=claude-opus-5[1m] -> claude-haiku-4-5-20251001 | effort=high -> high | messages=10 | answered=claude-haiku-4-5-20251001 | stop=end_turn | tools=0 | trace=jev-model-router/user:returned > engine/core:returned
answered= は結果の usage.model、つまり 実際に答えたモデル。ここが書き換え後の id になっていれば、書き換えが API まで届いたということ。
turn.step の model は そのまま API に送られる。'haiku' のようなエイリアスは 解決されず、リクエストが 404 (not_found_error, model: haiku) で落ちる。だから この Mod は MODEL_IDS で id に直してから渡している。詳細は NOTES.md。
claude plugin validate .
claude plugin test .hooks/register.ts 96 lines1import type { Register } from 'claude-code'
2
3// `turn.step`'s `model` reaches the API verbatim: nothing resolves an alias
4// on the way down. `model: 'haiku'` makes the request itself 404 with
5// `not_found_error, model: haiku`, so a router writes ids, not family names.
6const MODEL_IDS: Record<string, string> = {
7 haiku: 'claude-haiku-4-5-20251001',
8 sonnet: 'claude-sonnet-5',
9 opus: 'claude-opus-5',
10}
11const modelId = (name: string): string => MODEL_IDS[name.trim().toLowerCase()] ?? name
12const SUBAGENT_MODEL = modelId('haiku')
13const SHORT_PROMPT = 80
14
15export const register: Register = on => {
16 let override: string | null = null
17 let lastPromptLength = 0
18 const log: string[] = []
19
20 on('session.start', async ($, e, next) => {
21 const r = await next(e)
22 const saved = await $.store.get('override')
23 override = typeof saved === 'string' ? saved : null
24 await $.command.register({
25 name: 'route',
26 description: 'Pin the main loop to a model, or hand it back (model-router)',
27 argumentHint: '<model | auto>',
28 })
29 return r
30 })
31
32 on('command.run', { command: 'route' }, async ($, e) => {
33 const arg = String(e.args ?? '').trim()
34 if (arg === 'auto') {
35 override = null
36 await $.store.delete('override')
37 } else if (arg !== '') {
38 // Resolved here, so what the person is told is what the API is sent.
39 override = modelId(arg)
40 await $.store.set('override', override)
41 }
42 return {
43 text: override
44 ? `model-router: the main loop runs on ${override}`
45 : 'model-router: the main loop keeps the model it asked for',
46 }
47 })
48
49 // The length of the last prompt is the only thing rule 2 needs, and a
50 // length is not the person's text, so nothing of the prompt is kept.
51 on('prompt.submit', async ($, e, next) => {
52 lastPromptLength = e.text.length
53 return next(e)
54 })
55
56 on('turn.step', async function* ($, e, next) {
57 let model = e.model
58 let effort = e.effort
59 let rule = 'none'
60 if (e.agentId !== undefined) {
61 if (model !== SUBAGENT_MODEL) {
62 model = SUBAGENT_MODEL
63 rule = 'subagent'
64 }
65 } else if (override !== null) {
66 model = override
67 rule = 'override'
68 } else if (e.index === 0 && lastPromptLength > 0 && lastPromptLength < SHORT_PROMPT) {
69 effort = 'low'
70 rule = 'short-prompt'
71 }
72
73 const r = yield* next({ ...e, model, effort })
74
75 const trace = next.trace
76 .map(t => `${t.plugin}/${t.tier}:${t.outcome}${t.chunks === undefined ? '' : `(${t.chunks}c)`}`)
77 .join(' > ')
78 log.push(
79 [
80 `index=${e.index}`,
81 `loop=${e.agentId ?? 'main'}`,
82 `rule=${rule}`,
83 `model=${e.model} -> ${model}`,
84 `effort=${String(e.effort)} -> ${String(effort)}`,
85 `messages=${e.messageCount}`,
86 `answered=${r.usage === null ? 'none' : r.usage.model}`,
87 `stop=${String(r.stopReason)}`,
88 `tools=${r.toolUses.length}`,
89 `trace=${trace}`,
90 ].join(' | '),
91 )
92 await $.fs.write(`${$.plugin.root}/PROOF-steps.log`, `${log.join('\n')}\n`)
93 return r
94 })
95}
96