SLOPSHOPPER

jev-router

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

newbandstatuspromptprocessnetwork
v0.2.1MITupdated 2026-10-24sebasvermeir-sys/jev-router
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jev-router
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ jev-router: Jev: no API key found (see README)
README

jev-router

Before Claude works on your prompt, this mod asks TypeSafe Jev which Claude model the prompt really needs (Haiku, Sonnet or Opus).

Modes (pick in /config → jev-router: Mode)

  • auto (default): the turn runs on Jev's pick, no click. It changes the model of each request in that turn; the model picker keeps showing your own choice, the band shows what really ran.
  • advise: only shows the advice, with a Use X next button (runs /model).
  • off: does nothing.

What you see

Jev → Haiku (95%) · chat is small, switching is cheap · 210 ms [Hide]

When it switches (auto) or offers to (advise)

  • Up (to a bigger model): whenever Jev is 45%+ sure. Quality first.
  • Down (to a smaller model): only when Jev is 70%+ sure and switching is cheap: the chat is still small (under ~20k tokens), or its cache went cold (idle about an hour) and the chat is under 100k tokens.
  • Otherwise it stays on the model that answered last, so the cache stays warm.
  • Subagents are left alone.

Install

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.

Setup: the API key

The key never goes in a chat. The mod looks in two places, in this order:

  1. macOS Keychain (recommended). In Terminal, run the line below. It asks for the key without showing it, so it never lands in your shell history:
   security add-generic-password -a "$USER" -s typesafe-jev -w
  1. A file: ~/.jev/api_key containing only the key.

Get a key at https://console.typesafe.ai/.

Files

FileWhat it is
hooks/register.tsxThe mod itself: the prompt hook, the Jev call, the band
types/index.d.tsThe shape of the values the band shows
.claude-plugin/plugin.jsonThe mod's name card
~/.jev/router-log.jsonlOne line per prompt: Jev's pick, confidence, speed

Rules it follows

  • Jev gets 1.5 s. Slower, down, or no key: your prompt goes on unchanged.
  • It skips slash commands, hidden prompts and other plugins' prompts.
  • Only the first 4,000 characters of a prompt are sent to Jev.

Privacy

With this mod on, every prompt you type is also sent to TypeSafe. Turn the mod off for sensitive work.

jev-monitor (optional companion)

A collapsible band above the prompt that shows jev-router at work, read from ~/.jev/router-log.jsonl (local only, no network):

  • Collapsed: ▸ Jev, the mode, and the last pick → model used, e.g. Haiku 92 % → Opus
  • Expanded (click ▸ 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).

Source 2 files
hooks/register.tsx 257 lines
1// 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}
257
types/index.d.ts 30 lines
1export 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