Picks the model for each subagent that names none, with OpenAI's Decisions API or Jev, from its short label only. One line per spawn above the prompt; /route…

A private reference collection of public Claude Code mods, with a security-oriented analysis.
mods/Nothing here is installed or executed. All code is by its upstream authors under their own licences (MIT, one Apache-2.0); each folder keeps its original LICENSE.
hooks/register.tsx 286 lines1// Switchboard: the right Claude model for each subagent, and what each one cost.
2// Before a subagent starts that names no model, a picker chooses one from a ladder (Haiku,
3// Sonnet, Opus, or Sonnet, Opus, Fable) by its short label alone: Jev (TypeSafe's decision
4// model, directly or through Vercel AI Gateway) or OpenAI's Decisions API, each a fraction
5// of a cent a pick. A model the caller named is kept. With no picker, no key, or no answer
6// in time, nothing changes. In auto mode the pick replaces the model the spawn would have run
7// on; in suggest mode it is only shown. /route lists every subagent, its pick and its cost.
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10
11import type { Route, Tier } from '../types'
12import { bandsFile, parseOrder, stackBands } from './bands'
13import { LADDERS, PICKER_NAME, PICK_URL, PRICES_AS_OF, addUsage, costOf, parsePick, pickRequest, tierOf, usd } from './route'
14import type { Ladder, Style, Via } from './route'
15
16const PANE = 'switchboard'
17const PICK_TIMEOUT_MS = 4_000 // the spawn waits this long for the picker at most
18const MIN_CONFIDENCE = 0.5 // below this, a picker's pick is shown but not applied
19const SHOW_DONE_MS = 30_000 // a finished spawn stays in the band this long
20const BAND_ROWS = 3
21const PANE_ROWS = 12
22
23// Held by the host, so a hot reload keeps the log.
24const routes = atom({ plugin: 'switchboard', key: 'routes' } as const, [] as Route[])
25const now = atom({ plugin: 'switchboard', key: 'now' } as const, 0)
26
27let bandOrder: Promise<string[]> | undefined // read once per session: restart to apply
28
29// The band order file, as the claude-code-mods checklist saves it; [] on any error.
30async function readBandOrder($: EngineInterface): Promise<string[]> {
31 const file = bandsFile(await $.env.get('CLAUDE_CONFIG_DIR'), await $.env.get('HOME'))
32 return file && (await $.fs.exists(file)) ? parseOrder(await $.fs.read(file)) : []
33}
34
35// Forgets the cached band order, for tests.
36export function resetBandOrder() {
37 bandOrder = undefined
38}
39
40export const register: Register = (on, options) => {
41 const mode = options.mode === 'suggest' ? 'suggest' : 'auto'
42 const text = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
43 const settings: Settings = {
44 picker: options.picker === 'jev' || options.picker === 'openai' ? options.picker : 'off',
45 ladder: options.models === 'sonnet-opus-fable' ? 'sonnet-opus-fable' : 'haiku-sonnet-opus',
46 style: options.style === 'quality' ? 'quality' : 'saver',
47 respectNamed: options.respectNamed !== false,
48 jev: text(options.jevApiKey),
49 gateway: text(options.gatewayApiKey),
50 openai: text(options.openaiApiKey),
51 zeroRetention: options.gatewayZeroRetention === true,
52 }
53
54 on('session.start', async ($, e, next) => {
55 const r = await next(e)
56 await $.command.register({ name: 'route', description: 'Show which model Switchboard picked for each subagent, and what it cost' }).catch(() => {}) // a name Claude Code already has is refused: start anyway
57 // Redraws the band so a finished line leaves after 30 s; quiet when nothing is shown.
58 $.clock.every(5_000, () => {
59 void (async () => {
60 if ((await read($, routes)).some(r => isShown(r, Date.now() - 5_000))) await update($, now, () => Date.now())
61 })().catch(() => {})
62 })
63 return r
64 })
65
66 on('agent.spawn', async ($, e, next) => {
67 if (e.fork || e.isTeammate) return next(e) // a fork runs on its parent's model; a teammate keeps the one it was given
68 const asked = baseline(e)
69 const named = settings.respectNamed && !!e.model && e.model !== 'inherit'
70 const pick: Pick = named
71 ? { by: 'none', reason: 'named by the caller, kept' }
72 : await credential($, settings)
73 .then(c => decide($, e, c, settings))
74 .catch(() => ({ by: 'none' as const, reason: 'picker failed, kept' }))
75 const sure = pick.confidence === undefined || pick.confidence >= MIN_CONFIDENCE
76 const apply = mode === 'auto' && sure && !!pick.tier && asked !== undefined && pick.tier !== tierOf(asked)
77 const r = await next(apply ? { ...e, model: pick.tier } : e)
78 if (r.deny !== undefined) return r
79 const one: Route = {
80 id: e.tool_use_id,
81 agentId: r.agentId,
82 description: e.description || e.name || e.subagentType,
83 type: e.subagentType,
84 asked,
85 picked: pick.tier,
86 by: pick.by,
87 confidence: pick.confidence,
88 reason: sure ? pick.reason : `${pick.reason}, too unsure to switch`,
89 applied: apply,
90 status: 'running',
91 startedAt: Date.now(),
92 model: r.model,
93 pickerUsd: pick.pickerUsd,
94 }
95 await update($, routes, list => trim([one, ...list.filter(x => x.id !== one.id)])).catch(() => {})
96 return r
97 })
98
99 // Each subagent turn's tokens, counted once: a turn.complete is one turn.
100 on('turn.complete', async ($, e, next) => {
101 const r = await next(e)
102 if (!e.agentId) return r
103 const id = e.agentId
104 const failed = e.reason === 'error' || e.reason === 'aborted'
105 await update($, routes, list =>
106 list.map(x => {
107 if (x.agentId !== id) return x
108 const usage = r.usage ? addUsage(x.usage, r.usage) : x.usage
109 // What it ran on: the API's word, else the switch we made, else what was asked.
110 const model = r.usage?.model ?? x.model ?? (x.applied ? x.picked : x.asked) ?? x.picked ?? ''
111 return {
112 ...x,
113 status: failed ? 'failed' : 'done',
114 endedAt: Date.now(),
115 model,
116 usage,
117 costUsd: usage ? costOf(model, usage) : x.costUsd,
118 // Not switched, it ran on what was asked; switched, the same tokens at the asked model's prices.
119 askedUsd: !usage ? x.askedUsd : x.applied && x.asked ? costOf(x.asked, usage) : costOf(model, usage),
120 }
121 }),
122 )
123 return r
124 })
125
126 on('command.run', { command: 'route' }, async $ => {
127 await $.ui.open({ id: PANE, title: 'Switchboard', focus: true })
128 const t = totals(await read($, routes))
129 return { text: `${t.count} subagents, ${t.switched} switched · ${usd(t.cost)} spent, ${usd(t.asked)} at the asked models` }
130 })
131
132 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
133 const rest = await next(e) // what other mods and Claude Code draw here stays
134 await read($, now) // subscribes the band to the clock
135 const shown = (await read($, routes)).filter(r => isShown(r))
136 if (e.props.hasSurvey || shown.length === 0) return rest
137 const { Box, Text } = $.ui.resolve(e)
138 const mine = (
139 <Box flexDirection="column">
140 {shown.slice(0, BAND_ROWS).map(r => (
141 <Text wrap="truncate-end">
142 <Text color={color(r)} bold>{` ⇄ ${r.description}`}</Text>
143 <Text>{` ${move(r, mode)}`}</Text>
144 <Text dimColor>{` ${source(r)}${r.status === 'running' ? '' : ` · ${usd(r.costUsd)}`}`}</Text>
145 </Text>
146 ))}
147 {shown.length > BAND_ROWS && <Text dimColor>{` +${shown.length - BAND_ROWS} more · /route`}</Text>}
148 </Box>
149 )
150 bandOrder ??= readBandOrder($).catch(() => [])
151 return stackBands(rest, 'switchboard', mine, 'above', await bandOrder) // above what other mods draw, unless the band order says otherwise
152 })
153
154 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
155 const { Box, Text, Button } = $.ui.resolve(e)
156 const list = await read($, routes)
157 const t = totals(list)
158 const keyless = settings.picker !== 'off' && !(await credential($, settings).catch(() => null))
159 return (
160 <Box flexDirection="column">
161 <Text bold>{`${t.count} subagents · ${t.switched} switched · mode: ${mode} · picker: ${settings.picker}${keyless ? ' (no key, nothing changes)' : ''}`}</Text>
162 <Text>
163 <Text>{`spent ${usd(t.cost)}`}</Text>
164 <Text dimColor>{` · at the asked models ${usd(t.asked)} · picker ${usd(t.picker)}`}</Text>
165 </Text>
166 <Text dimColor>{`API price estimates as of ${PRICES_AS_OF}, not plan charges. ? = not known yet.`}</Text>
167 <Text dimColor>{'─'.repeat(Math.max(10, e.props.bodyColumns - 2))}</Text>
168 {list.length === 0 && <Text dimColor>No subagents yet this session.</Text>}
169 {list.slice(0, PANE_ROWS).map(r => (
170 <Box flexDirection="column">
171 <Text wrap="truncate-end">
172 <Text color={color(r)} bold>{`${icon(r)} ${r.description}`}</Text>
173 <Text>{` ${move(r, mode)}`}</Text>
174 <Text dimColor>{` ${r.status === 'running' ? 'running' : `${usd(r.costUsd)} (asked ${usd(r.askedUsd)})`}`}</Text>
175 </Text>
176 <Text dimColor wrap="truncate-end">{` ${r.type} · ${r.by === 'none' ? r.reason : `${source(r)} · ${r.reason}`}${r.model ? ` · ran on ${r.model}` : ''}`}</Text>
177 </Box>
178 ))}
179 {list.length > PANE_ROWS && <Text dimColor>{`… ${list.length - PANE_ROWS} older`}</Text>}
180 <Box flexDirection="row" gap={1}>
181 <Button key="clear" label="Clear finished" hotkey="c" onPress={() => update($, routes, all => all.filter(x => x.status === 'running'))} />
182 <Button key="close" label="Close" hotkey="q" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
183 </Box>
184 </Box>
185 )
186 })
187}
188
189type Pick = { tier?: Tier; by: Route['by']; confidence?: number; reason: string; pickerUsd?: number }
190type Settings = { picker: 'off' | 'jev' | 'openai'; ladder: Ladder; style: Style; respectNamed: boolean; jev: string; gateway: string; openai: string; zeroRetention: boolean }
191type Credential = { via: Via; key: string } | null
192
193// The picker the settings chose, with its key. A key set in /config wins over one from the
194// environment. Jev: a TypeSafe key goes straight to TypeSafe, a gateway key through Vercel AI
195// Gateway. OpenAI: the setting, then OPENAI_API_KEY. A key alone never turns a picker on:
196// many tools set these variables.
197async function credential($: EngineInterface, s: Settings): Promise<Credential> {
198 if (s.picker === 'openai') {
199 const key = s.openai || ((await $.env.get('OPENAI_API_KEY').catch(() => undefined)) ?? '').trim()
200 return key ? { via: 'openai', key } : null
201 }
202 if (s.picker !== 'jev') return null
203 if (s.jev) return { via: 'typesafe', key: s.jev }
204 if (s.gateway) return { via: 'gateway', key: s.gateway }
205 const typesafe = ((await $.env.get('TYPESAFE_API_KEY').catch(() => undefined)) ?? '').trim()
206 if (typesafe) return { via: 'typesafe', key: typesafe }
207 const gateway = ((await $.env.get('AI_GATEWAY_API_KEY').catch(() => undefined)) ?? '').trim()
208 return gateway ? { via: 'gateway', key: gateway } : null
209}
210
211// The picker when there is one and it answers in time; otherwise no pick, and nothing changes.
212async function decide($: EngineInterface, e: { subagentType: string; description: string }, c: Credential, s: Settings): Promise<Pick> {
213 if (!c) return { by: 'none', reason: s.picker === 'off' ? 'no picker' : `no ${s.picker === 'openai' ? 'OpenAI' : 'Jev'} key` }
214 const name = PICKER_NAME[c.via]
215 const timeout = $.clock.sleep(PICK_TIMEOUT_MS).then(() => null)
216 const asked = $.http
217 .fetch(PICK_URL[c.via], {
218 method: 'POST',
219 headers: { Authorization: `Bearer ${c.key}`, 'Content-Type': 'application/json' },
220 body: JSON.stringify(pickRequest(e, c.via, { ladder: s.ladder, style: s.style, zeroRetention: s.zeroRetention })),
221 })
222 .then(r => (r.ok ? parsePick(r.text, c.via, LADDERS[s.ladder]) : null))
223 .catch(() => null)
224 const got = await Promise.race([asked, timeout])
225 if (!got) return { by: 'none', reason: `${name} did not answer` }
226 const by: Pick['by'] = c.via === 'openai' ? 'openai' : 'jev'
227 return { tier: got.tier, by, confidence: got.confidence, reason: `${name} picked ${got.tier}`, pickerUsd: got.costUsd }
228}
229
230// The model the spawn would run on without us, when we can know it: what the caller named,
231// or the parent's for an agent that inherits. Undefined when the agent's own definition
232// decides, which a hook can't see; such a spawn gets a suggestion, never a switch.
233export function baseline(e: { model?: string; parentModel: string; subagentType: string }): string | undefined {
234 if (e.model && e.model !== 'inherit') return e.model
235 if (e.model === 'inherit' || e.subagentType === 'general-purpose') return e.parentModel
236 return undefined
237}
238
239// The newest 50, but never a running one: its cost and status are still to come.
240export function trim(list: Route[]) {
241 return list.filter((r, i) => i < 50 || r.status === 'running')
242}
243
244// Running, or finished in the last 30 s.
245export function isShown(r: Route, at = Date.now()) {
246 return r.status === 'running' || (r.endedAt !== undefined && at - r.endedAt < SHOW_DONE_MS)
247}
248
249// "opus → sonnet", "sonnet (kept)", "opus · try sonnet" in suggest mode, or, with no pick, the
250// model it runs on and why nothing was picked.
251export function move(r: Route, mode: 'auto' | 'suggest') {
252 const from = r.asked === undefined ? 'own model' : (tierOf(r.asked) ?? r.asked)
253 if (!r.picked) return from
254 if (r.asked === undefined) return `own model · try ${r.picked}`
255 if (r.applied) return `${from} → ${r.picked}`
256 if (tierOf(r.asked) === r.picked) return `${r.picked} (kept)`
257 return mode === 'suggest' ? `${from} · try ${r.picked}` : `${from} (kept)`
258}
259
260export function totals(list: Route[]) {
261 const sum = (f: (r: Route) => number | undefined) => list.reduce((n, r) => n + (f(r) ?? 0), 0)
262 return {
263 count: list.length,
264 switched: list.filter(r => r.applied).length,
265 cost: sum(r => r.costUsd) + sum(r => r.pickerUsd),
266 asked: sum(r => r.askedUsd),
267 picker: sum(r => r.pickerUsd),
268 }
269}
270
271function source(r: Route) {
272 return r.by === 'none' ? r.reason : `${r.by} ${pct(r.confidence ?? 0)} sure`
273}
274
275function pct(n: number) {
276 return `${Math.round(n * 100)}%`
277}
278
279function icon(r: Route) {
280 return r.status === 'running' ? '●' : r.status === 'done' ? '✓' : '✗'
281}
282
283function color(r: Route) {
284 return r.status === 'running' ? 'yellow' : r.status === 'done' ? 'green' : 'red'
285}
286hooks/bands.ts 73 lines1// Band order: every band mod ships this same file (canonical copy: shared/bands.ts in the repo).
2// The order file, <configDir>/claude-code-mods/bands.json, lists mod names top to bottom
3// (the last sits right above the prompt). Each band mod wraps its band in a Box keyed
4// `band:<name>` and returns one column keyed `bands`; the next band mod unpacks that
5// column, adds its own band and sorts again, so the last one to draw sorts them all.
6// Edit shared/bands.ts, then copy it over every mod's hooks/bands.ts (a repo test checks).
7// Reading the file needs $, which Claude Code follows only within one file, so each mod
8// reads it in its own register file with bandsFile and parseOrder, once per session.
9import type { RenderElement, RenderNode } from 'claude-code'
10
11const COLUMN = 'bands'
12const PREFIX = 'band:'
13
14// Where the checklist saves the order: CLAUDE_CONFIG_DIR, or ~/.claude. Undefined with neither.
15export function bandsFile(configDir: string | undefined, home: string | undefined): string | undefined {
16 const dir = configDir ? configDir.replace(/\/+$/, '') : home ? `${home}/.claude` : ''
17 return dir ? `${dir}/claude-code-mods/bands.json` : undefined
18}
19
20// The names in an order file; [] when it is no JSON or not { order: string[] }.
21export function parseOrder(text: string): string[] {
22 try {
23 const order = (JSON.parse(text) as { order?: unknown } | null)?.order
24 return Array.isArray(order) && order.every(n => typeof n === 'string') ? order : []
25 } catch {
26 return []
27 }
28}
29
30/**
31 * One column of bands: `mine` wrapped in a Box keyed `band:<name>`, placed
32 * `at` (above or below) what `next(e)` returned, then sorted by `order`.
33 * With an order, items that are no band come first, then bands not in the
34 * order (in their current order), then the listed ones in file order. With
35 * no order nothing moves, so the screen looks as it did before.
36 */
37export function stackBands(rest: RenderElement | null | undefined, name: string, mine: RenderNode, at: 'above' | 'below', order: string[]): RenderElement {
38 const own: RenderElement = { type: 'Box', props: { key: PREFIX + name, flexDirection: 'column' }, children: [mine] }
39 const others = unpack(rest)
40 const items = at === 'above' ? [own, ...others] : [...others, own]
41 return { type: 'Box', props: { key: COLUMN, flexDirection: 'column' }, children: order.length ? sorted(items, order) : items }
42}
43
44// Another band mod's column gives up its items; anything else is one item.
45function unpack(rest: RenderElement | null | undefined): RenderNode[] {
46 if (!rest) return []
47 if (!isColumn(rest)) return [rest]
48 const kids = rest.children ?? []
49 const owner = rest.part?.plugin
50 // The engine stamped the column with its owner: that stamp belongs on the owner's own band.
51 return kids.map(k => (owner && typeof k !== 'string' && bandName(k) === owner && !('part' in k && k.part) ? { ...k, part: rest.part } : k))
52}
53
54function isColumn(el: RenderElement): el is RenderElement & { type: 'Box' } {
55 return el.type === 'Box' && el.props?.key === COLUMN
56}
57
58function bandName(node: RenderNode): string | undefined {
59 if (typeof node === 'string' || node.type !== 'Box') return undefined
60 const key = node.props?.key
61 return typeof key === 'string' && key.startsWith(PREFIX) ? key.slice(PREFIX.length) : undefined
62}
63
64function sorted(items: RenderNode[], order: string[]): RenderNode[] {
65 const rank = (n: RenderNode) => {
66 const name = bandName(n)
67 if (name === undefined) return -2 // not a band: first
68 const i = order.indexOf(name)
69 return i < 0 ? -1 : i // a band not in the order: on top of the listed ones
70 }
71 return items.map((n, i) => ({ n, i, r: rank(n) })).sort((a, b) => a.r - b.r || a.i - b.i).map(x => x.n)
72}
73hooks/route.ts 147 lines1// The routing brain, kept free of the engine so tests can call it directly:
2// the model ladders and styles, each picker's request and answer, and the cost of a run.
3import type { Tier, Usage } from '../types'
4
5// The models a picker may choose between, cheapest first. A ladder is a setting.
6export type Ladder = 'haiku-sonnet-opus' | 'sonnet-opus-fable'
7export const LADDERS: Record<Ladder, readonly Tier[]> = {
8 'haiku-sonnet-opus': ['haiku', 'sonnet', 'opus'],
9 'sonnet-opus-fable': ['sonnet', 'opus', 'fable'],
10}
11
12// How a picker weighs cost against quality, a setting too: the question it answers and what each
13// model is for. "quality" was tuned against one developer's own model choices (see docs/mods.md).
14export type Style = 'saver' | 'quality'
15const QUESTION: Record<Style, string> = {
16 saver: 'A coding agent is about to hand this task to a subagent. Which model is the cheapest one that will still do the task well?',
17 quality: 'Which model would this developer pick for this subagent? They pay for quality on anything they will rely on, and use the cheaper model for routine execution.',
18}
19const DESCRIBE: Record<Style, Record<Ladder, Partial<Record<Tier, string>>>> = {
20 saver: {
21 'haiku-sonnet-opus': {
22 haiku: 'Lookups and light work: searching or listing files, reading and summarizing code or docs, answering a factual question about the repo, small mechanical edits.',
23 sonnet: 'Everyday coding: implementing a feature, fixing an ordinary bug, writing or updating tests, reviewing a diff, editing a few files.',
24 opus: 'Hard thinking: planning or architecture, debugging a subtle or intermittent issue, security review, large multi-file refactors, work where a wrong answer is costly.',
25 },
26 'sonnet-opus-fable': {
27 sonnet: 'Everything routine: lookups, reading, summarizing, research, ordinary coding, tests, reviews and verification of ordinary diffs, docs, small refactors, ordinary bug fixes.',
28 opus: 'Hard thinking: planning and architecture, subtle or intermittent bugs, security reviews, large multi-file refactors, work where a wrong answer is costly.',
29 fable: 'The hardest few: long-horizon autonomous work running for hours, novel research-grade problems, or the highest-stakes calls where even Opus is likely to fall short. Costs 2.5x Opus, so only when clearly needed.',
30 },
31 },
32 quality: {
33 'haiku-sonnet-opus': {
34 haiku: 'Only trivial lookups: listing or finding files, reading one file, a quick factual check.',
35 sonnet: 'Routine execution: implementing or fixing something whose plan is already decided, routine ops and maintenance, searches and summaries, the standard pre-merge review of a pull request, and verifying that review fixes were applied.',
36 opus: 'Anything the developer will rely on: audits and quality checks of finished work, independent or fresh-eyes reviews, extra code reviews and regression hunts, planning, design and idea generation, building a new feature end to end on its own, and high-stakes or security-sensitive work.',
37 },
38 'sonnet-opus-fable': {
39 sonnet: 'Routine execution: implementing or fixing something whose plan is already decided, routine ops and maintenance (servers, disk, configs, sweeps, CI chores), lookups, searches and summaries, the standard pre-merge review of a pull request, and verifying that review fixes were applied.',
40 opus: 'Anything the developer will rely on: audits and quality checks of finished work (papers, docs, figures, numbers, references), independent or fresh-eyes reviews, code reviews and regression hunts asked for on top of the standard one, planning, design and idea generation, building a new feature end to end on its own, and high-stakes or security-sensitive work.',
41 fable: 'Rare: long autonomous runs left alone for hours, or research-grade problems where even Opus is likely to fall short.',
42 },
43 },
44}
45
46// API prices in USD per million tokens, as published on 2026-09-25. Estimates, not plan charges.
47export const PRICES_AS_OF = '2026-09-25'
48const PRICES: { match: RegExp; input: number; output: number; cacheRead: number }[] = [
49 { match: /haiku/i, input: 1, output: 5, cacheRead: 0.1 },
50 { match: /sonnet/i, input: 2, output: 10, cacheRead: 0.2 },
51 { match: /opus/i, input: 4, output: 20, cacheRead: 0.2 },
52 { match: /fable|mythos/i, input: 10, output: 50, cacheRead: 0.25 },
53]
54// Who picks: Jev, from TypeSafe itself or through Vercel AI Gateway, or OpenAI's Decisions API.
55export type Via = 'typesafe' | 'gateway' | 'openai'
56export const PICK_URL: Record<Via, string> = {
57 typesafe: 'https://api.typesafe.ai/v1/systemone',
58 gateway: 'https://ai-gateway.vercel.sh/v1/evaluate',
59 openai: 'https://api.openai.com/v1/decisions',
60}
61export const PICKER_NAME: Record<Via, 'Jev' | 'OpenAI'> = { typesafe: 'Jev', gateway: 'Jev', openai: 'OpenAI' }
62// Input tokens only; none of them bill output.
63const USD_PER_TOKEN: Record<Via, number> = { typesafe: 0.042 / 1_000_000, gateway: 0.042 / 1_000_000, openai: 0.1 / 1_000_000 }
64
65// Only the agent type and its short label go out: never the task text, which can hold code,
66// paths, hostnames or a pasted key.
67type Task = { subagentType: string; description: string }
68type Ask = { ladder: Ladder; style: Style; zeroRetention?: boolean }
69function stateOf(input: Task) {
70 return { agent_type: input.subagentType, description: input.description }
71}
72
73// The body of one pick: the label, and one choice over the ladder, in each API's own shape.
74// Through the gateway, only TypeSafe may serve it; zero data retention is opt-in, since the
75// gateway refuses it below the Pro plan.
76export function pickRequest(input: Task, via: Via, ask: Ask) {
77 const tiers = LADDERS[ask.ladder]
78 const describe = DESCRIBE[ask.style][ask.ladder]
79 if (via === 'openai') {
80 return {
81 model: 'gpt-6-luna',
82 input: JSON.stringify(stateOf(input)),
83 questions: [{ type: 'choice', name: 'tier', instructions: QUESTION[ask.style], choices: tiers.map(t => ({ value: t, description: describe[t] })) }],
84 }
85 }
86 return {
87 model: via === 'gateway' ? 'typesafe-ai/jev' : 'jev-latest',
88 ...(via === 'gateway' ? { providerOptions: { gateway: { only: ['typesafe-ai'], ...(ask.zeroRetention ? { zeroDataRetention: true } : {}) } } } : {}),
89 state: stateOf(input),
90 questions: { tier: { type: 'choice', instructions: QUESTION[ask.style], criteria: Object.fromEntries(tiers.map(t => [t, describe[t]])) } },
91 }
92}
93
94// Reads a picker's answer; null when it is not one we can use.
95export function parsePick(text: string, via: Via, tiers: readonly Tier[]): { tier: Tier; confidence: number; probabilities: Partial<Record<Tier, number>>; costUsd: number } | null {
96 let body: any
97 try {
98 body = JSON.parse(text)
99 } catch {
100 return null
101 }
102 // OpenAI lists answers and probabilities; Jev keys them by name.
103 const a = via === 'openai' ? (Array.isArray(body?.answers) ? body.answers.find((x: any) => x?.name === 'tier') : undefined) : body?.answers?.tier
104 if (!a || !tiers.includes(a.choice)) return null
105 const probabilities: Partial<Record<Tier, number>> = Array.isArray(a.probabilities)
106 ? Object.fromEntries(a.probabilities.filter((x: any) => tiers.includes(x?.value) && typeof x.probability === 'number').map((x: any) => [x.value, x.probability]))
107 : (a.probabilities ?? {})
108 // TypeSafe and OpenAI send a confidence; the gateway sends only the probabilities, so the pick's own stands in.
109 const confidence = typeof a.confidence === 'number' ? a.confidence : (probabilities[a.choice as Tier] ?? 0)
110 const tokens = body.usage?.input_tokens ?? body.usage?.inputTokens
111 const raw = body.providerMetadata?.gateway?.cost
112 const billed = typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : NaN // the gateway's billed cost, when it sends one
113 const costUsd = Number.isFinite(billed) && billed >= 0 ? billed : typeof tokens === 'number' ? tokens * USD_PER_TOKEN[via] : 0
114 return { tier: a.choice, confidence, probabilities, costUsd }
115}
116
117// Which tier a model name or id belongs to, if any of ours.
118export function tierOf(model: string | undefined): Tier | undefined {
119 if (!model) return undefined
120 return (['haiku', 'sonnet', 'opus', 'fable'] as const).find(t => model.toLowerCase().includes(t))
121}
122
123// What a run cost at API prices; undefined for a model we have no price for.
124export function costOf(model: string, u: Usage): number | undefined {
125 const p = PRICES.find(x => x.match.test(model))
126 if (!p) return undefined
127 const write = p.input * 1.25 // 5-minute cache writes
128 return (u.input_tokens * p.input + u.output_tokens * p.output + u.cache_read_input_tokens * p.cacheRead + u.cache_creation_input_tokens * write) / 1_000_000
129}
130
131export function addUsage(a: Usage | undefined, b: Usage): Usage {
132 if (!a) return { ...b }
133 return {
134 input_tokens: a.input_tokens + b.input_tokens,
135 output_tokens: a.output_tokens + b.output_tokens,
136 cache_read_input_tokens: a.cache_read_input_tokens + b.cache_read_input_tokens,
137 cache_creation_input_tokens: a.cache_creation_input_tokens + b.cache_creation_input_tokens,
138 }
139}
140
141export function usd(n: number | undefined): string {
142 if (n === undefined) return '?'
143 if (n === 0) return '$0'
144 if (n < 0.01) return '<$0.01'
145 return `$${n.toFixed(2)}`
146}
147types/index.d.ts 37 lines1export type Tier = 'haiku' | 'sonnet' | 'opus' | 'fable'
2
3export type Usage = {
4 input_tokens: number
5 output_tokens: number
6 cache_read_input_tokens: number
7 cache_creation_input_tokens: number
8}
9
10// One subagent spawn and what the router did with it.
11export type Route = {
12 id: string // the Agent tool call's id
13 agentId?: string
14 description: string
15 type: string
16 asked?: string // what it would run on without us; undefined when its own agent definition decides
17 picked?: Tier // undefined when nothing picked: a named model, no picker, or no answer
18 by: 'jev' | 'openai' | 'none'
19 confidence?: number // the picker's, 0 to 1
20 reason: string
21 applied: boolean // false in suggest mode, or when the pick matched what was asked
22 status: 'running' | 'done' | 'failed'
23 startedAt: number
24 endedAt?: number
25 model?: string // what it ran on, as the API reports it
26 usage?: Usage
27 costUsd?: number // at API prices, for the tokens it used
28 askedUsd?: number // the same tokens at the asked model's prices
29 pickerUsd?: number // what the pick itself cost
30}
31
32declare module 'claude-code' {
33 interface PluginState {
34 switchboard: { routes: Route[]; now: number }
35 }
36}
37