Sends each prompt and each subagent to the model and effort its task deserves, with guards that raise a turn when quality is at risk.

A Claude Code plugin that sends each prompt, and each subagent, to the model and effort its job deserves. Leave your session on your strongest model and stop paying for it on small jobs, without risking the big ones.
For every prompt you type and every subagent the session starts, the router picks a model (Haiku, Sonnet or Opus) and an effort level and applies them to that turn's requests. Your session's own setting is untouched, and /router off hands it straight back.
It runs in the Claude Code CLI and the desktop app, and was built and tested on Claude Code 2.1.285. Its pane draws with elements every Claude Code surface has, so the IDE extensions should show it too, though that is untested.
/plugin marketplace add njsoria/dk-tools
/plugin install kore-model-router@dk-tools
Then start a new session. The router starts off ("enabled": false in routes.json); /router on turns it on for that session, and { "enabled": true } in your own routes.json (see Configuration) starts every session with it on. When it is on it says so with a toast, and router: on appears under the prompt. Open its pane with /router pane or by clicking router: on (set "openPane": true in routes.json to open it with every session).
To classify with Jev (the default), give the plugin a TypeSafe API key. Claude Code asks for it when you enable the plugin and keeps it in secure storage; change it later under /config, in the plugin's typesafe_api_key field.
Without a key, set "classifier": "haiku" in routes.json to classify on your own Claude login instead. With neither, prompts run as your session set them.
To run a copy you are working on, point Claude Code at its folder:
claude --plugin-dir path/to/kore-router
rules is a regular expression, tried case-insensitively. A plain rule decides the route with no classifier call: ultrathink goes to Opus at max effort, and typo, rename or format at the start of a prompt is a mechanical task. A rule marked atLeast only raises what the classifier answers: anything that mentions security, secrets, auth, payments or charges, migrations, production, deploys or concurrency never runs below Opus at high effort.guards.shortWords), which is too thin to classify; an atLeast rule still raises it.when of each task: answer, lookup, mechanical, build, review, debug, design or risky. Each task has the least model and effort it runs on, and the classifier's effort can raise that, never lower it."classifier": "haiku") runs on your own Claude login, so it needs no key. It is slower: a full model call per prompt.minConfidence, the router takes whichever of the two likeliest tasks runs on the stronger model, and rounds the effort up.The harness's own <system-reminder> notes are stripped first, so they neither trip a rule nor sway the classifier.
The classifier is how the router tells "what does git stash pop do?" (Haiku, low effort) from "the checkout test fails one run in ten" (Opus, high effort). Rules alone catch only the obvious cases, and without a route every prompt runs on whatever the session has selected. The cost is a short wait before the turn starts: the classifier's answer, plus a few milliseconds of rules. The router keeps the last lines of the conversation in memory rather than reading the transcript each time, and gives up after timeoutMs (2 seconds), leaving the route in force. Each decision logs classifyMs and routeMs, and the pane shows both, so you can see what routing costs in your own sessions.
When the session starts a subagent, the router picks its model too:
subagents.types gets its task with no classifier call (Explore is a lookup, Plan is a design).Forks (which must share their parent's model), teammates and a workflow's agents are left alone. A subagent starts with an empty cache, so moving it to a cheaper model costs nothing: this is where routing saves the most.
The router raises a turn when in doubt and never lowers one:
atLeast rule raises any task whose words mark it risky.guards.pushback ("that's wrong", "still failing", "no, the other one"...) runs a tier above the turn it answers, and so do the next holdTurns - 1 prompts. At the top tier it thinks a level harder instead. Blunt openers ("no", "wrong", "revert") count only at the start of the prompt. Softer corrections and frustration ("I'm talking about...", "I said...", "I meant...", "I don't need you to...", "it still doesn't work", "that's wrong") count at the start of any sentence or line, after at most two interjections ("ok,", "sorry,", "try again,"), so they are caught after a first sentence too. A few phrases ("you're confusing me", "as I said", "do what I'm saying") count anywhere. An instruction like "I need you to add a flag" is not a correction and does not escalate, and neither does a bare "try again" or "check again", which may only mean refresh a report or re-run a search.guards.toolErrors failed tool calls in one turn below the top model, the rest of the turn runs a tier up, and the floor holds for the next few prompts.maxContext is the most conversation it is trusted with. Past it (Haiku's is 100K tokens, where its price also rises fivefold) the router keeps a stronger model, whatever the task./router floor sonnet keeps the session at Sonnet or above until /router floor off. An escalation running out doesn't lift it.routes.json), the prompt runs on the route already in force, or as the session set it. The router is never the reason a prompt fails.Every escalation, floor and hold is logged with its reason and shown in the pane.
Caches are kept per model. Switching to a cheaper model makes it write the whole conversation into its own cache, and switching back may make the stronger one write it again. The router steps down only when the turn saves at least switching.margin (1.5) times what the switch costs, worked out from:
routes.json, including Haiku's dearer rates above 100K,expectedSteps and expectedOutput until there are turns to measure),cacheTtlMinutes.At current prices a step from Opus to Haiku pays at almost any length under 100K. A step from Opus to Sonnet almost never pays once there is a conversation, since Sonnet 5.5's cache reads cost the same as Opus 5.5's: the router keeps Opus and still lowers the effort. Changing effort alone doesn't rewrite the cache.
A model not in pricing falls back to a plain rule: no step down past fallbackMessages (30) messages.
A turn keeps the route it began on, so a prompt typed mid-turn doesn't switch models under it. The one exception is a struggling turn, as above.
The pane lists this session's last 30 decisions, newest first, one line each: the time, the model and effort the turn ran on, and marks for a subagent, a hold or a guard that changed the route (*). A collapsed row shows the first line of the prompt. Click a line (or focus it and press Enter) to open it and see everything the router logged for it: the full prompt, what the classifier asked for, what ran, why, what the session had selected, the message count and how long routing took. Open all and Close all do every row at once.
The route in force shows under the prompt (router: sonnet · medium, with the reason when a guard changed it). In the CLI it leads the hint line under the prompt. The desktop app doesn't draw that hint line, so there the route sits in the footer under the input bar, beside the session's mode labels; a long one is cut short, with the rest in its tooltip. Click it in either to open the pane, and again to close it, or run /router pane. Other surfaces don't show it yet.
| Command | What it does |
|---|---|
/router | Shows whether the router is on, the last route and any floor |
/router on, /router off | Turn it on, or hand the session back to its own model |
/router pane, /router open | Open the pane |
/router floor <model> | Never run below this model until /router floor off |
/router floor off | Lift your floor and any escalation |
/router savings | Price what the router saved against your baseline model |
/router savings sonnet | The same, against another model from routes.json |
/router savings session | Against whatever model you had selected for each turn |
/router <a prompt> | Show the route a prompt would get, without sending it |
Each routed turn, yours or a subagent's, adds a line to its session's spend file with the model you (or the subagent's parent) had selected, the model the router ran and the turn's token counts. /router savings prices those same tokens on both models, reports the difference, and breaks it down by the model that ran.
The baseline defaults to { "model": "opus", "effort": "xhigh" }. Prices sit beside it in routes.json, in dollars per million tokens. Cache writes are priced at the 1-hour rate (2x input), which Claude Code sessions use. Check the prices against Anthropic's current pricing.
What the figure leaves out:
eval/cases.json holds 60 labelled prompts. The first 44 are plain, everyday asks. The last 16 are deliberately hard: debugging phrased as a question, risky work phrased as a quick fix, pasted stack traces, two jobs in one prompt, and bare follow-ups like "make it faster". eval/run.ts sends each through the same code the plugin runs before a turn (rules, classifier, task floors) and scores the model it lands on:
npx -y tsx@4.19.2 eval/run.ts --runs 5
Latest result with Jev, 60 cases × 5 runs:
| Measure | Result |
|---|---|
| Right model | 100% (plain 100%, hard 100%) |
| Under-routed (the quality risk) | 0% |
| Over-routed (the cost) | 0% |
| Right task | 98% (92% the exact label) |
| Same route on every run | 98% |
| Latency | p50 167 ms, p95 236 ms |
Treat these as a floor test, not a promise. The cases were written alongside the routing rules, and one round of tuning (adding charges and refunds to the risky words) came from a miss this suite found. Real prompts are messier. When the pane shows a misroute, add it to cases.json with the task it should have had, and fix routes.json until the suite passes again.
The eval needs TYPESAFE_API_KEY and makes one Jev call per case per run. It writes every answer to eval/results/, which git ignores.
The pushback guard has its own offline check, with no key or API call. It tests guards.pushback against the corrections and near-misses under pushback in cases.json and exits non-zero on a miss:
npx -y tsx@4.19.2 eval/pushback.ts
All of it is in routes.json.
Don't edit that file for your own settings: an installed plugin runs a copy that each update replaces, and a plugin run from its folder would put them in git. Put them in Claude Code's data folder for the plugin, ~/.claude/plugins/data/kore-model-router-<marketplace>/routes.json (for this repository, kore-model-router-dk-tools; for --plugin-dir, -inline). The router reads it there whether it runs from an installed copy or from its own folder, and lays it over the shipped routes.json: each top-level key in your file replaces the shipped one, and the keys you leave out stay as shipped. So
{ "enabled": true }
on its own starts every session with the router on. A key you do set replaces the whole of it (your tasks is every task), and the two files are checked together when they are read.
| Key | Meaning |
|---|---|
classifier | jev or haiku |
minConfidence | Below this, take the more capable choice (0 to 1) |
timeoutMs | How long to wait for the classifier |
enabled | Whether a session starts with the router on. Shipped as false: sessions start with it off, and /router on turns it on for that session. Leave it out for on |
openPane | Open the pane when a session starts (true), or leave it to /router pane |
baseline | What /router savings compares against by default |
pricing | Dollars per million tokens, by model id, with an optional longContext rate above a token count |
switching | expectedSteps, expectedOutput, cacheTtlMinutes, margin, fallbackMessages: when a step down pays |
guards | pushback and followUp (regexes), shortWords, holdTurns, toolErrors |
subagents | classify (other types), and types: agent type to task |
rules | match (regex), then a task or a model and effort, a why, and atLeast to raise rather than decide |
tasks | name, the least model and effort it runs on, and a when the classifier reads |
models | name, id, optional maxContext. Order them weakest to strongest |
efforts | Levels, weakest to strongest, each with a when |
The when of each task is the router's whole idea of your work. Edit it to describe the jobs you'd send to each model, then run the eval. A mistake in routes.json (a task naming a model that isn't listed, say) is caught when the file is read, and the prompt runs as the session set it.
claude plugin test kore-router
runs hooks/register.test.ts, 47 tests that read as the router's specification: each test name states one behaviour, from "a rule routes without asking the classifier" to "a step down that wouldn't pay for its cache keeps the model". They run against a mocked engine, with no network and no model calls. hooks/config.test.ts adds 6 for where the router keeps its logs and your routes.json.
The CLI and the desktop app each carry their own copy of the engine, and the two are not always the same version: the desktop app's can offer hook fields the CLI's does not have yet (agent.spawn's isTeammate and workflow, for example). Keep the CLI current (brew upgrade --cask claude-code, or claude update), and write against fields every supported version has, or read the newer ones defensively, as register.tsx does. Claude Code writes the API types of the engine that last loaded the plugin into .claude-plugin/types/, so type-check after running it in each.
Claude Code's plugin host follows $, the engine handle, only into functions declared in the hooks module itself. So everything that touches the engine lives in register.tsx, and the decisions are pure functions in the modules beside it.
hooks/register.tsx: the hooks, and everything that touches the engine.hooks/decide.ts: rules, floors, escalation and the step-down sum.hooks/classify.ts: what Jev and Haiku are asked, and how their answers are read.hooks/config.ts: the types of routes.json, and its check.hooks/ledger.ts: the log lines, and /router savings.hooks/pane.ts: what the pane says about each decision.hooks/register.test.ts, hooks/config.test.ts: the tests.eval/: the labelled prompts and the accuracy runner.routes.json: all configuration.logs/<session id>.routes.jsonl: every decision of one session, which its pane reads.logs/<session id>.spend.jsonl: one line per routed turn of one session, with tokens. /router savings adds up every session's file.logs/ sits beside the plugin when it runs from its own folder, and in Claude Code's data folder for the plugin (~/.claude/plugins/data/kore-model-router-<marketplace>/logs/) when it is installed, so an update keeps it. Each session writes only its own files, so sessions running side by side can't overwrite each other. logs/ is git-ignored and never trimmed. Logs an earlier installed version left in its own folder, and a routes.spend.jsonl from before each session had its own file, still count.
The plugin runs on your machine and sends nothing to Kore. With the default classifier, the prompt and the last few messages go to TypeSafe to be sorted; set "classifier": "haiku" to keep that on your own Claude login. Its logs stay on your machine, in logs/. The detail is in PRIVACY.md.
MIT. See LICENSE.
hooks/register.tsx 619 lines1// Kore Model Router: sends each prompt, and each subagent, to the model and effort
2// its job deserves.
3//
4// Rules in routes.json are tried first; what they don't settle goes to a cheap
5// classifier, Jev (TypeSafe) or Haiku, which names the kind of job (a task).
6// Each task has the least model and effort it is ever run on. Guards then
7// raise a turn, never lower it: a floor after the person pushes back or a
8// turn struggles, a model trusted with the conversation's length, and a step
9// down only when it saves more than the switch costs. `/router off` hands the
10// session back to its own model; `/router pane` opens a live view of the
11// decisions; `/router savings` prices what they saved.
12
13// Everything that touches the engine lives in this file: the plugin host
14// follows `$` only into functions declared here. The decisions themselves are
15// pure, in decide.ts and the modules beside it.
16
17import { atom, read, update } from 'claude-code'
18import type { EngineInterface, PluginOptions, Register } from 'claude-code'
19import { JEV_URL, type Verdict, clean, clip, haikuSystem, jevBody, readHaiku, readJev, state } from './classify'
20import { type Effort, type Routes, check, dataDir, merge, model, ownRoutes, tier, versionsDir } from './config'
21import { type Floor, type Habits, type Route, type Settled, byRules, escalate, fromVerdict, isThin, keepRoute, routeTask, settle } from './decide'
22import { type Entry, type Spend, savings } from './ledger'
23import { LIME, details, firstLine, summary } from './pane'
24
25const PANE = 'router'
26// How many of this session's decisions the pane lists, newest first.
27const SHOWN = 30
28// How much of the conversation the classifier reads before a request.
29const TAIL = 4
30// A prompt is routed when a person sent it, not when a notification did.
31const FROM_A_PERSON = new Set(['composer', 'bridge', 'sdk'])
32const LATE = Symbol('late')
33// The router's line under the prompt, held by the host: a write redraws the
34// hint that reads it, which a module variable and an invalidate did not always.
35const hint = atom({ plugin: 'kore-model-router', key: 'line' } as const, null)
36
37async function show($: EngineInterface, text: string | undefined) {
38 await update($, hint, () => text ?? null)
39}
40
41// A click on the router's line opens the pane, or closes it when it is up.
42async function togglePane($: EngineInterface) {
43 const isUp = (await $.ui.panes()).some(pane => pane.id === PANE)
44 if (isUp) await $.ui.close({ id: PANE })
45 else await $.ui.open({ id: PANE, title: 'Kore Model Router', focus: true })
46}
47
48// A person's own routes.json, in Claude Code's data folder, goes over the one
49// the plugin ships, which an update replaces. Without one, or without a home
50// folder to find it in, the shipped one runs as it is.
51async function load($: EngineInterface) {
52 const shipped = JSON.parse(await $.fs.read(`${$.plugin.root}/routes.json`)) as Routes
53 const file = await ownFile($)
54 const text = file === undefined ? undefined : await $.fs.read(file).catch(() => undefined)
55 return check(text === undefined ? shipped : merge(shipped, JSON.parse(text) as Partial<Routes>))
56}
57
58async function ownFile($: EngineInterface) {
59 const home = await $.env.get('HOME')
60 const claudeDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home === undefined ? undefined : `${home}/.claude`)
61 if (claudeDir === undefined) return undefined
62 // Run from its folder, the plugin's marketplace is the one beside it.
63 const marketplace = await $.fs
64 .read(`${$.plugin.root.replace(/[\\/][^\\/]+[\\/]?$/, '')}/.claude-plugin/marketplace.json`)
65 .then(text => (JSON.parse(text) as { name?: string }).name)
66 .catch(() => undefined)
67 return ownRoutes($.plugin.root, { name: $.plugin.name, claudeDir, marketplace })
68}
69
70// The TypeSafe key comes from the plugin's own settings: Claude Code asks for
71// it when the plugin is enabled and keeps it in secure storage, and every
72// surface sees it, where a shell variable reached only some of them.
73function typesafeKey(options: PluginOptions) {
74 const key = options.typesafe_api_key
75 return typeof key === 'string' && key.trim() !== '' ? key.trim() : undefined
76}
77
78// $.http.fetch has no time limit of its own.
79async function within<T>($: EngineInterface, ms: number, work: Promise<T>) {
80 const stop = new AbortController()
81 const late = $.clock.sleep(ms, { signal: stop.signal }).then(
82 () => LATE,
83 () => LATE,
84 )
85 try {
86 const first = await Promise.race([work, late])
87 if (first === LATE) throw new Error(`no answer within ${ms} ms`)
88 return first as T
89 } finally {
90 stop.abort()
91 }
92}
93
94// What came before a request, as the classifier reads it.
95type Rows = readonly { role: string; text: string }[]
96
97async function askJev($: EngineInterface, routes: Routes, text: string, rows: Rows, key: string | undefined): Promise<Verdict> {
98 if (key === undefined) throw new Error('no TypeSafe key is set')
99 const reply = await within(
100 $,
101 routes.timeoutMs,
102 $.http.fetch(JEV_URL, {
103 method: 'POST',
104 headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
105 body: jevBody(routes, state(text, rows)),
106 }),
107 )
108 if (!reply.ok) throw new Error(`Jev answered ${reply.status}`)
109 return readJev(routes, reply.text)
110}
111
112// Runs on the session's own login, so it needs no key; slower than Jev.
113async function askHaiku($: EngineInterface, routes: Routes, text: string, rows: Rows): Promise<Verdict> {
114 const reply = await $.model.complete({
115 model: 'haiku',
116 effort: 'low',
117 timeoutMs: routes.timeoutMs,
118 system: haikuSystem(routes),
119 prompt: JSON.stringify(state(text, rows)),
120 })
121 if (!reply.isAnswered) throw new Error(`Haiku gave no answer (${reply.reason})`)
122 return readHaiku(routes, reply.text)
123}
124
125// A rule settles it with no classifier call, and so does a short follow-up
126// ("yes, go ahead") to the route in force; otherwise the classifier names a
127// task, raised by any `atLeast` rule the text matched. The request alone
128// misleads on a reply, so the classifier also reads `rows`, what came just
129// before it; a subagent's task stands alone, with none.
130async function decide($: EngineInterface, routes: Routes, key: string | undefined, text: string, rows: Rows, previous?: Route): Promise<Route & { ms?: number }> {
131 const { decided, raises } = byRules(routes, text)
132 if (decided !== undefined) return decided
133 const followUp = routes.guards?.followUp
134 if (previous !== undefined && followUp !== undefined && new RegExp(followUp, 'i').test(clean(text))) {
135 return keepRoute(routes, previous, raises, 'a follow-up, so the route in force')
136 }
137 if (previous !== undefined && rows.length > 0 && isThin(routes, text)) {
138 return keepRoute(routes, previous, raises, 'too short to classify, so the route in force')
139 }
140 const started = await $.clock.now()
141 const verdict = routes.classifier === 'haiku' ? await askHaiku($, routes, text, rows) : await askJev($, routes, text, rows, key)
142 return { ...fromVerdict(routes, verdict, raises), ms: Math.round((await $.clock.now()) - started) }
143}
144
145// Each session keeps its own two files under logs/, never trimmed: its
146// decisions, which the pane reads, and its spend, one line per routed turn with
147// token counts. $.fs has no append, so a file is read and rewritten; a file per
148// session means no session rewrites another's lines.
149const LOGS = 'logs'
150const DECISIONS = 'routes.jsonl'
151const SPEND = 'spend.jsonl'
152// The one spend file every session shared before each had its own.
153const OLD_SPEND = 'routes.spend.jsonl'
154
155const logFile = async ($: EngineInterface, kind: string) => `${dataDir($.plugin.root)}/${LOGS}/${await $.session.id()}.${kind}`
156
157// One write at a time, so two of this session's lines can't rewrite each other.
158let writing: Promise<unknown> = Promise.resolve()
159
160function append($: EngineInterface, kind: string, entry: Record<string, unknown>) {
161 writing = writing
162 .then(async () => {
163 const file = await logFile($, kind)
164 const kept = await $.fs.read(file).catch(() => '')
165 const line = JSON.stringify({
166 at: new Date(await $.clock.now()).toISOString(),
167 sessionId: await $.session.id(),
168 ...entry,
169 })
170 await $.fs.write(file, `${kept}${line}\n`)
171 })
172 .catch(() => undefined)
173 return writing
174}
175
176async function record($: EngineInterface, entry: Omit<Entry, 'at' | 'sessionId'> & Record<string, unknown>) {
177 await append($, DECISIONS, entry)
178 $.ui.invalidate('ui.render')
179}
180
181const recordSpend = ($: EngineInterface, spend: Omit<Spend, 'at' | 'sessionId'>) => append($, SPEND, spend)
182
183// The newest decisions of this session, newest first. A new chat has its own
184// file, so it starts with an empty pane rather than another's routes.
185async function recent($: EngineInterface, count: number) {
186 const text = await $.fs.read(await logFile($, DECISIONS)).catch(() => '')
187 return text
188 .split('\n')
189 .filter(Boolean)
190 .map(line => JSON.parse(line) as Entry)
191 .slice(-count)
192 .reverse()
193}
194
195// Every routed turn of every session, oldest first: those in the data folder,
196// and those an earlier installed version left in its own logs/.
197async function spent($: EngineInterface) {
198 const dirs = [`${dataDir($.plugin.root)}/${LOGS}`]
199 const versions = versionsDir($.plugin.root)
200 if (versions !== undefined) {
201 const earlier = (await $.fs.list(versions.dir).catch(() => [])).filter(entry => entry.kind === 'dir' && entry.name !== versions.current)
202 dirs.push(...earlier.map(entry => `${versions.dir}/${entry.name}/${LOGS}`))
203 }
204 const listed = await Promise.all(
205 dirs.map(async dir => (await $.fs.list(dir).catch(() => [])).filter(entry => entry.name.endsWith(`.${SPEND}`)).map(entry => `${dir}/${entry.name}`)),
206 )
207 const files = [`${$.plugin.root}/${OLD_SPEND}`, ...listed.flat()]
208 const texts = await Promise.all(files.map(file => $.fs.read(file).catch(() => '')))
209 return texts
210 .flatMap(text => text.split('\n').filter(Boolean).map(line => JSON.parse(line) as Spend))
211 .sort((a, b) => a.at.localeCompare(b.at))
212}
213
214const say = (route: Route) => `${route.name} · ${route.effort} (${route.via})`
215// The middle of a session's figures: one overnight pause mustn't make every
216// prompt look an hour apart.
217const median = (list: number[]) => (list.length === 0 ? undefined : [...list].sort((a, b) => a - b)[Math.floor(list.length / 2)])
218
219export const register: Register = (on, options) => {
220 const key = typesafeKey(options)
221 let isOn = true
222 let routes: Routes | undefined
223 // The route the newest prompt earned, and what each running turn began on:
224 // a prompt typed mid-turn must not switch models under that turn.
225 let latest: Route | undefined
226 let lastModel: string | undefined
227 // Tokens the main loop's last request carried: what a model switch rewrites.
228 let lastContext = 0
229 // What the last main turn ran on, which a pushback escalates from.
230 let lastRan: Settled | undefined
231 // A floor the person set with /router floor, and one an escalation set, which counts down.
232 let chosen: Floor | undefined
233 let floor: Floor | undefined
234 let asked = ''
235 // The router's line under the prompt, which opens its pane when clicked; the
236 // copy kept here only spares a write when it hasn't changed.
237 let line: string | undefined
238 // The conversation's last few lines, kept as they pass so routing never has
239 // to read the whole transcript; read once only for a resumed session.
240 const tail: { role: string; text: string }[] = []
241 let isTailRead = false
242 // The pane's rows the person has opened, by their time.
243 const expanded = new Set<string>()
244 // How long the newest prompt's routing took, for its decision line.
245 let timing: { classifyMs?: number; routeMs?: number } = {}
246 const pinned = new Map<string, Settled | undefined>()
247 // What a routed turn was set to and what it ran on, until its token counts arrive.
248 const open = new Map<string, Omit<Spend, 'at' | 'sessionId' | 'tokens'>>()
249 // Tool errors in the main turn under way, and the turns already moved up for them.
250 let errors = 0
251 const struggled = new Set<string>()
252 // The subagents the router started, by id: the effort their steps take and their spend's labels.
253 const agents = new Map<string, { effort: Effort } & Omit<Spend, 'at' | 'sessionId' | 'tokens'>>()
254 // This session's own figures, which the step-down sum prefers to routes.json's guesses.
255 const seen = { steps: [] as number[], output: [] as number[], spans: [] as number[], lastPromptAt: undefined as number | undefined }
256 const steps = new Map<string, number>()
257 const habits = (): Habits => ({ steps: median(seen.steps), output: median(seen.output), spanMs: median(seen.spans) })
258 const remember = (role: string, text: string) => {
259 if (clean(text) === '') return
260 tail.push({ role, text: clip(text, 2000) })
261 tail.splice(0, Math.max(0, tail.length - TAIL))
262 }
263
264 on('session.start', async ($, e, next) => {
265 await $.command.register({
266 name: 'router',
267 description: 'Model router: turn it on or off, set a floor, or try a prompt against it',
268 argumentHint: '[on | off | pane (or open) | floor <model> | floor off | savings [model | session] | a prompt to try]',
269 })
270
271 // `"enabled": false` in routes.json starts sessions with the router off.
272 // A routes.json that can't be read leaves it on, as before; the prompts
273 // then run as the session set them.
274 try {
275 if ((await load($)).enabled === false) isOn = false
276 } catch {
277 // Same as a broken file at any other time.
278 }
279
280 // The router changes what a session runs on, so it says what it is doing.
281 if (isOn) {
282 $.ui.toast('Kore Model Router is on. /router off to turn it off.')
283 line = 'router: on'
284 } else {
285 $.ui.toast('Kore Model Router is off. /router on to turn it on.')
286 line = undefined
287 }
288 await show($, line)
289
290 // A slow tick keeps the pane current should a redraw be missed.
291 $.clock.every(3000, () => $.ui.invalidate('ui.render'))
292
293 // The pane opens with the session unless routes.json says `"openPane": false`.
294 // A pane that won't open must not stop the router from starting.
295 try {
296 if ((await load($)).openPane !== false) await $.ui.open({ id: PANE, title: 'Kore Model Router' })
297 } catch {
298 // `/router pane` still opens it by hand.
299 }
300
301 return next(e)
302 })
303
304 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
305 const { Box, Button, Text } = $.ui.resolve(e)
306 const rows = await recent($, SHOWN)
307 if (rows.length === 0) return <Text dimColor>No prompt has been routed yet.</Text>
308 // A press opens or closes a row; the redraw shows it.
309 const toggle = (key: string) => () => {
310 if (!expanded.delete(key)) expanded.add(key)
311 $.ui.invalidate('ui.render')
312 }
313 const all = (isOpen: boolean) => () => {
314 for (const row of rows) {
315 if (isOpen) expanded.add(row.at)
316 else expanded.delete(row.at)
317 }
318 $.ui.invalidate('ui.render')
319 }
320
321 return (
322 <Box flexDirection="column" paddingX={1} rowGap={1}>
323 <Box columnGap={2}>
324 <Button key="open-all" plain dimColor label="Open all" onPress={all(true)} />
325 <Button key="close-all" plain dimColor label="Close all" onPress={all(false)} />
326 </Box>
327 {rows.map((row, at) => {
328 const isOpen = expanded.has(row.at)
329 return (
330 <Box key={`row-${row.at}`} flexDirection="column">
331 <Button key={`toggle-${row.at}`} plain label={`${isOpen ? '▾' : '▸'} ${summary(row)}`} onPress={toggle(row.at)} />
332 {at === 0 && !isOpen && (
333 <Text color={LIME} wrap="truncate-end">
334 {row.failed ?? row.note ?? `Decided by ${row.via}.`}
335 </Text>
336 )}
337 {!isOpen && (
338 <Text dimColor wrap="truncate-end">
339 {firstLine(row.prompt)}
340 </Text>
341 )}
342 {isOpen &&
343 details(row).map(([label, value]) => (
344 <Box key={`${row.at}-${label}`} columnGap={1}>
345 <Text dimColor>{`${label}:`}</Text>
346 <Text>{value}</Text>
347 </Box>
348 ))}
349 </Box>
350 )
351 })}
352 </Box>
353 )
354 })
355
356 // The terminal's line under the prompt leads with the router's route, as a
357 // button that opens the pane, and keeps the engine's own hints after it.
358 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
359 const line = await read($, hint)
360 if (line === null) return next(e)
361 const { Box, Button } = $.ui.resolve(e)
362
363 return (
364 <Box columnGap={2}>
365 <Button key="router-line" plain dimColor label={line} onPress={() => togglePane($)} />
366 {await next(e)}
367 </Box>
368 )
369 })
370
371 // The desktop app never asks for the hint line. Under its input bar it draws
372 // the session's mode labels, so there the route is the same button beside
373 // them. The terminal's mode labels are left alone: its hint line has it.
374 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
375 if (e.surface === 'terminal') return next(e)
376 const line = await read($, hint)
377 if (line === null) return next(e)
378 const { Box, Button } = $.ui.resolve(e)
379
380 return (
381 <Box columnGap={1}>
382 <Button key="router-line" plain dimColor label={line} onPress={() => togglePane($)} />
383 {await next(e)}
384 </Box>
385 )
386 })
387
388 on('prompt.submit', async ($, e, next) => {
389 if (isOn && FROM_A_PERSON.has(e.origin.kind)) {
390 asked = clip(clean(e.text), 2000)
391 const at = await $.clock.now()
392 if (seen.lastPromptAt !== undefined) seen.spans.push(at - seen.lastPromptAt)
393 seen.lastPromptAt = at
394 // A floor counts down the person's prompts, then lifts.
395 if (floor?.turns !== undefined) floor = floor.turns <= 1 ? undefined : { ...floor, turns: floor.turns - 1 }
396 try {
397 routes = await load($)
398 const pushback = routes.guards?.pushback
399 if (pushback !== undefined && lastRan !== undefined && new RegExp(pushback, 'i').test(clean(e.text))) {
400 floor = escalate(routes, lastRan, 'escalated: you pushed back on the last turn')
401 }
402 if (!isTailRead && tail.length === 0) {
403 tail.push(...(await $.session.messages()).map(row => ({ role: row.role, text: row.text })).filter(row => clean(row.text) !== '').slice(-TAIL))
404 }
405 isTailRead = true
406 const decided = await decide($, routes, key, e.text, tail, latest)
407 timing = { classifyMs: decided.ms, routeMs: Math.round((await $.clock.now()) - at) }
408 latest = decided
409 } catch (error) {
410 // Routing must never be why a prompt fails: the last route stands, or
411 // the session's own model when there has been none.
412 line = `router: no new route (${(error as Error).message})`
413 await show($, line)
414 await record($, { prompt: asked, failed: (error as Error).message })
415 }
416 remember('user', e.text)
417 }
418
419 return next(e)
420 })
421
422 // A subagent starts on the model its task earns, unless it is a fork (which
423 // must share its parent's model), a teammate or a workflow's agent. A model
424 // the parent named is kept, or raised to the task's own if it is weaker.
425 on('agent.spawn', async ($, e, next) => {
426 // Newer engines say when a spawn is a teammate or a workflow's agent; older
427 // ones (the CLI at 2.1.285) don't, and raise neither through this event.
428 const kind = e as { workflow?: unknown; isTeammate?: boolean }
429 if (!isOn || e.fork || kind.workflow !== undefined || kind.isTeammate === true) return next(e)
430 const task = clip(clean(e.description || e.prompt), 120)
431 let current: Routes
432 let wanted: Route
433 try {
434 current = await load($)
435 const typed = current.subagents?.types?.[e.subagentType]
436 if (typed !== undefined) wanted = routeTask(current, typed, undefined, `${e.subagentType} agents`)
437 else if (current.subagents?.classify !== false) wanted = await decide($, current, key, `${e.description}\n\n${e.prompt}`, [])
438 else return next(e)
439 } catch (error) {
440 await record($, { prompt: task, failed: (error as Error).message, agent: e.subagentType })
441 return next(e)
442 }
443 const named = e.model === undefined || e.model === 'inherit' ? undefined : e.model
444 const isNamedKept = named !== undefined && tier(current, named) >= tier(current, wanted.model)
445 const ran = isNamedKept ? (current.models.find(one => one.name === named)?.id ?? named) : wanted.model
446 const result = await next({ ...e, model: ran })
447 if (result.deny !== undefined) return result
448 if (result.agentId !== undefined) {
449 agents.set(result.agentId, { effort: wanted.effort, prompt: task, session: e.parentModel, ran, agent: e.subagentType })
450 }
451 await record($, {
452 prompt: task,
453 wanted: `${wanted.name} · ${wanted.effort}`,
454 via: wanted.via,
455 ran: `${ran} · ${wanted.effort}`,
456 held: false,
457 note: isNamedKept && ran !== wanted.model ? `kept the ${named} the call asked for` : undefined,
458 agent: e.subagentType,
459 })
460 return result
461 })
462
463 // Tool errors in the main turn under way, which may mean it was routed too low.
464 on('tool.call', async ($, e, next) => {
465 const result = await next(e)
466 if (e.agentId === undefined && result.isError === true) errors += 1
467 return result
468 })
469
470 on('turn.step', async function* ($, e, next) {
471 if (e.agentId !== undefined) {
472 // A routed subagent keeps the model it started on and takes its task's effort.
473 const routed = agents.get(e.agentId)
474 return yield* next(routed === undefined ? e : { ...e, effort: routed.effort })
475 }
476
477 if (!pinned.has(e.turnId)) {
478 const isRouted = isOn && latest !== undefined && routes !== undefined
479 const now = { before: lastModel ?? e.model, context: lastContext, messages: e.messageCount, floors: [chosen, floor].filter(one => one !== undefined), habits: habits() }
480 const ran = isRouted ? settle(routes!, latest!, now) : undefined
481 pinned.set(e.turnId, ran)
482 errors = 0
483 if (ran !== undefined) {
484 lastRan = ran
485 await record($, {
486 prompt: asked,
487 wanted: `${latest!.name} · ${latest!.effort}`,
488 via: latest!.via,
489 ran: `${ran.model} · ${ran.effort}`,
490 held: ran.held === true,
491 note: ran.note,
492 session: e.model,
493 sessionEffort: e.effort,
494 messages: e.messageCount,
495 ...timing,
496 })
497 open.set(e.turnId, {
498 prompt: asked,
499 session: e.model,
500 sessionEffort: typeof e.effort === 'string' ? e.effort : undefined,
501 ran: ran.model,
502 })
503 }
504 } else {
505 // A turn that keeps failing below the top model moves up a tier for the
506 // rest of it, and the floor holds for the next few prompts too.
507 const ran = pinned.get(e.turnId)
508 const limit = routes?.guards?.toolErrors ?? 4
509 if (ran !== undefined && routes !== undefined && !struggled.has(e.turnId) && errors >= limit) {
510 struggled.add(e.turnId)
511 const up = escalate(routes, ran, `escalated: ${errors} tool errors in one turn`)
512 const raised = { model: model(routes, up.model).id, effort: up.effort ?? ran.effort, note: up.why }
513 if (tier(routes, raised.model) > tier(routes, ran.model) || raised.effort !== ran.effort) {
514 floor = up
515 lastRan = raised
516 pinned.set(e.turnId, raised)
517 const spend = open.get(e.turnId)
518 if (spend !== undefined) open.set(e.turnId, { ...spend, ran: raised.model })
519 await record($, { prompt: asked, via: latest?.via, ran: `${raised.model} · ${raised.effort}`, note: raised.note, messages: e.messageCount })
520 }
521 }
522 }
523 const route = pinned.get(e.turnId)
524 lastModel = route?.model ?? e.model
525 steps.set(e.turnId, e.index + 1)
526 const said = route === undefined ? line : `router: ${route.model.replace(/^claude-|-[\d-]+.*$/g, '')} · ${route.effort}${route.note === undefined ? '' : ` (${route.note})`}`
527 if (said !== line) {
528 line = said
529 await show($, line)
530 }
531
532 const result = yield* next(route === undefined ? e : { ...e, model: route.model, effort: route.effort })
533 if (result.usage !== null) {
534 lastContext = result.usage.input_tokens + result.usage.cache_read_input_tokens + result.usage.cache_creation_input_tokens
535 }
536 return result
537 })
538
539 on('turn.complete', async ($, e, next) => {
540 if (e.agentId !== undefined) {
541 const routed = agents.get(e.agentId)
542 if (routed !== undefined && e.usage !== undefined) {
543 const { effort: _, ...labels } = routed
544 await recordSpend($, { ...labels, tokens: tokensOf(e.usage) })
545 }
546 return next(e)
547 }
548
549 remember('assistant', e.answer)
550 pinned.delete(e.turnId)
551 struggled.delete(e.turnId)
552 const count = steps.get(e.turnId)
553 steps.delete(e.turnId)
554 if (count !== undefined && !e.isAborted) seen.steps.push(count)
555 if (e.usage !== undefined && !e.isAborted) seen.output.push(e.usage.output_tokens)
556 const routed = open.get(e.turnId)
557 open.delete(e.turnId)
558 if (routed !== undefined && e.usage !== undefined) await recordSpend($, { ...routed, tokens: tokensOf(e.usage) })
559
560 return next(e)
561 })
562
563 on('command.run', { command: 'router' }, async ($, e) => {
564 const args = e.args.trim()
565 if (args === 'on' || args === 'off') {
566 isOn = args === 'on'
567 line = isOn ? 'router: on' : undefined
568 await show($, line)
569 return { text: `Router is ${args}.` }
570 }
571 // `open` too: the word people reach for.
572 if (args === 'pane' || args === 'open') {
573 await $.ui.open({ id: PANE, title: 'Kore Model Router' })
574 return { text: 'Router pane opened.' }
575 }
576 if (args === 'floor off') {
577 chosen = undefined
578 floor = undefined
579 return { text: 'Floor lifted.' }
580 }
581 if (args.startsWith('floor ')) {
582 try {
583 const named = model(await load($), args.slice('floor '.length).trim())
584 chosen = { model: named.name, why: `your floor: ${named.name}` }
585 return { text: `This session won't run below ${named.name} until /router floor off.` }
586 } catch (error) {
587 return { text: (error as Error).message }
588 }
589 }
590 if (args === 'savings' || args.startsWith('savings ')) {
591 try {
592 return { text: savings(await load($), await spent($), args.slice('savings'.length).trim() || undefined) }
593 } catch (error) {
594 return { text: `No figures: ${(error as Error).message}` }
595 }
596 }
597 if (args === '') {
598 const last = latest === undefined ? '' : ` Last route: ${say(latest)}.`
599 const held = [chosen, floor]
600 .filter(one => one !== undefined)
601 .map(one => ` Floor: ${one.model}${one.effort === undefined ? '' : ` · ${one.effort}`} (${one.why}${one.turns === undefined ? '' : `, ${one.turns} more prompts`}).`)
602 .join('')
603 return { text: `Router is ${isOn ? 'on' : 'off'}.${last}${held}` }
604 }
605 try {
606 return { text: say(await decide($, await load($), key, args, tail)) }
607 } catch (error) {
608 return { text: `No route: ${(error as Error).message}` }
609 }
610 })
611}
612
613const tokensOf = (usage: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }) => ({
614 input: usage.input_tokens,
615 output: usage.output_tokens,
616 cacheRead: usage.cache_read_input_tokens,
617 cacheWrite: usage.cache_creation_input_tokens,
618})
619hooks/classify.ts 102 lines1// What the cheap classifiers, Jev (TypeSafe) or Haiku, are asked and how their
2// answers are read. Each names the kind of job a request is (a task from
3// routes.json) and how much thinking it calls for. The calls themselves are
4// made in register.tsx.
5
6import { type Effort, type Routes, type Task, effortRank, tier, model as modelNamed } from './config'
7
8export type Verdict = { task: Task; effort: Effort; via: string }
9
10// What the classifier reads: the request and, for a reply like "yes, go ahead",
11// what came just before it.
12export type State = { request: string; earlier: { from: string; text: string }[] }
13
14export const JEV_URL = 'https://api.typesafe.ai/v1/systemone'
15
16// A long text says most about itself at its start and its end.
17export const clip = (text: string, max: number) =>
18 text.length <= max ? text : `${text.slice(0, max / 2)}\n[...]\n${text.slice(-max / 2)}`
19
20// The harness rides its own notes on a prompt; they say nothing of the job.
21export const clean = (text: string) => text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim()
22
23export function state(request: string, rows: readonly { role: string; text: string }[]): State {
24 const earlier = rows
25 .map(row => ({ from: row.role, text: clean(row.text) }))
26 .filter(row => row.text !== '')
27 .slice(-4)
28 .map(row => ({ ...row, text: clip(row.text, 600) }))
29 return { request: clip(clean(request), 8000), earlier }
30}
31
32export const jevBody = (routes: Routes, state: State) =>
33 JSON.stringify({
34 model: 'jev-latest',
35 state,
36 questions: {
37 task: {
38 type: 'choice',
39 instructions:
40 "`request` is the newest message to an AI coding assistant and `earlier` is the conversation before it, oldest first. Which kind of job is the assistant being asked to do now? A short reply such as an approval or 'continue' asks for the job already under way in `earlier`. When the job touches anything the criteria call risky, choose that.",
41 criteria: Object.fromEntries(routes.tasks.map(task => [task.name, task.when])),
42 },
43 effort: {
44 type: 'score',
45 instructions:
46 'How much thinking does the job that `request` asks for call for before acting? Judge a short reply such as an approval by the job under way in `earlier`.',
47 criteria: routes.efforts.map(effort => effort.when),
48 },
49 },
50 })
51
52type JevReply = {
53 answers: {
54 task: { choice: string; probabilities: Record<string, number>; confidence: number }
55 effort: { score: number; confidence: number }
56 }
57}
58
59export function readJev(routes: Routes, text: string): Verdict {
60 const { answers } = JSON.parse(text) as JevReply
61 const task = pickTask(routes, answers.task)
62 if (task === undefined) throw new Error(`Jev named an unknown task: ${answers.task.choice}`)
63 return {
64 task,
65 effort: pickEffort(routes, answers.effort),
66 via: `jev, ${task.name}, ${Math.round(answers.task.confidence * 100)}% sure`,
67 }
68}
69
70// Torn between two tasks: take the one that runs on the more capable model,
71// and of two on the same model, the one that thinks harder.
72function pickTask(routes: Routes, answer: JevReply['answers']['task']) {
73 const named = (name: string | undefined) => routes.tasks.find(task => task.name === name)
74 if (answer.confidence >= routes.minConfidence) return named(answer.choice)
75 const [first, second] = Object.entries(answer.probabilities)
76 .sort((a, b) => b[1] - a[1])
77 .map(([name]) => named(name))
78 .filter(task => task !== undefined)
79 if (first === undefined || second === undefined) return first ?? named(answer.choice)
80 const weight = (task: Task) => tier(routes, modelNamed(routes, task.model).id) * 10 + effortRank(task.effort)
81 return weight(second) > weight(first) ? second : first
82}
83
84// The score lands between levels; when Jev is unsure, round up, not down.
85function pickEffort(routes: Routes, answer: JevReply['answers']['effort']) {
86 const level = answer.confidence >= routes.minConfidence ? Math.round(answer.score) : Math.ceil(answer.score)
87 return routes.efforts[Math.max(0, Math.min(level, routes.efforts.length - 1))]!.name
88}
89
90export const haikuSystem = (routes: Routes) => {
91 const guide = (list: { name: string; when: string }[]) => list.map(one => `- ${one.name}: ${one.when}`).join('\n')
92 return `You sort the newest request to an AI coding assistant. Never carry the request out. Answer with two words: the kind of job, then the effort. When unsure between two kinds, name the riskier one.\n\nKinds of job:\n${guide(routes.tasks)}\n\nEfforts:\n${guide(routes.efforts)}`
93}
94
95export function readHaiku(routes: Routes, text: string): Verdict {
96 const named = <T extends { name: string }>(list: T[]) => list.find(one => new RegExp(`\\b${one.name}\\b`, 'i').test(text))
97 const task = named(routes.tasks)
98 const effort = named(routes.efforts)
99 if (task === undefined || effort === undefined) throw new Error('Haiku named no route')
100 return { task, effort: effort.name, via: `haiku, ${task.name}` }
101}
102hooks/config.ts 157 lines1// routes.json, typed, and the small lookups every part of the router shares.
2
3export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
4
5export type Rates = { input: number; output: number; cacheRead: number; cacheWrite: number }
6
7// Dollars per million tokens, and the dearer rates some models charge once a
8// request carries more than `above` tokens.
9export type Price = Rates & { longContext?: Rates & { above: number } }
10
11// A kind of job, and the least model and effort it is ever run on.
12export type Task = { name: string; model: string; effort: Effort; when: string }
13
14export type Model = {
15 name: string
16 id: string
17 // The most context, in tokens, this model is trusted to work in. Past it the
18 // router keeps a stronger model, whatever the job.
19 maxContext?: number
20}
21
22// A rule decides the route outright, or, with `atLeast`, only raises what the
23// classifier answers to its task (or model and effort) when it answers less.
24export type Rule = { match: string; why: string; atLeast?: boolean } & ({ task: string } | { model: string; effort: Effort })
25
26export type Routes = {
27 classifier: 'jev' | 'haiku'
28 minConfidence: number
29 timeoutMs: number
30 // Whether a session starts with the router on. Default true; `/router on` and `/router off` change it for one session.
31 enabled?: boolean
32 openPane?: boolean
33 baseline?: { model: string; effort: Effort }
34 pricing?: Record<string, Price>
35 switching?: {
36 // A turn's likely size before its own figures exist: requests, and output tokens.
37 expectedSteps?: number
38 expectedOutput?: number
39 // How long a model's cache outlives its last request, in minutes.
40 cacheTtlMinutes?: number
41 // A step down must save this many times what the switch costs.
42 margin?: number
43 // Without prices, the longest conversation the router steps down in.
44 fallbackMessages?: number
45 }
46 guards?: {
47 // A prompt this matches is a person saying the last turn fell short.
48 pushback?: string
49 // A prompt this matches whole ("yes, go ahead") continues the job under
50 // way on the route in force, with no classifier call.
51 followUp?: string
52 // A prompt of this many words or fewer, with no question mark, is too thin
53 // to classify ("router pane"): it keeps the route in force. Default 2; 0 turns it off.
54 shortWords?: number
55 // How many of the person's turns an escalation holds for.
56 holdTurns?: number
57 // Tool errors in one turn, below the top model, before it moves up a tier.
58 toolErrors?: number
59 }
60 subagents?: {
61 // Classify a subagent's task when its type isn't listed below.
62 classify?: boolean
63 // Agent type to task, settled without a classifier.
64 types?: Record<string, string>
65 }
66 rules: Rule[]
67 tasks: Task[]
68 models: Model[]
69 efforts: { name: Effort; when: string }[]
70}
71
72// A mistake in routes.json is found when it is read, not halfway through a turn.
73export function check(routes: Routes) {
74 const models = new Set(routes.models.map(model => model.name))
75 const tasks = new Set(routes.tasks.map(task => task.name))
76 const efforts = new Set<string>(routes.efforts.map(effort => effort.name))
77 for (const task of routes.tasks) {
78 if (!models.has(task.model)) throw new Error(`task ${task.name} names an unknown model: ${task.model}`)
79 if (!efforts.has(task.effort)) throw new Error(`task ${task.name} names an unknown effort: ${task.effort}`)
80 }
81 for (const rule of routes.rules) {
82 if ('task' in rule ? !tasks.has(rule.task) : !models.has(rule.model)) {
83 throw new Error(`the rule for /${rule.match}/ names an unknown ${'task' in rule ? `task: ${rule.task}` : `model: ${rule.model}`}`)
84 }
85 }
86 for (const [type, task] of Object.entries(routes.subagents?.types ?? {})) {
87 if (!tasks.has(task)) throw new Error(`subagent ${type} names an unknown task: ${task}`)
88 }
89 return routes
90}
91
92// Efforts past the listed ones (`max`) rank above them all.
93const EFFORTS: Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
94
95export const effortRank = (effort: string | number | undefined) =>
96 typeof effort === 'string' ? EFFORTS.indexOf(effort as Effort) : -1
97
98export const stronger = (a: Effort, b: Effort) => (effortRank(a) >= effortRank(b) ? a : b)
99
100export const nextEffort = (effort: Effort) => EFFORTS[Math.min(effortRank(effort) + 1, EFFORTS.length - 1)]!
101
102// A model's place, weakest first. A model routes.json doesn't list ranks above
103// the ones it does: the router never assumes an unknown model is weaker.
104export const tier = (routes: Routes, id: string) => {
105 const at = routes.models.findIndex(model => id === model.id || id === model.name || id.includes(model.name))
106 return at === -1 ? routes.models.length : at
107}
108
109export const model = (routes: Routes, name: string) => {
110 const found = routes.models.find(model => model.name === name || model.id === name)
111 if (found === undefined) throw new Error(`no model called ${name}`)
112 return found
113}
114
115export const task = (routes: Routes, name: string) => {
116 const found = routes.tasks.find(task => task.name === name)
117 if (found === undefined) throw new Error(`no task called ${name}`)
118 return found
119}
120
121// The price of a model by its id, a variant such as `[1m]` priced as its base.
122export const price = (routes: Routes, id: string) => routes.pricing?.[id] ?? routes.pricing?.[id.replace(/\[.*\]$/, '')]
123
124// A plugin installed from a repository or the directory runs a copy,
125// plugins/cache/<marketplace>/<plugin>/<version>, which the next version
126// replaces. What must outlive an update, the logs and a person's own
127// routes.json, goes to the folder Claude Code keeps for the plugin's data,
128// plugins/data/<plugin>-<marketplace>. A plugin run from its own folder (a
129// folder marketplace, --plugin-dir) keeps its logs beside it.
130const INSTALLED = /^(.*)[\\/]plugins[\\/]cache[\\/]([^\\/]+)[\\/]([^\\/]+)[\\/]([^\\/]+)[\\/]?$/
131
132export function dataDir(root: string) {
133 const at = INSTALLED.exec(root)
134 return at === null ? root : `${at[1]}/plugins/data/${`${at[3]}-${at[2]}`.replace(/[^\w-]/g, '-')}`
135}
136
137// A person's own routes.json lives in Claude Code's data folder for the plugin
138// however it runs, so it stays out of the plugin's folder and out of git. A
139// plugin run from its own folder has no marketplace in its path: it is the one
140// that lists it (`marketplace`), or `inline` for --plugin-dir, as Claude Code
141// names it.
142export function ownRoutes(root: string, plugin: { name: string; claudeDir: string; marketplace?: string }) {
143 if (INSTALLED.test(root)) return `${dataDir(root)}/routes.json`
144 const folder = `${plugin.name}-${plugin.marketplace ?? 'inline'}`.replace(/[^\w-]/g, '-')
145 return `${plugin.claudeDir}/plugins/data/${folder}/routes.json`
146}
147
148// Own settings name only what they change: each top-level key replaces the
149// shipped one, so `{ "enabled": true }` alone turns the router on.
150export const merge = (shipped: Routes, own: Partial<Routes>): Routes => ({ ...shipped, ...own })
151
152// The folder holding every installed version, whose logs/ earlier versions wrote.
153export function versionsDir(root: string) {
154 const at = INSTALLED.exec(root)
155 return at === null ? undefined : { dir: `${at[1]}/plugins/cache/${at[2]}/${at[3]}`, current: at[4]! }
156}
157hooks/decide.ts 173 lines1// What a prompt earns (a rule or the classifier's verdict), and what a turn
2// then runs on once the guards have had their say. All of it is pure: the
3// classifier is called from register.tsx.
4
5import { type Verdict, clean } from './classify'
6import { type Effort, type Rates, type Routes, type Rule, model, nextEffort, price, stronger, task as taskNamed, tier } from './config'
7
8// What the prompt asked for, before any guard.
9export type Route = { model: string; name: string; effort: Effort; via: string; task?: string }
10
11// What a turn runs on, and why, when that isn't simply the route.
12export type Settled = { model: string; effort: Effort; held?: boolean; note?: string }
13
14// A least model (and effort) the router won't go beneath for a while, and why.
15// `turns` counts the person's prompts it has left; undefined holds until lifted.
16export type Floor = { model: string; effort?: Effort; why: string; turns?: number }
17
18// How this session's turns have gone: requests, output tokens, and the time
19// from one prompt to the next. Undefined until a turn has finished.
20export type Habits = { steps?: number; output?: number; spanMs?: number }
21
22// The situation a turn starts in.
23export type Now = {
24 // The model the conversation's last request went to.
25 before: string
26 // Tokens the last request carried: what a switch must write again.
27 context: number
28 messages: number
29 // The floors in force: the person's own, and an escalation's.
30 floors: Floor[]
31 habits: Habits
32}
33
34export const routeTask = (routes: Routes, name: string, effort: Effort | undefined, via: string): Route => {
35 const task = taskNamed(routes, name)
36 const target = model(routes, task.model)
37 return { model: target.id, name: target.name, effort: effort === undefined ? task.effort : stronger(task.effort, effort), via, task: task.name }
38}
39
40const ruled = (routes: Routes, rule: Rule): Route => {
41 if ('task' in rule) return routeTask(routes, rule.task, undefined, rule.why)
42 const target = model(routes, rule.model)
43 return { model: target.id, name: target.name, effort: rule.effort, via: rule.why }
44}
45
46// The stronger model and the harder effort of two routes.
47const atLeast = (routes: Routes, route: Route, least: Route): Route => {
48 const isWeaker = tier(routes, route.model) < tier(routes, least.model)
49 const effort = stronger(route.effort, least.effort)
50 if (!isWeaker && effort === route.effort) return route
51 return { ...(isWeaker ? least : route), effort, via: `${route.via}; raised, ${least.via}` }
52}
53
54// The rules a text matches: one that decides the route outright, or the
55// `atLeast` ones that only raise what the classifier answers.
56export function byRules(routes: Routes, text: string) {
57 const said = clean(text)
58 const matches = routes.rules.filter(rule => new RegExp(rule.match, 'i').test(said))
59 const decider = matches.find(rule => rule.atLeast !== true)
60 return { decided: decider === undefined ? undefined : ruled(routes, decider), raises: matches }
61}
62
63// The route in force carried on to a thin prompt, still raised by any
64// `atLeast` rule the text matched.
65export const keepRoute = (routes: Routes, previous: Route, raises: Rule[], why: string): Route =>
66 raises.reduce((best, rule) => atLeast(routes, best, ruled(routes, rule)), { ...previous, via: `${why} (${previous.via})` })
67
68// Too few words, and no question, to say what job it is.
69export const isThin = (routes: Routes, text: string) => {
70 const said = clean(text)
71 const max = routes.guards?.shortWords ?? 2
72 return said !== '' && !said.includes('?') && said.split(/\s+/).length <= max
73}
74
75// The classifier's task, never run below its own model and effort, nor below
76// any `atLeast` rule the text matched.
77export const fromVerdict = (routes: Routes, verdict: Verdict, raises: Rule[]) =>
78 raises.reduce(
79 (best, rule) => atLeast(routes, best, ruled(routes, rule)),
80 routeTask(routes, verdict.task.name, verdict.effort, verdict.via),
81 )
82
83// The model one tier up, or the same at the top.
84export const above = (routes: Routes, id: string) => routes.models[Math.min(tier(routes, id) + 1, routes.models.length - 1)]!
85
86// A turn fell short on `ran`: the next ones start a tier above it, or, at the
87// top tier already, think a level harder.
88export function escalate(routes: Routes, ran: { model: string; effort: Effort }, why: string): Floor {
89 const up = above(routes, ran.model)
90 const isTop = tier(routes, up.id) === tier(routes, ran.model)
91 return {
92 model: up.name,
93 effort: isTop ? nextEffort(ran.effort) : ran.effort,
94 why,
95 turns: routes.guards?.holdTurns ?? 3,
96 }
97}
98
99const priceAt =(routes: Routes, id: string, context: number): Rates | undefined => {
100 const base = price(routes, id)
101 const long = base?.longContext
102 return long !== undefined && context > long.above ? long : base
103}
104
105const usd = (n: number) => `$${n.toFixed(2)}`
106
107// Whether running this turn on `to` instead of `from` saves more than the
108// switch costs. Caches are kept per model: the first request on `to` writes
109// the whole conversation again, and the return to `from` does too if its
110// cache has expired by then. A conversation the router can't price falls
111// back to a plain length limit.
112export function stepDownPays(routes: Routes, from: string, to: string, now: Now): { pays: boolean; why?: string } {
113 const s = routes.switching ?? {}
114 const a = priceAt(routes, from, now.context)
115 const b = priceAt(routes, to, now.context)
116 if (a === undefined || b === undefined) {
117 const limit = s.fallbackMessages ?? 30
118 return now.messages <= limit ? { pays: true } : { pays: false, why: `long conversation (${now.messages} messages)` }
119 }
120 const steps = now.habits.steps ?? s.expectedSteps ?? 6
121 const output = (now.habits.output ?? s.expectedOutput ?? 2500) / 1e6
122 const context = now.context / 1e6
123 // The turn on each model with a warm cache: every request reads the conversation.
124 const stay = steps * context * a.cacheRead + output * a.output
125 const go = steps * context * b.cacheRead + output * b.output
126 // The switch: `to` writes what it would have read, and `from` may have to
127 // write it all again on the way back. The last request on `from` and the next
128 // are about two prompts apart.
129 const ttl = (s.cacheTtlMinutes ?? 5) * 60_000
130 const isColdReturn = 2 * (now.habits.spanMs ?? 120_000) >= ttl
131 const cost = context * (b.cacheWrite - b.cacheRead) + (isColdReturn ? context * (a.cacheWrite - a.cacheRead) : 0)
132 const saved = stay - go
133 const margin = s.margin ?? 1.5
134 if (saved > 0 && saved >= margin * cost) return { pays: true }
135 return { pays: false, why: `switching would cost ~${usd(cost)} to save ~${usd(Math.max(saved, 0))}` }
136}
137
138// What a main-loop turn runs on: the route, raised to any floor, to a model
139// trusted with this much context, and held on the model in use when a step
140// down wouldn't pay for its switch.
141export function settle(routes: Routes, wanted: Route, now: Now): Settled {
142 let target = wanted.model
143 let effort = wanted.effort
144 const notes: string[] = []
145
146 for (const floor of now.floors) {
147 const isBelow = tier(routes, target) < tier(routes, floor.model)
148 const isLighter = floor.effort !== undefined && stronger(effort, floor.effort) !== effort
149 if (isBelow) target = model(routes, floor.model).id
150 if (isLighter) effort = floor.effort!
151 if (isBelow || isLighter) notes.push(floor.why)
152 }
153
154 // A long conversation needs a model trusted to keep all of it in mind.
155 for (let at = tier(routes, target); at < routes.models.length - 1; at++) {
156 const max = routes.models[at]!.maxContext
157 if (max === undefined || now.context <= max) break
158 target = routes.models[at + 1]!.id
159 notes.push(`${Math.round(now.context / 1000)}K of context is past ${routes.models[at]!.name}'s ${Math.round(max / 1000)}K`)
160 }
161
162 const isRaised = target !== wanted.model
163 const isSameTier = tier(routes, target) === tier(routes, now.before)
164 // Same tier: keep the variant in use (`[1m]`, say).
165 if (isSameTier) return { model: now.before, effort, held: false, note: notes.join('; ') || undefined }
166
167 if (tier(routes, target) < tier(routes, now.before)) {
168 const check = stepDownPays(routes, now.before, target, now)
169 if (!check.pays) return { model: now.before, effort, held: true, note: [...notes, `model kept: ${check.why}`].join('; ') }
170 }
171 return { model: target, effort, held: false, note: isRaised ? notes.join('; ') : undefined }
172}
173hooks/ledger.ts 95 lines1// What the router's logs hold, and what /router savings makes of them. The
2// files themselves are read and written in register.tsx.
3
4import { type Rates, type Routes, price } from './config'
5
6export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
7
8export type Spend = {
9 at: string
10 sessionId: string
11 prompt: string
12 // The model the session (or, for a subagent, its parent) had selected.
13 session: string
14 sessionEffort?: string
15 ran: string
16 // The subagent's type, for a subagent's run.
17 agent?: string
18 tokens: Tokens
19}
20
21// One decision, as the pane shows it.
22export type Entry = {
23 at: string
24 sessionId?: string
25 prompt: string
26 wanted?: string
27 via?: string
28 ran?: string
29 held?: boolean
30 // Why the turn didn't simply run as the prompt asked: a hold, a floor, an escalation.
31 note?: string
32 failed?: string
33 messages?: number
34 agent?: string
35 session?: string
36 sessionEffort?: string | number
37 // How long the classifier took, and all of routing, in milliseconds.
38 classifyMs?: number
39 routeMs?: number
40}
41
42const cost = (rates: Rates, tokens: Tokens) =>
43 (tokens.input * rates.input +
44 tokens.output * rates.output +
45 tokens.cacheRead * rates.cacheRead +
46 tokens.cacheWrite * rates.cacheWrite) /
47 1_000_000
48
49const usd = (n: number) => `$${n.toFixed(2)}`
50const family = (id: string) => id.replace(/^claude-|-[\d-]+.*$/g, '')
51
52// The same tokens, priced as if the turn had run on `against`: a model name
53// from routes.json, or `session` for whatever the session had selected.
54export function savings(routes: Routes, turns: Spend[], against: string | undefined) {
55 const wanted = against ?? routes.baseline?.model ?? 'opus'
56 const isSession = wanted === 'session'
57 const baseModel = routes.models.find(model => model.name === wanted || model.id === wanted)
58 if (!isSession && baseModel === undefined) {
59 return `No model called "${wanted}". Try ${routes.models.map(model => model.name).join(', ')} or session.`
60 }
61
62 let actual = 0
63 let compared = 0
64 let counted = 0
65 const byModel = new Map<string, { turns: number; cost: number }>()
66 const byLoop = { main: 0, subagents: 0 }
67 for (const turn of turns) {
68 const ranPrice = price(routes, turn.ran)
69 const basePrice = price(routes, isSession ? turn.session : baseModel!.id)
70 if (ranPrice === undefined || basePrice === undefined) continue
71 const spentHere = cost(ranPrice, turn.tokens)
72 actual += spentHere
73 compared += cost(basePrice, turn.tokens)
74 counted += 1
75 const row = byModel.get(family(turn.ran)) ?? { turns: 0, cost: 0 }
76 byModel.set(family(turn.ran), { turns: row.turns + 1, cost: row.cost + spentHere })
77 byLoop[turn.agent === undefined ? 'main' : 'subagents'] += 1
78 }
79 if (counted === 0) return 'No routed turns with prices yet.'
80
81 const label = isSession
82 ? 'the model you had selected'
83 : `${baseModel!.name}${routes.baseline?.model === wanted ? ` · ${routes.baseline.effort}` : ''}`
84 const saved = compared - actual
85 const skipped = turns.length - counted
86 const split = [...byModel].map(([name, row]) => `${name} ${row.turns} (${usd(row.cost)})`).join(' · ')
87 return [
88 `Against ${label}, since ${turns[0]!.at.slice(0, 10)}, over ${counted} turns (${byLoop.main} yours, ${byLoop.subagents} subagents'):`,
89 ` router ${usd(actual)} vs ${usd(compared)} → saved ${usd(saved)} (${((saved / compared) * 100).toFixed(0)}%)`,
90 ` ran on: ${split}`,
91 'Same tokens priced on both models; effort is not priced, only the model. A cheaper effort also thinks less, which this does not credit.',
92 ...(skipped > 0 ? [`${skipped} turns skipped for want of a price in routes.json.`] : []),
93 ].join('\n')
94}
95hooks/pane.ts 47 lines1// What the router's pane says about each decision: a one-line summary, and
2// every field behind it for when a row is opened. register.tsx draws it.
3
4import type { Entry } from './ledger'
5
6export const LIME = '#AADD00'
7
8// A collapsed row shows only where a prompt starts; opening it shows all of it.
9export const firstLine = (text: string) => text.split('\n').find(line => line.trim() !== '')?.trim() ?? ''
10
11export const clock = (at: string) => new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
12
13// "claude-opus-5-5 · medium" as its two parts, the model by its family name.
14const parts = (ran = '') => {
15 const [model = '', effort = ''] = ran.split(' · ')
16 return { model: model.replace(/^claude-|-[\d-]+.*$/g, ''), effort }
17}
18
19// The row's summary: when, what it ran on, and whose it was.
20export function summary(row: Entry) {
21 const { model, effort } = parts(row.ran)
22 const route = row.failed === undefined ? `${model} · ${effort}` : 'no new route'
23 const marks = [row.agent === undefined ? '' : ` ${row.agent}`, row.held ? ' held' : '', row.note === undefined ? '' : ' *'].join('')
24 return `${clock(row.at)} ${route}${marks}`
25}
26
27const ms = (n: number | undefined) => (n === undefined ? undefined : `${n} ms`)
28
29// Every field the log holds for the row, in reading order; empty ones left out.
30export function details(row: Entry): [string, string][] {
31 const fields: [string, string | undefined][] = [
32 ['Prompt', row.prompt],
33 ['Failed', row.failed],
34 ['Asked for', row.wanted],
35 ['Ran on', row.ran],
36 ['Decided by', row.via],
37 ['Changed because', row.note],
38 ['Subagent', row.agent],
39 ['Session had', row.session === undefined ? undefined : `${row.session}${row.sessionEffort === undefined ? '' : ` · ${row.sessionEffort}`}`],
40 ['Messages', row.messages === undefined ? undefined : String(row.messages)],
41 ['Classifier', ms(row.classifyMs)],
42 ['Routing took', ms(row.routeMs)],
43 ['At', row.at],
44 ]
45 return fields.filter((field): field is [string, string] => field[1] !== undefined && field[1] !== '')
46}
47types/index.d.ts 7 lines1declare module 'claude-code' {
2 interface PluginState {
3 // The router's line under the prompt; null hides it.
4 'kore-model-router': { line: string | null }
5 }
6}
7