SLOPSHOPPER

Kore Model Router

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.

panespinnerguardcommandtoastprompt
v0.6.0MITupdated 2026-10-16njsoria/dk-tools/kore-router
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · kore-model-router
│ ┃ router ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ No prompt has been routed yet. │ kore-model-router │ │ ⏺ Read(src/auth.ts) │ Kore Model Router is on. /router off to │ │ ⎿ Read 6 lines │ turn it off. │ │ ⏺ 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 │ │ › /router │ ⎿ kore-model-router: Router is on. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › router: no new route (ENOENT: no such file /plugins/kore-model-router/routes.json) ⟨Claude Code's own drawing⟩

Draws

Pane · router
No prompt has been routed yet.
Prompt hint
router: no new route (ENOENT: no such file /plugins/kore-model-router/routes.json) ⟨Claude Code's o
README

Kore Model Router

Kore Model Router

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.

Install

/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

How it picks

  1. Rules first. Each entry in 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.
  2. A short follow-up keeps the route in force. "yes", "go ahead", "continue" and the like carry on the job under way, so no classifier call is made. So does a prompt of two words or fewer with no question mark (guards.shortWords), which is too thin to classify; an atLeast rule still raises it.
  3. Otherwise a classifier names the task. It reads the prompt and the last few lines of the conversation (so "ok, do it" is judged by the job under way) against the 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.
  4. Jev (the default) is TypeSafe's classifier. It answers in about 0.2 seconds (p95 under 0.3s), and at about $0.042 per million input tokens a routed prompt costs a few thousandths of a cent.
  5. Haiku ("classifier": "haiku") runs on your own Claude login, so it needs no key. It is slower: a full model call per prompt.
  6. Unsure means more capable. Below 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.

Why classify at all

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.

Subagents

When the session starts a subagent, the router picks its model too:

  • A type listed under subagents.types gets its task with no classifier call (Explore is a lookup, Plan is a design).
  • Any other type is classified on its own description and prompt, without the main conversation.
  • If the Agent call named a model, it is kept when it is at least as strong as the task's, and raised to the task's when it is weaker.
  • The subagent's requests then take the task's effort.

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.

Quality guards

The router raises a turn when in doubt and never lowers one:

  • Task floors. A task never runs below its own model and effort, and an atLeast rule raises any task whose words mark it risky.
  • Pushback escalates. A prompt that matches 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.
  • A struggling turn moves up. After 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.
  • Context limits. A model's 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.
  • Your own floor. /router floor sonnet keeps the session at Sonnet or above until /router floor off. An escalation running out doesn't lift it.
  • Failures fall back. If routing fails for any reason (no key, a timeout, a mistake in 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.

Stepping down

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:

  • the size of the last request (the conversation the switch rewrites),
  • the prices in routes.json, including Haiku's dearer rates above 100K,
  • this session's median requests and output tokens per turn (expectedSteps and expectedOutput until there are turns to measure),
  • whether the stronger model's cache will still be warm on the way back: two of the session's median gaps between prompts against 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

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.

Commands

CommandWhat it does
/routerShows whether the router is on, the last route and any floor
/router on, /router offTurn it on, or hand the session back to its own model
/router pane, /router openOpen the pane
/router floor <model>Never run below this model until /router floor off
/router floor offLift your floor and any escalation
/router savingsPrice what the router saved against your baseline model
/router savings sonnetThe same, against another model from routes.json
/router savings sessionAgainst whatever model you had selected for each turn
/router <a prompt>Show the route a prompt would get, without sending it

Savings

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:

  • It prices the model, not the effort, and uses the same tokens on both sides. A lower effort also thinks less, so the real saving is probably somewhat larger.
  • The classifier's own cost isn't counted.
  • It prices a turn's tokens at one rate, so Haiku's dearer rate above 100K isn't applied.
  • A turn that moved up a tier partway is priced wholly on the stronger model.

Accuracy

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:

MeasureResult
Right model100% (plain 100%, hard 100%)
Under-routed (the quality risk)0%
Over-routed (the cost)0%
Right task98% (92% the exact label)
Same route on every run98%
Latencyp50 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

Configuration

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.

KeyMeaning
classifierjev or haiku
minConfidenceBelow this, take the more capable choice (0 to 1)
timeoutMsHow long to wait for the classifier
enabledWhether 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
openPaneOpen the pane when a session starts (true), or leave it to /router pane
baselineWhat /router savings compares against by default
pricingDollars per million tokens, by model id, with an optional longContext rate above a token count
switchingexpectedSteps, expectedOutput, cacheTtlMinutes, margin, fallbackMessages: when a step down pays
guardspushback and followUp (regexes), shortWords, holdTurns, toolErrors
subagentsclassify (other types), and types: agent type to task
rulesmatch (regex), then a task or a model and effort, a why, and atLeast to raise rather than decide
tasksname, the least model and effort it runs on, and a when the classifier reads
modelsname, id, optional maxContext. Order them weakest to strongest
effortsLevels, 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.

Development

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.

Files

  • 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.

Privacy

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.

License

MIT. See LICENSE.

Source 7 files
hooks/register.tsx 619 lines
1// 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})
619
hooks/classify.ts 102 lines
1// 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}
102
hooks/config.ts 157 lines
1// 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}
157
hooks/decide.ts 173 lines
1// 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}
173
hooks/ledger.ts 95 lines
1// 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}
95
hooks/pane.ts 47 lines
1// 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}
47
types/index.d.ts 7 lines
1declare 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