SLOPSHOPPER

model-router

Routes each subagent to the cheapest Claude model and effort that can do its task, judged by TypeSafe Jev. On every Agent call it sends the subagent's task…

newguardnetwork
A shopper browsing a rack in a slop shop
README

model-router

The repository README says what this mod does and how to install it. This file says what the mod can reach, what leaves the machine, the routing, the effort ladder and the troubleshooting steps.

What this mod can reach

Anthropic's guide to trusting a mod asks six questions. The answers for this mod:

  • Acts on your machine as you? No. It reads no files, writes none and starts no program.
  • Reads your secrets? Only its own TypeSafe key. It comes from the plugin's sensitive api_key option, which Claude Code hands it from secure storage. When that option is empty, the mod reads the key from the environment variable TYPESAFE_API_KEY. It reads no other environment variable and no file. Its other option is model, the Jev alias.
  • Sees your session? The arguments of each Agent tool call. It sends the task, the description and the agent type. It reads model, name and effort to decide whether to route and whether to keep the caller's effort. It asks Claude Code's permission verdict for the call through $.tool.check, which opens no dialog and runs nothing. The verdict never leaves the machine. It keeps nothing between calls.
  • Changes your session? Two arguments of that Agent tool call: model and effort.
  • Acts without asking you? No. It approves or denies nothing. Your permission rules and prompts decide every call.
  • Spends your usage? No model calls on your plan. One request per routed call goes to TypeSafe on your TypeSafe key.

claude plugin validate plugins/model-router lists the same from the code: one hook, tool.call{tool=Agent}, the calls $.clock.sleep, $.env.get, $.http.fetch, $.tool.check and $.ui.log, and the one variable it reads.

Routing

On each routed call the mod sets the model and the effort. One dim line per routed call shows the pick. To turn routing off, disable the plugin in /plugin.

The Jev model row holds the TypeSafe alias the request names:

  • jev-latest (default): the most recent official Jev release.
  • jev-preview: the most recent release, official or not.

Both aliases move when TypeSafe ships a release, so the picks can shift with no change to the plugin. TypeSafe's Models page says which version each alias points to.

The mod routes Agent tool calls only. A fork, a named agent and a call that names a model pass through untouched. So do agents that a workflow script starts, or that another plugin starts without the Agent tool. The mod overrides a model or an effort pinned in an agent definition's frontmatter, because the call does not carry them. Name model or effort in the Agent call to keep your own.

What leaves the machine

On each routed call the mod sends one HTTPS POST to https://api.typesafe.ai/v1/systemone through Claude Code's own $.http.fetch, with your TypeSafe key as the bearer token. The body holds:

  • the task text, condensed and redacted as below
  • the agent type and the caller's one-line description
  • the Jev alias from the Jev model row and two scoring questions with their three texts each, which ship with the plugin

Two steps run on the task text before it leaves. Fenced code blocks become a one-line size note. Token-shaped strings are masked: Bearer …, sk-… keys, and assignments to names ending in key, token, secret or password. Nothing else leaves the machine.

The request is sent before Claude Code's permission check for the call. Before routing, the mod asks Claude Code's permission rules about the call's original arguments. If they deny the call, nothing is sent. A rule on the model the mod picks, a PreToolUse hook and the auto-mode classifier decide after the request left. A call that prompts you is sent before you answer. To read each request as it left, start Claude Code with --debug. The mod writes each request body to the debug log, in numbered pieces when it is long. The key is never written.

Any failure, from a missing key to a timeout, leaves the call unchanged.

PRIVACY.md states the same in policy form.

Effort

Jev answers a second question: how much work the task takes once the judgment is settled, as an index from 0 to 2. The index rests on the middle and moves to an end only when Jev gives that end more than twice the middle's probability. The index then maps to an effort per model:

indexhaikusonnetopus
0, a short taskmediumlowlow
1, an ordinary taskhighmediummedium
2, a long or delicate taskxhighhighhigh

The router never sets Haiku below medium, because Anthropic measured early stops at low. max is never set. An effort named in the Agent call is kept, with or without a model. A model named in the call keeps both.

Examples

The mod needs no prompt of its own. It acts whenever Claude calls the Agent tool. Three dispatches and what the mod does with them:

  1. "Copy this function into utils.ts exactly as given and run the tests." The task holds the exact code, so Jev scores it level 0. The subagent runs on haiku.
  2. "Check this diff against the five requirements in the spec and list what is missing." Bounded judgment over given material scores level 1, sonnet.
  3. "Review the whole branch for design problems and propose a split." Open judgment scores level 2, opus.

The first runs on haiku. For it the log shows:

model-router L0 haiku · medium · p=0.91/0.62

The second number is the probability of the chosen work index. A kept caller effort shows as caller effort in place of the ladder label. The pick is logged as the call starts, and a call that Claude Code then refuses adds a second line, denied.

Troubleshooting

  • No log line appears: the plugin is disabled, the call passed through, or your permission rules deny the Agent call. Forks, named agents and calls that name a model pass through. Enable the plugin in /plugin.
  • The line says model-router skipped, api_key unset: the key is not set. Set it as Install says for your app, then run /reload-plugins. In the desktop app a saved key can read back as empty after a restart, so set TYPESAFE_API_KEY in the env block of ~/.claude/settings.json instead.
  • The line says skipped, HTTP 401: TypeSafe rejected the key. Check it at https://console.typesafe.ai/keys.
  • The line says skipped, no answer in 8000 ms: the request timed out. The call ran unchanged. Check the network and try again.
  • A subagent ran on a model you did not expect: a model pinned in an agent definition is overridden. Pass model in the Agent call to keep it.
  • A subagent thinks harder or less than you expect: the effort ladder in Effort set it. Name effort in the Agent call to keep your own.
  • A subagent ran on a forced or substituted model at the router's effort: CLAUDE_CODE_SUBAGENT_MODEL_FORCE or availableModels replaced the pick. The effort stayed with the call. Name effort in the Agent call.
  • A kind of task lands on the wrong tier: add a labelled brief to calibration.jsonl at the repository root and reword a tier text in hooks/router.ts. Then run the calibration script.

Support

Questions and bug reports go to https://github.com/Akurganow/model-router/issues. The privacy policy is in PRIVACY.md.

Source 2 files
hooks/register.ts 104 lines
1import type { Register } from "claude-code";
2import { route } from "./router.ts";
3
4const HTTP_TIMEOUT_MS = 8000;
5// The debug log keeps 10000 characters of a line, and a body can carry 12000 of task text.
6const DEBUG_PIECE_CHARS = 9000;
7
8type AgentCall = {
9	prompt: string;
10	description: string;
11	subagent_type?: string;
12	model?: string;
13	effort?: string;
14	name?: string;
15};
16
17export const register: Register = (on, options) => {
18	const { api_key: keyOption, model: modelOption } = options;
19	const jev = modelOption === "jev-preview" ? "jev-preview" : "jev-latest";
20	const key = (v: unknown) =>
21		typeof v === "string" && v !== "" ? v : undefined;
22
23	on("tool.call", { tool: "Agent" }, async ($, e, next) => {
24		const call = e as typeof e & AgentCall;
25		// A fork inherits its parent's model, a named agent is a teammate, and a caller-named model keeps its effort too.
26		if (
27			call.subagent_type === "fork" ||
28			call.name !== undefined ||
29			call.model !== undefined
30		)
31			return next(e);
32		// A call the user's rules deny as Claude made it never leaves the machine. The check opens no dialog.
33		const {
34			tool: _tool,
35			tool_use_id: _id,
36			agentId: _agentId,
37			requestMeta: _meta,
38			...input
39		} = e as Record<string, unknown>;
40		const check = await $.tool.check({ tool: "Agent", input });
41		if (check.decision === "deny") return next(e);
42		const keep = call.effort !== undefined;
43		// The key goes only to TypeSafe, which issued it. The sensitive option comes first. The desktop app
44		// reads it back empty after a restart, so the variable stands in when it is empty.
45		const apiKey = key(keyOption) ?? key(await $.env.get("TYPESAFE_API_KEY"));
46
47		const post = async (body: string): Promise<string> => {
48			if (!apiKey) throw new Error("api_key unset");
49			const pieces = Math.ceil(body.length / DEBUG_PIECE_CHARS);
50			for (let i = 0; i < pieces; i++)
51				$.ui.log(
52					`request ${i + 1}/${pieces} ${body.slice(i * DEBUG_PIECE_CHARS, (i + 1) * DEBUG_PIECE_CHARS)}`,
53					{ to: "debug" },
54				);
55			const stop = new AbortController();
56			const timeout = $.clock
57				.sleep(HTTP_TIMEOUT_MS, {
58					signal: AbortSignal.any([stop.signal, next.signal]),
59				})
60				.then(() => {
61					throw new Error(`no answer in ${HTTP_TIMEOUT_MS} ms`);
62				});
63			try {
64				const res = await Promise.race([
65					$.http.fetch("https://api.typesafe.ai/v1/systemone", {
66						method: "POST",
67						headers: {
68							Authorization: `Bearer ${apiKey}`,
69							"Content-Type": "application/json",
70						},
71						body,
72					}),
73					timeout,
74				]);
75				if (!res.ok) throw new Error(`HTTP ${res.status}`);
76				return res.text;
77			} finally {
78				stop.abort();
79			}
80		};
81
82		const decision = await route(
83			{
84				role: call.subagent_type ?? "general-purpose",
85				description: call.description,
86				prompt: call.prompt,
87				model: jev,
88			},
89			post,
90		);
91		if ("skipped" in decision) {
92			$.ui.log(`skipped, ${decision.skipped}`);
93			return next(e);
94		}
95		const { level, model, workIndex, effort, probabilities } = decision;
96		const p = `${probabilities.tier[level].toFixed(2)}/${probabilities.work[workIndex].toFixed(2)}`;
97		const pick = `L${level} ${model} · ${keep ? "caller effort" : effort} · p=${p}`;
98		$.ui.log(pick);
99		const r = await next(keep ? { ...e, model } : { ...e, model, effort });
100		if ("deny" in r && r.deny !== undefined) $.ui.log("denied");
101		return r;
102	}).catch((_, e, next) => next(e));
103};
104
hooks/router.ts 168 lines
1export const BUDGET_CHARS = 12000;
2export const TIE_MARGIN = 0.1;
3export const WORK_PRIOR = [0.5, 1, 0.5] as const;
4
5export type Model = "haiku" | "sonnet" | "opus";
6export type Effort = "low" | "medium" | "high" | "xhigh";
7export type Level = 0 | 1 | 2;
8export type Probabilities = Record<`${Level}`, number>;
9
10export const TIERS = [
11	{
12		model: "haiku",
13		text: "Lookup or transcription: the brief names the exact files to read or string to find, or holds the exact code, config or prose to write. It gives the commands to run and the check that proves the work done. No choice is left.",
14	},
15	{
16		model: "sonnet",
17		text: "Bounded judgment: the brief fixes the goal and a way to check the result. Check work against stated requirements, fix named defects, implement from a given plan or spec, or answer a question from a few named files.",
18	},
19	{
20		model: "opus",
21		text: "Open judgment: the brief leaves the approach open. Decide a design, create content with no plan or spec to follow, review a multi-file change for risks no checklist names, or debug with an unclear cause.",
22	},
23] as const satisfies readonly { model: Model; text: string }[];
24
25export const WORK_LEVELS: readonly string[] = [
26	"A short task: one or two mechanical steps in one place, nothing to diagnose, and the given command is the whole check.",
27	"An ordinary task: reading or diagnosing code, a few steps or files, and a result to verify.",
28	"A long or delicate task: many steps or files, hidden edge cases, or a wrong result that would look right.",
29];
30
31// Effort is calibrated per model. Haiku's row sits one step higher: at low it stops early on agentic prompts.
32export const EFFORT_LADDER: Record<Model, readonly [Effort, Effort, Effort]> = {
33	haiku: ["medium", "high", "xhigh"],
34	sonnet: ["low", "medium", "high"],
35	opus: ["low", "medium", "high"],
36};
37
38const TIER_INSTRUCTIONS =
39	"How much judgment does completing `task` require from an agent whose role is `role`? `description` is the caller's one-line summary of the task.";
40const WORK_INSTRUCTIONS =
41	"How much work does completing `task` take for an agent whose role is `role`, once the judgment is settled? Count the steps, the files and the edge cases to verify.";
42
43// The task text goes to a third party, and nothing marks which strings are secrets, so redaction matches shapes.
44const REDACT_PATTERNS: readonly [RegExp, string][] = [
45	[/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer ***"],
46	[/\bsk-[A-Za-z0-9_-]{8,}/g, "***"],
47	[
48		/\b([A-Za-z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD))(["']?\s*[=:]\s*)(?:"[^"]*"?|'[^']*'?|[^\s"']+)/gi,
49		"$1$2***",
50	],
51];
52
53export type RouteInput = {
54	role: string;
55	description: string;
56	prompt: string;
57	model: string;
58};
59export type Post = (body: string) => Promise<string>;
60export type Decision = {
61	model: Model;
62	level: Level;
63	workIndex: Level;
64	effort: Effort;
65	probabilities: { tier: Probabilities; work: Probabilities };
66};
67export type Skipped = { skipped: string };
68
69export async function route(
70	input: RouteInput,
71	post: Post,
72): Promise<Decision | Skipped> {
73	const task = redact(condense(input.prompt));
74	const body = JSON.stringify({
75		model: input.model,
76		state: { role: input.role, description: input.description, task },
77		questions: {
78			tier: {
79				type: "score",
80				instructions: TIER_INSTRUCTIONS,
81				criteria: TIERS.map((t) => t.text),
82			},
83			work: {
84				type: "score",
85				instructions: WORK_INSTRUCTIONS,
86				criteria: WORK_LEVELS,
87			},
88		},
89	});
90	let text: string;
91	try {
92		text = await post(body);
93	} catch (err) {
94		return { skipped: err instanceof Error ? err.message : String(err) };
95	}
96	let answers: unknown;
97	try {
98		answers = JSON.parse(text)?.answers;
99	} catch {
100		return { skipped: "malformed answer" };
101	}
102	const tier = parseScore(answers, "tier");
103	const work = parseScore(answers, "work");
104	if (!tier || !work) return { skipped: "malformed answer" };
105	const level = pickTier(tier);
106	const workIndex = pickWork(work);
107	const model = TIERS[level].model;
108	return {
109		model,
110		level,
111		workIndex,
112		effort: EFFORT_LADDER[model][workIndex],
113		probabilities: { tier, work },
114	};
115}
116
117function condense(text: string): string {
118	return text
119		.replace(/```[^\n]*\n([\s\S]*?)```/g, (_, block: string) => {
120			const lines = block.replace(/\n$/, "").split("\n").length;
121			return `[an exact block of ${lines} lines is given verbatim]`;
122		})
123		.replace(/\n{3,}/g, "\n\n")
124		.slice(0, BUDGET_CHARS);
125}
126
127function redact(text: string): string {
128	return REDACT_PATTERNS.reduce(
129		(t, [pattern, replacement]) => t.replace(pattern, replacement),
130		text,
131	);
132}
133
134const isRecord = (v: unknown): v is Record<string, unknown> =>
135	typeof v === "object" && v !== null;
136
137function parseScore(answers: unknown, key: string): Probabilities | undefined {
138	const a = isRecord(answers) ? answers[key] : undefined;
139	if (!isRecord(a)) return undefined;
140	const { type, probabilities } = a;
141	if (type !== "score" || !isRecord(probabilities)) return undefined;
142	const { 0: p0, 1: p1, 2: p2 } = probabilities;
143	if (
144		typeof p0 !== "number" ||
145		typeof p1 !== "number" ||
146		typeof p2 !== "number"
147	)
148		return undefined;
149	return { 0: p0, 1: p1, 2: p2 };
150}
151
152// A near tie goes to the highest level within the margin: when unsure, the larger model.
153function pickTier(p: Probabilities): Level {
154	const best = ([0, 1, 2] as Level[]).reduce((a, b) => (p[b] > p[a] ? b : a));
155	for (const i of [2, 1] as Level[])
156		if (i > best && p[best] - p[i] < TIE_MARGIN) return i;
157	return best;
158}
159
160// The middle is the resting point: an end wins only with more than twice its probability. Equal ends go to the heavier one.
161function pickWork(p: Probabilities): Level {
162	let best: Level = 1;
163	for (const i of [2, 0] as Level[]) {
164		if (p[i] * WORK_PRIOR[i] > p[best] * WORK_PRIOR[best]) best = i;
165	}
166	return best;
167}
168