Routes each subagent to the cheapest Claude model that can do its task, judged by TypeSafe Jev

A Claude Code mod that picks the cheapest Claude model for each subagent before it starts. On every subagent dispatch the mod condenses and redacts the task text and asks TypeSafe Jev one Score question. The mod sets the subagent's model: haiku for lookup or transcription, sonnet for bounded judgment, opus for open judgment.
The mod has one hook, on Claude Code's agent.spawn event. That event fires when a subagent is about to start.
In auto mode the hook changes exactly one thing: the model field of that subagent. In suggest mode it changes nothing and writes one dim log line.
The hook reads two values from the plugin's user configuration. They are api_key, which is sensitive and kept in secure storage, and mode. It reads nothing else from the machine.
On each routed dispatch the hook sends one request through Claude Code's own $.http.fetch. It sends the condensed and redacted task text, the subagent's type and the caller's one-line description to https://api.typesafe.ai/v1/systemone. The request is an HTTPS POST with the key as the bearer token. The body also carries the fixed model name and scoring question that ship with the plugin.
The hook runs no commands, spawns no processes and writes no files.
scripts/calibrate.ts is a developer tool that you run by hand from the plugin folder. It asks for the key at a masked prompt and sends the labelled seed to the same host. Claude Code never runs it.
PRIVACY.md states the same in policy form.
/plugin asks for it and keeps it in your system's secure storage./plugin install model-router --marketplace Akurganow/model-router
Answer y to add the marketplace, then pick the user scope.
In a session, run /plugin configure model-router@model-router to open the install dialog again. A saved key takes effect after /reload-plugins or in the next session.
The Routing mode row in /config holds the mode:
suggest (default): log the model Jev would pick, change nothing. One dim line per dispatch shows the pick beside the model that ran.auto: set the model.To turn routing off, disable the plugin in /plugin.
The hook keeps a model passed in the Agent call. In auto mode it overrides a model pinned in an agent definition's frontmatter, because the hook cannot see it. Forks, agent-team teammates and workflow agents pass through untouched.
What the mod does names the host and the request. Two steps run on the subagent's prompt before it leaves. Fenced code blocks become a one-line size note. Token-shaped strings are masked (Bearer …, sk-… keys, assignments to names ending in key, token, secret or password). Nothing else leaves the machine. Any failure, from a missing key to a timeout, leaves the dispatch unchanged.
The mod needs no prompt of its own. It acts whenever Claude Code starts a subagent. Three dispatches and what the mod does with them:
utils.ts exactly as given and run the tests." The task holds the exact code, so Jev scores it level 0. The subagent runs on haiku.sonnet.opus.In suggest mode each of these only logs the pick, for example model-router would pick L0 haiku · p=0.91 · ran on opus. In auto mode the line reads model-router: L0 haiku · p=0.91 and the subagent runs on haiku.
/plugin.model-router: skipped, api_key unset: the key is not set. Run /plugin configure model-router@model-router, enter the key, then /reload-plugins.skipped, HTTP 401: TypeSafe rejected the key. Check it at https://console.typesafe.ai/keys.skipped, no answer in 8000 ms: the request timed out. The dispatch ran unchanged. Check the network and try again.auto mode a model pinned in an agent definition is overridden. Pass model in the Agent call to keep it.calibration.jsonl and reword a tier text in hooks/router.ts. Then run the calibration script.Questions and bug reports go to https://github.com/Akurganow/model-router/issues. The privacy policy is in PRIVACY.md.
From the plugin folder:
claude plugin test .
node scripts/calibrate.ts
The calibration script sends calibration.jsonl, twenty-one labelled briefs, to the same endpoint and exits 1 when agreement falls below 85%. It prompts for the key with masked input, or reads stdin when piped, so the key never appears in command lines or shell history. A miss is fixed by rewording a tier text in hooks/router.ts, never by lowering the bar.
hooks/register.ts 52 lines1import type { Register } from 'claude-code'
2import { route } from './router.ts'
3
4const HTTP_TIMEOUT_MS = 8000
5
6export const register: Register = (on, options) => {
7 const mode = options.mode === 'auto' ? 'auto' : 'suggest'
8 // The key is the plugin's sensitive userConfig option and goes only to TypeSafe, which issued it.
9 const apiKey = typeof options.api_key === 'string' && options.api_key !== '' ? options.api_key : undefined
10
11 on('agent.spawn', async ($, e, next) => {
12 if (e.fork || e.isTeammate || e.workflow !== undefined || e.model !== undefined) return next(e)
13
14 const post = async (body: string): Promise<string> => {
15 if (!apiKey) throw new Error('api_key unset')
16 const stop = new AbortController()
17 const timeout = $.clock.sleep(HTTP_TIMEOUT_MS, { signal: stop.signal })
18 .then(() => { throw new Error(`no answer in ${HTTP_TIMEOUT_MS} ms`) })
19 try {
20 const res = await Promise.race([
21 $.http.fetch('https://api.typesafe.ai/v1/systemone', {
22 method: 'POST',
23 headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
24 body,
25 }),
26 timeout,
27 ])
28 if (!res.ok) throw new Error(`HTTP ${res.status}`)
29 return res.text
30 } finally {
31 stop.abort()
32 }
33 }
34
35 const decision = await route({ role: e.subagentType, description: e.description, prompt: e.prompt }, post)
36 if ('skipped' in decision) {
37 $.ui.log(`model-router: skipped, ${decision.skipped}`)
38 return next(e)
39 }
40 const top = decision.probabilities[String(decision.level)].toFixed(2)
41 const pick = `L${decision.level} ${decision.model} · p=${top}`
42 if (mode === 'suggest') {
43 const r = await next(e)
44 $.ui.log(`model-router would pick ${pick} · ran on ${'deny' in r && r.deny !== undefined ? 'nothing, denied' : r.model}`)
45 return r
46 }
47 const r = await next({ ...e, model: decision.model })
48 $.ui.log(`model-router: ${pick}`)
49 return r
50 }).catch(($, e, next) => next(e))
51}
52hooks/router.ts 80 lines1export const JEV_MODEL = 'jev-1.13.0'
2export const BUDGET_CHARS = 12000
3
4export const TIERS = [
5 { model: 'haiku', text: 'Lookup or transcription: the text holds the exact code, config or prose to write and the commands to run; no choice is left.' },
6 { model: 'sonnet', text: 'Bounded judgment: check given work against stated requirements, fix named defects, or answer a question from a few named files.' },
7 { model: 'opus', text: 'Open judgment: create new content or decide a design from source material, review a whole multi-file change, or debug with an unclear cause.' },
8] as const
9
10const INSTRUCTIONS = 'How much judgment does completing `task` require from an agent whose role is `role`? `description` is the caller\'s one-line summary of the task.'
11
12// The task text goes to a third party, and nothing marks which strings are secrets, so redaction matches shapes.
13const REDACT_PATTERNS: readonly [RegExp, string][] = [
14 [/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer ***'],
15 [/\bsk-[A-Za-z0-9_-]{8,}/g, '***'],
16 [/\b([A-Za-z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD))(["']?\s*[=:]\s*)(?:"[^"]*"?|'[^']*'?|[^\s"']+)/gi, '$1$2***'],
17]
18
19export type RouteInput = { role: string; description: string; prompt: string }
20export type Post = (body: string) => Promise<string>
21export type Decision = { model: string; level: number; probabilities: Record<string, number>; confidence: number }
22export type Skipped = { skipped: string }
23
24export async function route(input: RouteInput, post: Post): Promise<Decision | Skipped> {
25 const task = redact(condense(input.prompt))
26 const body = JSON.stringify({
27 model: JEV_MODEL,
28 state: { role: input.role, description: input.description, task },
29 questions: { tier: { type: 'score', instructions: INSTRUCTIONS, criteria: TIERS.map(t => t.text) } },
30 })
31 let text: string
32 try {
33 text = await post(body)
34 } catch (err) {
35 return { skipped: err instanceof Error ? err.message : String(err) }
36 }
37 const parsed = parse(text)
38 if (!parsed) return { skipped: 'malformed answer' }
39 const level = argmax(parsed.probabilities)
40 return { model: TIERS[level].model, level, probabilities: parsed.probabilities, confidence: parsed.confidence }
41}
42
43function condense(text: string): string {
44 return text
45 .replace(/```[^\n]*\n([\s\S]*?)```/g, (_, block: string) => {
46 const lines = block.replace(/\n$/, '').split('\n').length
47 return `[an exact block of ${lines} lines is given verbatim]`
48 })
49 .replace(/\n{3,}/g, '\n\n')
50 .slice(0, BUDGET_CHARS)
51}
52
53function redact(text: string): string {
54 return REDACT_PATTERNS.reduce((t, [pattern, replacement]) => t.replace(pattern, replacement), text)
55}
56
57function parse(text: string): { probabilities: Record<string, number>; confidence: number } | undefined {
58 try {
59 const tier = JSON.parse(text)?.answers?.tier
60 if (tier?.type !== 'score' || typeof tier.confidence !== 'number' || typeof tier.probabilities !== 'object') return undefined
61 const probabilities: Record<string, number> = {}
62 for (let i = 0; i < TIERS.length; i++) {
63 const p = tier.probabilities[String(i)]
64 if (typeof p !== 'number') return undefined
65 probabilities[String(i)] = p
66 }
67 return { probabilities, confidence: tier.confidence }
68 } catch {
69 return undefined
70 }
71}
72
73function argmax(probabilities: Record<string, number>): number {
74 let best = 0
75 for (let i = 1; i < TIERS.length; i++) {
76 if (probabilities[String(i)] > probabilities[String(best)]) best = i
77 }
78 return best
79}
80