Asks TypeSafe Jev which Claude model each prompt needs, then routes the turn to it (auto) or shows advice (advise).

Before Claude works on your prompt, this mod asks TypeSafe Jev which Claude model the prompt really needs (Haiku, Sonnet or Opus).
/config → jev-router: Mode)/model).Jev → Haiku (95%) · chat is small, switching is cheap · 210 ms [Hide]
You need Claude Code (the desktop app or the terminal) and your own TypeSafe Jev API key. No key is included here.
In a terminal session, at the prompt:
/plugin install jev-router --marketplace sebasvermeir-sys/jev-router
Answer y to add the marketplace, pick the user scope (Enter), choose a mode (keep auto). From a shell instead:
claude plugin install jev-router --marketplace sebasvermeir-sys/jev-router
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with sebasvermeir-sys/jev-router, then Add plugin.
Then add your key (next section) and open a new chat.
The key never goes in a chat. The mod looks in two places, in this order:
security add-generic-password -a "$USER" -s typesafe-jev -w
~/.jev/api_key containing only the key.Get a key at https://console.typesafe.ai/.
| File | What it is |
|---|---|
hooks/register.tsx | The mod itself: the prompt hook, the Jev call, the band |
types/index.d.ts | The shape of the values the band shows |
.claude-plugin/plugin.json | The mod's name card |
~/.jev/router-log.jsonl | One line per prompt: Jev's pick, confidence, speed |
With this mod on, every prompt you type is also sent to TypeSafe. Turn the mod off for sensitive work.
A collapsible band above the prompt that shows jev-router at work, read from ~/.jev/router-log.jsonl (local only, no network):
▸ Jev, the mode, and the last pick → model used, e.g. Haiku 92 % → Opus▸ Jev or type /jev): last prompt in detail, the Haiku / Sonnet / Opus split of Jev's picks, model switches, average latency, and the last 12 prompts.It draws above the other plugins' bands (e.g. Ribbon) without hiding them.
Install: /plugin install jev-monitor@jev-router (desktop app: + → Plugins).
hooks/register.tsx 257 lines1// jev-router: before each prompt runs, ask TypeSafe Jev which Claude model
2// it needs.
3// - auto (default): the turn's model requests go to that model, no click.
4// - advise: only shows the advice, with a button to switch for next time.
5// - off: does nothing.
6// Pick the mode in /config (jev-router: Mode).
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register } from 'claude-code'
9
10import type { Advice, LastTurn, Tier } from '../types'
11
12const last = atom({ plugin: 'jev-router', key: 'last' } as const, null as Advice | null)
13const isHidden = atom({ plugin: 'jev-router', key: 'isHidden' } as const, false)
14const lastTurn = atom({ plugin: 'jev-router', key: 'lastTurn' } as const, null as LastTurn | null)
15
16const JEV_URL = 'https://api.typesafe.ai/v1/systemone'
17const KEYCHAIN_SERVICE = 'typesafe-jev'
18// Jev gets at most 1.5 s; past that the prompt goes on without advice
19const TIMEOUT_MS = 1500
20// A smaller model needs Jev to be this sure
21const MIN_CONFIDENCE = 0.7
22// A bigger model needs less: Jev is less sure on hard prompts, and a
23// too-small model costs quality while a too-big one only costs money
24const MIN_UPGRADE_CONFIDENCE = 0.45
25// The prompt cache stays warm for about an hour in this app
26const CACHE_TTL_MS = 55 * 60 * 1000
27// A chat this small costs next to nothing to re-read on another model
28const SMALL_CONTEXT_TOKENS = 20000
29// Never move a chat bigger than this down, even with a cold cache
30const MAX_DOWNGRADE_TOKENS = 100000
31// Jev needs an extra header past long inputs, so send the first part only
32const MAX_PROMPT_CHARS = 4000
33
34const MODEL_ID: Record<Tier, string> = {
35 haiku: 'claude-haiku-5-5',
36 sonnet: 'claude-sonnet-5-5',
37 opus: 'claude-opus-5-5',
38}
39const ORDER: Record<Tier, number> = { haiku: 0, sonnet: 1, opus: 2 }
40
41const CRITERIA: Record<Tier, string> = {
42 haiku: 'Simple, fixed-step task: spelling, formatting, renaming, a short factual lookup, filing a note, a yes/no question.',
43 sonnet: 'Moderate task: drafting or editing text, summarizing, routine coding, a clear multi-step task with known steps.',
44 opus: 'Hard task: planning, architecture, research, debugging something unclear, judgment calls, ambiguous or open-ended goals.',
45}
46
47// The key is read once per load: from the macOS Keychain, else ~/.jev/api_key
48let apiKey: string | undefined
49
50function tierOf(model: string): Tier | 'other' {
51 const m = model.toLowerCase()
52 if (m.includes('haiku')) return 'haiku'
53 if (m.includes('sonnet')) return 'sonnet'
54 if (m.includes('opus')) return 'opus'
55 return 'other'
56}
57
58function label(t: Tier | 'other'): string {
59 return t === 'other' ? 'this model' : t.charAt(0).toUpperCase() + t.slice(1)
60}
61
62async function readKey($: EngineInterface): Promise<string | undefined> {
63 if (apiKey) return apiKey
64 try {
65 const r = await $.process.run(['security', 'find-generic-password', '-s', KEYCHAIN_SERVICE, '-w'], { timeoutMs: 3000 })
66 if (r.exitCode === 0 && r.stdout.trim()) apiKey = r.stdout.trim()
67 } catch {}
68 if (!apiKey) {
69 try {
70 const home = await $.env.get('HOME')
71 const text = await $.fs.read(`${home}/.jev/api_key`)
72 if (text.trim()) apiKey = text.trim()
73 } catch {}
74 }
75 return apiKey
76}
77
78// One JSON line per prompt in ~/.jev/router-log.jsonl, newest 1,000 kept
79async function log($: EngineInterface, row: object): Promise<void> {
80 try {
81 const home = await $.env.get('HOME')
82 const path = `${home}/.jev/router-log.jsonl`
83 let old = ''
84 try {
85 old = await $.fs.read(path)
86 } catch {}
87 const lines = old.split('\n').filter(Boolean).slice(-999)
88 lines.push(JSON.stringify(row))
89 await $.fs.write(path, lines.join('\n') + '\n')
90 } catch {}
91}
92
93export const register: Register = (on, options) => {
94 const mode = String(options.mode ?? 'auto')
95 if (mode === 'off') return
96
97 // The model this turn's requests go to; null leaves the engine's choice
98 let route: Tier | null = null
99
100 on('prompt.submit', async ($, e, next) => {
101 // Only your own typed prompts: no slash commands, hidden or plugin prompts
102 const kind = e.origin.kind
103 const isOwnPrompt = kind === 'composer' || kind === 'bridge' || kind === 'sdk'
104 if (!isOwnPrompt || e.command || e.isMeta || !e.text.trim()) return next(e)
105
106 const turn = await read($, lastTurn)
107 // The model the chat is really on: the one that answered last, else the picker's
108 const sessionTier = tierOf(await $.session.model())
109 const current = turn && turn.tier !== 'other' ? turn.tier : sessionTier
110 // Until Jev answers, stay on the model the cache is warm for
111 route = current === 'other' ? null : current
112
113 const key = await readKey($)
114 if (!key) {
115 $.ui.status('Jev: no API key found (see README)')
116 return next(e)
117 }
118
119 const startedAt = await $.clock.now()
120 const request = $.http
121 .fetch(JEV_URL, {
122 method: 'POST',
123 headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
124 body: JSON.stringify({
125 model: 'jev-latest',
126 state: e.text.slice(0, MAX_PROMPT_CHARS),
127 questions: {
128 model: {
129 type: 'choice',
130 criteria: CRITERIA,
131 instructions: 'Pick the smallest Claude model that would handle this prompt well.',
132 },
133 },
134 }),
135 })
136 .catch(() => null)
137 const timeout = $.clock.sleep(TIMEOUT_MS).then(() => null)
138 const res = await Promise.race([request, timeout])
139 const ms = Math.round((await $.clock.now()) - startedAt)
140
141 if (!res || !res.ok) {
142 $.ui.status(res ? `Jev: error ${res.status}` : 'Jev: too slow, skipped')
143 await log($, { at: startedAt, error: res ? res.status : 'timeout', ms })
144 return next(e)
145 }
146
147 try {
148 const answer = JSON.parse(res.text).answers.model as { choice: Tier; confidence: number }
149 const tokens = turn?.contextTokens ?? 0
150 const isCold = turn === null || startedAt - turn.at > CACHE_TTL_MS
151 const isSmall = tokens < SMALL_CONTEXT_TOKENS
152 const isUpgrade = current !== 'other' && ORDER[answer.choice] > ORDER[current]
153 const needed = isUpgrade ? MIN_UPGRADE_CONFIDENCE : MIN_CONFIDENCE
154
155 let note: string
156 let isCheap = false
157 if (answer.choice === current) {
158 note = 'right model'
159 } else if (answer.confidence < needed) {
160 note = `not sure enough, stays on ${label(current)}`
161 } else if (isUpgrade) {
162 note = 'needs a bigger model'
163 isCheap = true
164 } else if (isSmall || (isCold && tokens < MAX_DOWNGRADE_TOKENS)) {
165 note = isSmall ? 'chat is small, switching is cheap' : 'cache is cold, switching is cheap'
166 isCheap = true
167 } else {
168 note = `cache is warm (${Math.round(tokens / 1000)}k tokens), stays on ${label(current)}`
169 }
170
171 if (mode === 'auto' && (isCheap || answer.choice === current)) route = answer.choice
172 const advice: Advice = {
173 pick: answer.choice,
174 confidence: answer.confidence,
175 current,
176 route: mode === 'auto' ? route : null,
177 note,
178 canSwitch: mode === 'advise' && isCheap,
179 ms,
180 }
181 await update($, last, () => advice)
182 await update($, isHidden, () => false)
183 $.ui.status(undefined)
184 await log($, { at: startedAt, mode, ...advice, chars: e.text.length })
185 } catch {
186 $.ui.status('Jev: could not read the answer')
187 }
188
189 return next(e)
190 }).catch(($, e, next) => next(e))
191
192 // The chat's size as the last model request of the main loop saw it. A
193 // turn's own usage adds up every request in it, so it overstates the chat.
194 let lastStepTokens: number | null = null
195
196 // Auto mode: send every request of the main turn to the routed model
197 on('turn.step', async function* ($, e, next) {
198 if (e.agentId !== undefined) return yield* next(e)
199 const target = mode === 'auto' ? route : null
200 // Haiku takes no effort setting; the bigger models keep the session's
201 const step =
202 target === null || tierOf(e.model) === target
203 ? e
204 : { ...e, model: MODEL_ID[target], effort: target === 'haiku' ? undefined : e.effort }
205 const result = yield* next(step)
206 const u = result.usage
207 if (u) lastStepTokens = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
208 return result
209 })
210
211 // Remember how big the chat is, when it last ran and on which model
212 on('turn.complete', async ($, e, next) => {
213 const result = await next(e)
214 const u = result.usage
215 if (u) {
216 const contextTokens =
217 lastStepTokens ?? u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
218 const at = await $.clock.now()
219 await update($, lastTurn, () => ({ at, contextTokens, tier: tierOf(u.model) }))
220 }
221 return result
222 })
223
224 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
225 const advice = await read($, last)
226 if (e.props.hasSurvey || advice === null || (await read($, isHidden))) return next(e)
227
228 const { Box, Button, Text } = $.ui.resolve(e)
229 const pct = Math.round(advice.confidence * 100)
230 const headline =
231 advice.route !== null
232 ? `Jev → ${label(advice.route)}${advice.route === advice.pick ? '' : ` (picked ${label(advice.pick)})`} (${pct}%)`
233 : `Jev: ${label(advice.pick)} ${advice.pick === advice.current ? '✓' : 'would do'} (${pct}%)`
234
235 return (
236 <Box>
237 <Text>{headline}</Text>
238 <Text dimColor>
239 {' '}· {advice.note} · {advice.ms} ms{' '}
240 </Text>
241 {advice.canSwitch ? (
242 <Button
243 key="switch"
244 variant="primary"
245 label={`Use ${label(advice.pick)} next`}
246 onPress={async () => {
247 await $.command.run({ command: 'model', args: advice.pick })
248 await update($, isHidden, () => true)
249 }}
250 />
251 ) : null}
252 <Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
253 </Box>
254 )
255 })
256}
257types/index.d.ts 30 lines1export type Tier = 'haiku' | 'sonnet' | 'opus'
2
3export type Advice = {
4 // What Jev picked, and how sure it was (0 to 1)
5 pick: Tier
6 confidence: number
7 // The model the chat was on before this prompt
8 current: Tier | 'other'
9 // The model this turn runs on (auto mode), or null to leave it alone
10 route: Tier | null
11 // Why, in plain words
12 note: string
13 // Advise mode: whether the band offers a switch button
14 canSwitch: boolean
15 ms: number
16}
17
18// What the last finished turn tells us about the chat and its cache
19export type LastTurn = { at: number; contextTokens: number; tier: Tier | 'other' }
20
21declare module 'claude-code' {
22 interface PluginState {
23 'jev-router': {
24 last: Advice | null
25 isHidden: boolean
26 lastTurn: LastTurn | null
27 }
28 }
29}
30