Classifica cada prompt via Vercel AI Gateway e roteia o turno para haiku, sonnet ou opus; /router fixa um tier

Mod do Claude Code que escolhe o modelo de cada turno. Antes de a requisição sair para a Anthropic, o prompt passa por um modelo de decisão (typesafe-ai/jev, via Vercel AI Gateway) que responde qual tier é o mais adequado: haiku para perguntas e edições simples, sonnet para o código do dia a dia, opus para arquitetura e raciocínio longo.
A sessão e o histórico não mudam: o mod só troca o model de cada requisição.
No terminal, em uma sessão do Claude Code:
/plugin install model-router --marketplace Adalink-ai/ada-mods
Responda y em Add marketplace?, escolha o escopo e ajuste as opções na tela de configuração (os padrões funcionam).
Defina AI_GATEWAY_API_KEY (chave do Vercel AI Gateway) no ambiente do shell em que o Claude Code é iniciado. Sem ela o mod não chama o gateway e o turno segue com o modelo atual; a status line mostra router: defina AI_GATEWAY_API_KEY.
A cada prompt seu, a status line mostra a decisão:
router → haiku (confianca 0.97)
O roteador só age em prompts escritos por você (terminal, Remote Control ou SDK). Ficam de fora e seguem com o modelo atual: comandos /…, prompts vazios, notificações de tarefa em segundo plano, disparos de /loop ou agendamentos, mensagens de outras sessões e prompts de plugins. Subagents também mantêm o próprio modelo.
Se o gateway der timeout, erro ou resposta inválida, o turno segue com o modelo atual e a status line avisa. O roteador nunca bloqueia um prompt.
/router| Comando | Efeito |
|---|---|
/router opus | Todos os turnos usam Opus, sem chamar o classificador |
/router sonnet / /router haiku | O mesmo para os outros tiers |
/router auto | Volta ao classificador |
/router | Mostra o estado atual |
Enquanto estiver forçado, a status line mostra router: forçado → opus 🔒. A trava vale só para a sessão em curso: uma sessão nova começa em modo automático.
Cada campo aparece no menu de configuração do Claude Code, ou em settings.json:
{
"pluginConfigs": {
"model-router": {
"opusModel": "claude-opus-5-6"
}
}
}
| Campo | Padrão | Descrição |
|---|---|---|
routerModel | typesafe-ai/jev | Modelo de decisão no AI Gateway |
gatewayUrl | https://ai-gateway.vercel.sh/v1/evaluate | Endpoint de avaliação |
timeoutMs | 4000 | Depois disso o turno segue com o modelo atual |
haikuModel | claude-haiku-4-5-20251001 | Id completo usado no tier haiku |
sonnetModel | claude-sonnet-5-5 | Id completo usado no tier sonnet |
opusModel | claude-opus-5-5 | Id completo usado no tier opus |
Quando sair um modelo novo, troque só o campo do tier. Os ids são completos de propósito: não verificamos se aliases como opus são resolvidos no turn.step.
O texto do seu prompt (até 8.000 caracteres) é enviado ao endpoint configurado em gatewayUrl para ser classificado. Se isso não for aceitável para o seu caso, não instale o mod ou use /router <tier>, que não chama o gateway.
claude plugin validate .
claude plugin test .
claude --plugin-dir .hooks/register.ts 148 lines1import { atom, read, update } from 'claude-code'
2import type { PromptSubmitInput, Register } from 'claude-code'
3
4import type { Route, Tier } from '../types'
5
6const route = atom({ plugin: 'model-router', key: 'route' } as const, null)
7const forced = atom({ plugin: 'model-router', key: 'forced' } as const, null as Tier | null)
8
9// Pergunta "choice" da decision API (/v1/evaluate): cada criterio e uma opcao possivel.
10const QUESTION = {
11 type: 'choice',
12 instructions: 'Qual modelo Claude e o mais adequado para este pedido de um agente de programacao?',
13 criteria: {
14 haiku: 'perguntas simples, consultas rapidas, edicoes triviais, comandos diretos',
15 sonnet: 'tarefas de codigo do dia a dia, bugs comuns, features pequenas/medias',
16 opus: 'arquitetura, refatoracoes grandes, bugs dificeis, raciocinio longo, planejamento',
17 },
18}
19
20const isTier = (v: unknown): v is Tier => v === 'haiku' || v === 'sonnet' || v === 'opus'
21
22// So o que a pessoa escreveu: o composer do terminal, o Remote Control e o host do SDK.
23// Notificacoes de tarefa, /loop agendado, outras sessoes e plugins ficam com o modelo atual.
24const USER_ORIGINS: ReadonlySet<PromptSubmitInput['origin']['kind']> = new Set(['composer', 'bridge', 'sdk'])
25
26const updateStatus = async ($: any) => {
27 const f = await read($, forced)
28 if (f) {
29 $.ui.status(`router: forçado → ${f} 🔒`)
30 } else {
31 $.ui.status(undefined)
32 }
33}
34
35export const register: Register = (on, options) => {
36 const routerModel = String(options.routerModel)
37 const gatewayUrl = String(options.gatewayUrl)
38 const timeoutMs = Number(options.timeoutMs)
39 // Ids completos vindos do userConfig: um modelo novo e so trocar a configuracao.
40 const models: Record<Tier, string> = {
41 haiku: String(options.haikuModel),
42 sonnet: String(options.sonnetModel),
43 opus: String(options.opusModel),
44 }
45
46 on('session.start', async ($, e, next) => {
47 await $.command.register({
48 name: 'router',
49 description: 'Fixa o modelo do turno (haiku, sonnet, opus) ou volta ao modo automático (auto)',
50 })
51 return next(e)
52 })
53
54 on('command.run', { command: 'router' }, async ($, e, next) => {
55 const arg = (e.args || '').trim().toLowerCase()
56
57 if (arg === '') {
58 const f = await read($, forced)
59 return { text: f ? `router: forçado → ${f} 🔒` : 'router: modo automático' }
60 }
61
62 if (arg === 'auto') {
63 await update($, forced, () => null as Tier | null)
64 await updateStatus($)
65 return { text: 'router: modo automático ativado' }
66 }
67
68 if (isTier(arg)) {
69 await update($, forced, () => arg)
70 await updateStatus($)
71 return { text: `router: forçado para ${arg} 🔒` }
72 }
73
74 return { text: `router: opção desconhecida "${arg}". Use: haiku, sonnet, opus, auto` }
75 })
76
77 on('prompt.submit', async ($, e, next) => {
78 const f = await read($, forced)
79
80 // Se forcado, pula o classificador
81 if (f) {
82 $.ui.log(`router: forçado ${f}`, { to: 'debug' })
83 await update($, route, () => ({ tier: f, model: models[f], reason: 'forçado' }))
84 return next(e)
85 }
86
87 // Prompt entregue dentro de um turno em andamento, que nao veio da pessoa, comando ou vazio: nao reroteia.
88 if (e.turnId !== undefined || !USER_ORIGINS.has(e.origin.kind) || e.text.trim() === '' || e.text.startsWith('/')) {
89 $.ui.log(`router: ignorado (origin=${e.origin.kind}, turnId=${e.turnId ?? '-'})`, { to: 'debug' })
90 return next(e)
91 }
92
93 const key = await $.env.get('AI_GATEWAY_API_KEY')
94 if (!key) {
95 $.ui.status('router: defina AI_GATEWAY_API_KEY')
96 return next(e)
97 }
98
99 const ask = $.http.fetch(gatewayUrl, {
100 method: 'POST',
101 headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
102 body: JSON.stringify({
103 model: routerModel,
104 state: `Pedido do usuario: ${e.text.slice(0, 8000)}`,
105 questions: { tier: QUESTION },
106 }),
107 })
108 const res = await Promise.race([ask, $.clock.sleep(timeoutMs).then(() => null)])
109
110 let chosen: Route | null = null
111 if (res === null) {
112 $.ui.status('router: timeout, modelo atual mantido')
113 } else if (!res.ok) {
114 $.ui.status(`router: gateway ${res.status}, modelo atual mantido`)
115 } else {
116 const answer = JSON.parse(res.text)?.answers?.tier
117 const tier: unknown = answer?.choice
118 if (isTier(tier)) {
119 const confidence = Number(answer.confidence)
120 const reason = Number.isFinite(confidence) ? `confianca ${confidence.toFixed(2)}` : ''
121 chosen = { tier, model: models[tier], reason }
122 $.ui.status(`router → ${chosen.tier}${chosen.reason ? ` (${chosen.reason})` : ''}`)
123 } else {
124 $.ui.status('router: resposta invalida, modelo atual mantido')
125 }
126 }
127 $.ui.log(`router: ${chosen ? `${chosen.tier} → ${chosen.model}` : 'sem rota'}`, { to: 'debug' })
128
129 await update($, route, () => chosen)
130 return next(e)
131 }).catch(async ($, e, next) => {
132 // O roteador nunca bloqueia o prompt: em erro, segue com o modelo atual.
133 if (!next.called) {
134 await update($, route, () => null)
135 $.ui.status('router: erro, modelo atual mantido')
136 }
137 return next(e)
138 })
139
140 // Cada requisicao do loop principal sai com o modelo escolhido; subagents ficam como estao.
141 on('turn.step', async function* ($, e, next) {
142 if (e.agentId !== undefined) return yield* next(e)
143 const f = await read($, forced)
144 const chosen = f ? { model: models[f] } : await read($, route)
145 return yield* next(chosen ? { ...e, model: chosen.model } : e)
146 })
147}
148types/index.d.ts 13 lines1export type Tier = 'haiku' | 'sonnet' | 'opus'
2export type Route = { tier: Tier; model: string; reason: string }
3
4// Dentro de `declare module 'claude-code'` o nome `Tier` resolve para um tipo do proprio
5// claude-code; este alias, fora do bloco, e o nosso.
6export type RouterTier = Tier
7
8declare module 'claude-code' {
9 interface PluginState {
10 'model-router': { route: Route | null; forced: RouterTier | null }
11 }
12}
13