SLOPSHOPPER

Mixture of Models

Routes each prompt to the cheapest capable model. Simple and coding work runs in a headless child Claude Code on an Anthropic-compatible gateway (GLM 5.3, Kimi…

newbandspinnerrowscommandprompt
v0.3.1MITupdated 2026-10-08alinaqi/mixture-of-models-claude-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mixture-of-models
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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 route: claude-opus-5-5 · [object Object] via claude-classify · 97.4k in / 1.5k out · cache 93% · main session · today: claude-opus-5-5 ×1 › /route ⎿ mixture-of-models: mixture-of-models observe-only (no gateway key for the child) · mode auto · current route: claude-opus-5-5 · [ mixture-of-models · observe-only: no gateway key for the child 1: auto 2: glm 3: kimi 4: claude 5: off ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
mixture-of-models · observe-only: no gateway key for the child 1: auto 2: glm 3: kimi 4: claud ⟨Claude Code's own drawing⟩
README

mixture-of-models-claude-mod

ci License: MIT

A mixture of models for Claude Code. Maggy's model routing, rebuilt as a Claude Code mod: it runs inside Claude Code, so the hand-off to a cheaper model is automatic instead of an instruction Claude has to follow.

claude plugin marketplace add alinaqi/mixture-of-models-claude-mod
claude plugin install mixture-of-models@mixture-of-models-claude-mod

Design rule: the main session is never touched. It stays on your claude.ai subscription with no ANTHROPIC_BASE_URL. When a prompt is routed to GLM or Kimi, the mod answers that turn itself by running a separate, headless claude -p child whose environment alone points at the gateway (srooter). The child's output becomes the turn's answer in your transcript. Nothing about the main process, its model, its auth or its requests changes.

Per prompt the mod:

  1. Classifies it into simple | coding | analysis | critical with a local Ollama model (free), falling back to $.model.classify on claude-haiku-4-5 (your plan), then the last cached label.
  2. Applies Maggy's pre-routing rules first: use claude or execute the plan force critical; go ahead, yes, or a prompt of six words or fewer while tools were just used keeps the current route instead of re-classifying.
  3. Delegates a routed turn to the child: claude -p --model glm-5.3 on the gateway, fed a brief over stdin with the last few exchanges of your session plus the task.
  4. Falls back to the main model for that turn if the child fails or prints nothing, and says so in the transcript.
  5. Shows you what ran where, on every turn. See What you see.

critical turns never start a child. They run in the main session, on the subscription, exactly as if the mod were not there.

What you see

Claude and the gateway are both visible, all the time:

| Surface | Main-session turn | Routed turn | | :- | :- | :- | | Band above the prompt | mixture-of-models · auto · last: claude-opus-5 (critical via rule) 1: auto 2: glm 3: kimi 4: claude 5: off | … · running glm-5.3 in a child… while it runs | | Spinner | Thinking… | Thinking · via glm-5.3 (child)… | | Reply in the transcript | unchanged (or tagged ⇢ claude-opus-5 · main session with ui.tags: "all") | a dim ⇢ glm-5.3 · child on gateway line above the reply, kept in scrollback | | Line under the answer | route: claude-opus-5 · critical via rule · 23.1k in / 0.9k out · cache 91% · main session · today: claude-opus-5 ×3, glm-5.3 ×5 | route: glm-5.3 · coding via ollama · child 42s · today: … | | /route | mixture-of-models live · mode auto · current route: … · today: … | same |

The band's buttons have digit hotkeys: with an empty prompt, type 3 and pause to pin Kimi, 1 to go back to auto. Turn the band off with "ui": { "band": false }.

Requirements

  • Claude Code v2.1.287 or later (claude --version). Mods do not load on older builds; claude update.
  • A gateway the child can use: srooter's Anthropic endpoint and a key, or any Anthropic-compatible endpoint that serves the model ids in routes.
  • Optional: Ollama with a small model for free classification (ollama pull qwen2.5-coder:3b). Without it the classifier falls back to Haiku on your plan.

Setup

See GETTING_STARTED.md for the full walkthrough, or ./install.sh.

  1. Put the gateway key in Maggy's env file, which the mod reads (never your shell or ps):
   echo 'SROOTER_API_KEY=srt_…' >> ~/.maggy/.env
  1. Add the child's base URL to Maggy's ~/.claude/model-config.json under router.child.baseUrl (full example below).
  1. Install (above) or load a checkout for one session with claude --plugin-dir ., then run /route in the session.

Until both the base URL and the key are present the mod is observe-only: it classifies and /route shows what it would do, but no child starts.

Configuration

The mod reads Maggy's single source of truth, ~/.claude/model-config.json, and adds an optional router block. It never writes to that file.

{
  "primary": "glm",
  "router": {
    "routes":     { "simple": "glm-5.3", "coding": "glm-5.3", "analysis": "kimi-k3", "critical": "claude" },
    "ollama":     { "base": "http://localhost:11434", "model": "qwen2.5-coder:3b" },
    "classifier": "claude-haiku-4-5",
    "child": {
      "command":  ["claude"],
      "baseUrl":  "https://api.srooter.ai/anthropic",
      "keyFile":  "~/.maggy/.env",
      "keyVar":   "SROOTER_API_KEY",
      "args":     ["--bare", "--permission-mode", "acceptEdits"],
      "maxTurns": 25,
      "timeoutMs": 600000,
      "contextMessages": 6
    },
    "ui":      { "band": true, "tags": "routed" },
    "summary": true
  }
}
  • routes: tier → model id the gateway serves. "claude" means the tier stays in the main session.
  • primary (Maggy's followed model, set with /model-config): glm, kimi or deepseek makes the coding tier follow it unless routes.coding is set. With primary: claude only simple and analysis leave the main session.
  • classifier: the Claude model used when Ollama is unreachable. Undated aliases only (claude-haiku-4-5, claude-sonnet-5, claude-opus-5).
  • child.command: the executable. Use a launcher that sets its own auth (such as the ~/bin/claude-<provider> launchers Maggy writes) and the mod skips the key lookup.
  • child.baseUrl, keyFile, keyVar: where the child's ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY come from. Set only in the child's environment.
  • child.args: extra flags. The default is --bare (no inherited hooks or plugins, API-key auth only, so the child can never fall back to your subscription) plus acceptEdits so it can edit files without prompting. The mod always adds --add-dir <cwd>, which keeps the project's CLAUDE.md in reach under --bare. Add --dangerously-skip-permissions only if you want the child to run commands unattended; drop --bare if you want your hooks and plugins inside the child.
  • child.maxTurns, timeoutMs: the child's agentic budget. timeoutMs caps at ten minutes, the limit of $.process.run.
  • child.contextMessages: how many recent exchanges go into the brief.
  • ui.band: draw the band above the prompt (default true). ui.tags: which replies get the provenance tag, routed (default), all or off.

Commands

| Command | Effect | | :- | :- | | /route | Live or observe-only, mode, current decision, today's counts | | /route auto | Classify every prompt (default) | | /route off | Never start a child | | /route simple\|coding\|analysis\|critical | Pin a tier | | /route glm, /route kimi, /route kimi-k3, /route claude | Pin a model (a prefix expands to the configured model) |

Pins persist across sessions in the plugin's store.

What the child can and cannot do

  • It has Claude Code's tools and runs in the session's working directory, so it can read, edit and run things. Its tool calls are not shown in the main transcript, only its final text.
  • It does not share the main session's memory. The brief carries the last contextMessages exchanges (truncated) and the task.
  • It runs --bare by default, so your hooks and plugins stay out of it. The mod also detects the MAGGY_ROUTER_CHILD marker and does nothing inside a child.
  • The tag and the route line name the model the mod asked for. A gateway that routes by intent, as srooter does, may answer with another model; Claude Code's -p output does not expose which, so the mod cannot show it.
  • Output arrives when the child finishes; this version uses $.process.run, so a routed answer is not streamed token by token.

Tests

npm test                           # pure-function tests under Node, no Claude Code needed
claude plugin validate --strict .  # manifest + static analysis of the hooks module
claude plugin test                 # needs v2.1.287+; fires events through the hooks with no session or network

tests/classify, config, routing, stats, child and ui cover the pure logic in hooks/lib/. tests/mod.test.ts drives the hooks end to end: a simple prompt is answered by a child with the gateway in its environment and the brief on stdin, a critical prompt never starts one, a failed child falls back to the main model, a missing key means observe-only, a child session disables the mod, and /route off and /route kimi behave.

Layout

mixture-of-models-claude-mod/
├── .claude-plugin/
│   ├── plugin.json         # the plugin manifest
│   └── marketplace.json    # lets `claude plugin install` find it in this repo
├── docs/adr/               # architecture decisions (0001: child-process delegation)
├── scripts/test-pure.mjs   # runs the pure tests under Node
├── hooks/
│   ├── hooks.json          # points at register.js
│   ├── register.js         # the 9 hooks; the only file that touches the mods API ($)
│   └── lib/                # pure, unit-tested
│       ├── config.js       # defaults and the model-config.json overlay
│       ├── classify.js     # Maggy's pre-routing rules, classifier prompt, Ollama wire format
│       ├── routing.js      # tier → model, /route grammar, summary line
│       ├── child.js        # child argv, scoped env, readiness, env-file parsing, the brief
│       └── stats.js        # per-day counts
└── tests/

Mods require every $ call to live in register.js (a $ passed to another file fails claude plugin validate), which is why that file holds more functions than the usual limit.

Why a child process

See ADR 0001. In short: a mod can rename a request's model but not change where it goes, and routing the subscription token through a proxy was rejected. A child process with its own environment is the clean split.

Mapping from Maggy

| Maggy | Here | | :- | :- | | hooks/route-task-hook (UserPromptSubmit, qwen3 classifier, continuation guard, cache) | prompt.submit + lib/classify.js, $.http.fetch to Ollama, $.store cache | | "You MUST delegate — run ~/bin/glm" injected into context | turn.step runs the child and returns its answer; Claude is never asked to delegate | | ~/bin/claude-<provider> launchers from model_routing.py write-launcher | child.command can point at one; otherwise the mod scopes the env itself | | hooks/usage-summary-hook (Stop) | turn.complete returns the summary line | | /model-config followed model | primary in model-config.json drives the coding tier | | srooter gateway | the child's endpoint; the main session never sees it |

Tested with Claude Code 2.1.274 for claude plugin validate only; types used are from the 2.1.277 declarations. Check .claude-plugin/types/ after the first load on a newer build, in particular whether $.process.spawn is available to stream the child's output.

Source 7 files
hooks/register.js 201 lines
1// mixture-of-models: Maggy's model routing as a Claude Code mod, child-process edition.
2// Classify each prompt → for a routed tier, answer the turn with a headless
3// `claude -p` child on the gateway → the main session never leaves its own model.
4import { parseConfig } from './lib/config.js'
5import { classifierPrompt, ollamaBody, ollamaReply, parseLabel, preClassify } from './lib/classify.js'
6import { parseRouteArg, resolveModel, summaryLine } from './lib/routing.js'
7import { buildBrief, childArgv, childEnv, childReady, expandHome, parseEnvFile } from './lib/child.js'
8import { bumpStats, statsLine } from './lib/stats.js'
9import { bandTree, reportText, tagText, tagTree } from './lib/ui.js'
10
11let cfg = parseConfig('')
12let disabled = false
13let ready = false
14let key = ''
15let sessionModel = ''
16let pin = { mode: 'auto' }
17let decision = { label: 'critical', model: '', source: 'default' }
18let lastPrompt = ''
19const answered = new Map()
20
21async function readFile($, path) {
22  try {
23    return await $.fs.read(path)
24  } catch {
25    return ''
26  }
27}
28
29async function loadConfig($) {
30  const home = (await $.env.get('HOME')) || ''
31  cfg = parseConfig(await readFile($, home + '/.claude/model-config.json'))
32  key = parseEnvFile(await readFile($, expandHome(cfg.child.keyFile, home)))[cfg.child.keyVar] || ''
33  ready = childReady(cfg.child, key)
34  sessionModel = await $.session.model()
35  pin = (await $.store.get('pin')) || { mode: 'auto' }
36}
37
38async function askOllama($, text) {
39  try {
40    const url = cfg.ollama.base + '/api/chat'
41    const r = await $.http.fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: ollamaBody(cfg.ollama.model, classifierPrompt(text)) })
42    return r.ok ? parseLabel(ollamaReply(r.text)) : undefined
43  } catch {
44    return undefined
45  }
46}
47
48async function askClaude($, text) {
49  try {
50    return await $.model.classify(classifierPrompt(text), ['simple', 'coding', 'analysis', 'critical'], { model: cfg.classifier })
51  } catch {
52    return undefined
53  }
54}
55
56async function classify($, text) {
57  const local = await askOllama($, text)
58  if (local) return { label: local, source: 'ollama' }
59  const remote = await askClaude($, text)
60  if (remote) return { label: remote, source: 'claude-classify' }
61  const cached = await $.store.get('last-label')
62  return { label: cached || 'critical', source: cached ? 'cache' : 'default' }
63}
64
65async function lastTurnUsedTools($) {
66  const messages = await $.session.messages()
67  const last = messages.filter((m) => m.role === 'assistant').pop()
68  return Boolean(last && last.toolUses.length)
69}
70
71function setDecision(label, source) {
72  decision = { label, source, model: resolveModel(label, cfg.routes, sessionModel) }
73}
74
75// A /route pin wins over classification: a label pins a tier, a model id pins the model itself.
76function applyPin() {
77  if (pin.mode === 'label') setDecision(pin.label, 'pin')
78  if (pin.mode === 'model') decision = { label: 'pinned', source: 'pin', model: pin.model === 'claude' ? sessionModel : pin.model }
79}
80
81async function decide($, text) {
82  if (pin.mode === 'model' || pin.mode === 'label') return applyPin()
83  const pre = preClassify(text, await lastTurnUsedTools($))
84  if (pre.kind === 'label') return setDecision(pre.label, 'rule')
85  if (pre.kind !== 'classify') return
86  const { label, source } = await classify($, text)
87  await $.store.set('last-label', label)
88  setDecision(label, source)
89}
90
91// The main session's own request goes out whenever the child is not the answer.
92function shouldDelegate(e) {
93  if (disabled || !ready || pin.mode === 'off' || e.agentId || e.index > 0) return false
94  return Boolean(decision.model) && decision.model !== sessionModel
95}
96
97async function runChild($) {
98  const brief = buildBrief(await $.session.messages(), lastPrompt, cfg.child.contextMessages)
99  const started = Date.now()
100  try {
101    const argv = childArgv(cfg.child, decision.model, await $.session.cwd())
102    const r = await $.process.run(argv, { env: childEnv(cfg.child, key), stdin: brief, timeoutMs: cfg.child.timeoutMs })
103    const ok = r.exitCode === 0 && r.stdout.trim() !== ''
104    decision = { ...decision, child: { ms: Date.now() - started, exitCode: ok ? 0 : r.exitCode || 1 } }
105    return ok ? r.stdout.trim() : ''
106  } catch {
107    decision = { ...decision, child: { ms: Date.now() - started, exitCode: 1 } }
108    return ''
109  }
110}
111
112async function pinFrom($, arg) {
113  pin = parseRouteArg(arg, cfg.routes)
114  await $.store.set('pin', pin)
115  applyPin()
116  $.ui.invalidate('ui.render')
117  return 'routing set to ' + pin.mode + (pin.model ? ' ' + pin.model : pin.label ? ' ' + pin.label : '')
118}
119
120function bandView(e, theirs) {
121  return { disabled, ready, sessionModel, pin, decision, routes: cfg.routes, isWorking: e.props.isWorking, theirs }
122}
123
124function taggedModel(props) {
125  if (cfg.ui.tags === 'off' || !props.isFirstOfReply) return undefined
126  return answered.get(props.text.trim()) || (cfg.ui.tags === 'all' ? sessionModel : undefined)
127}
128
129export function register(on) {
130  on('session.start', async ($, e, next) => {
131    disabled = Boolean(await $.env.get('MAGGY_ROUTER_CHILD'))
132    if (disabled) return next(e)
133    await loadConfig($)
134    applyPin()
135    try {
136      await $.command.register({ name: 'route', description: 'Show or pin the model route: /route [auto|off|simple|coding|analysis|critical|<model>]', argumentHint: '[auto|off|label|model]', immediate: true })
137    } catch {
138      $.ui.log('/route is taken by another plugin')
139    }
140    return next(e)
141  })
142
143  // Fail open: a classifier that throws or times out must never swallow the prompt.
144  on('prompt.submit', async ($, e, next) => {
145    if (disabled) return next(e)
146    lastPrompt = e.text
147    await decide($, e.text)
148    return next(e)
149  }).catch(async ($, e, next) => next(e))
150
151  on('turn.start', async ($, e, next) => {
152    decision = { label: decision.label, model: decision.model, source: decision.source }
153    return next(e)
154  })
155
156  on('turn.step', async function* ($, e, next) {
157    if (!shouldDelegate(e)) return yield* next(e)
158    const answer = await runChild($)
159    if (!answer) {
160      $.ui.log('child on ' + decision.model + ' failed, main model takes this turn')
161      return yield* next(e)
162    }
163    answered.set(answer, decision.model)
164    if (answered.size > 50) answered.delete(answered.keys().next().value)
165    yield { kind: 'text', index: 0, text: answer }
166    return { turnId: e.turnId, index: e.index, answer, toolUses: [], stopReason: 'end_turn', usage: null }
167  })
168
169  on('turn.complete', async ($, e, next) => {
170    const result = await next(e)
171    if (disabled || e.agentId || !cfg.summary || !decision.model) return result
172    const day = new Date().toISOString().slice(0, 10)
173    const stats = bumpStats((await $.store.get('stats')) || {}, day, decision.model)
174    await $.store.set('stats', stats)
175    return { ...result, text: summaryLine(decision, e.usage) + ' · ' + statsLine(stats, day) }
176  })
177
178  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
179    if (!cfg.ui.band || e.props.hasSurvey) return next(e)
180    const theirs = await next(e)
181    return bandTree($.ui.resolve(e), bandView(e, theirs), (arg) => pinFrom($, arg))
182  })
183
184  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
185    const model = taggedModel(e.props)
186    if (!model) return next(e)
187    return tagTree($.ui.resolve(e), tagText(model, sessionModel), await next(e))
188  })
189
190  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
191    if (!shouldDelegate({ index: 0 })) return next(e)
192    return next({ ...e, props: { ...e.props, suffix: ' · via ' + decision.model + ' (child)…' } })
193  })
194
195  on('command.run', { command: 'route' }, async ($, e) => {
196    const parsed = parseRouteArg(e.args, cfg.routes)
197    if (parsed.mode === 'show') return { text: reportText(bandView({ props: {} })) + ' · ' + statsLine((await $.store.get('stats')) || {}, new Date().toISOString().slice(0, 10)) }
198    return { text: await pinFrom($, e.args) }
199  })
200}
201
hooks/lib/config.js 74 lines
1// Routing configuration: defaults and the ~/.claude/model-config.json overlay.
2// Pure: no mods API here, so every function is unit-testable.
3
4export const LABELS = ['simple', 'coding', 'analysis', 'critical']
5
6// 'claude' means "keep the session's own model".
7export const DEFAULT_ROUTES = {
8  simple: 'glm-5.3',
9  coding: 'glm-5.3',
10  analysis: 'kimi-k3',
11  critical: 'claude',
12}
13
14// Maggy's "followed" primary, as model-config.json names it, to the model id a gateway resolves.
15export const PRIMARY_MODELS = {
16  claude: 'claude',
17  glm: 'glm-5.3',
18  kimi: 'kimi-k3',
19  deepseek: 'deepseek-v4-pro',
20}
21
22export const DEFAULTS = {
23  routes: DEFAULT_ROUTES,
24  subagents: { Explore: 'simple' },
25  ollama: { base: 'http://localhost:11434', model: 'qwen2.5-coder:3b' },
26  // Fallback classifier when Ollama is down. Undated alias: Claude Code resolves it to the current snapshot.
27  classifier: 'claude-haiku-4-5',
28  // The delegate: a headless claude on the gateway, in its own process and environment.
29  child: {
30    command: ['claude'],
31    baseUrl: '',
32    keyFile: '~/.maggy/.env',
33    keyVar: 'SROOTER_API_KEY',
34    // --bare: no inherited hooks or plugins, strictly API-key auth, so the child never touches the subscription.
35    args: ['--bare', '--permission-mode', 'acceptEdits'],
36    maxTurns: 25,
37    timeoutMs: 600000,
38    contextMessages: 6,
39  },
40  // The band above the prompt, and which replies get a provenance tag: 'routed', 'all' or 'off'.
41  ui: { band: true, tags: 'routed' },
42  summary: true,
43}
44
45function parseJson(text) {
46  try {
47    return JSON.parse(text || '{}')
48  } catch {
49    return {}
50  }
51}
52
53function codingRoute(primary, routes) {
54  const model = PRIMARY_MODELS[primary]
55  if (!model) return routes.coding
56  return model
57}
58
59export function parseConfig(text) {
60  const raw = parseJson(text)
61  const router = raw.router || {}
62  const routes = { ...DEFAULT_ROUTES, ...(router.routes || {}) }
63  if (raw.primary && !(router.routes || {}).coding) routes.coding = codingRoute(raw.primary, routes)
64  return {
65    routes,
66    subagents: { ...DEFAULTS.subagents, ...(router.subagents || {}) },
67    ollama: { ...DEFAULTS.ollama, ...(router.ollama || {}) },
68    classifier: router.classifier || DEFAULTS.classifier,
69    child: { ...DEFAULTS.child, ...(router.child || {}) },
70    ui: { ...DEFAULTS.ui, ...(router.ui || {}) },
71    summary: router.summary === undefined ? DEFAULTS.summary : router.summary,
72  }
73}
74
hooks/lib/classify.js 60 lines
1// Prompt classification: the heuristics Maggy's route-task-hook applies before
2// asking a model, the classifier prompt, and the Ollama wire format. Pure.
3import { LABELS } from './config.js'
4
5const CONTINUATIONS = new Set([
6  'go ahead', 'go', 'go on', 'continue', 'proceed', 'keep going', 'carry on', 'do it', 'run it', 'run',
7  'ok', 'okay', 'k', 'yes', 'yep', 'yeah', 'yes please', 'sure', 'next', 'next step', 'start', 'begin', 'execute',
8])
9const EXECUTION_INTENT = /validate[- ]plan|execute the plan|run the plan|implement the plan|start executing|begin implementation/
10const SHORT_WORDS = 6
11
12function normalize(text) {
13  return text.toLowerCase().trim().replace(/[.!?]+$/, '')
14}
15
16// What to do with a prompt before any model is asked.
17export function preClassify(text, lastHadTools) {
18  const lower = normalize(text)
19  if (lower.startsWith('/')) return { kind: 'skip' }
20  if (/\buse claude\b/.test(lower) || EXECUTION_INTENT.test(lower)) return { kind: 'label', label: 'critical' }
21  if (CONTINUATIONS.has(lower)) return { kind: 'sticky' }
22  const words = lower.split(/\s+/).filter(Boolean).length
23  if (words <= SHORT_WORDS && lastHadTools) return { kind: 'sticky' }
24  return { kind: 'classify' }
25}
26
27export function classifierPrompt(text) {
28  return [
29    'Classify this coding-assistant task into exactly ONE tier. Reply with the tier name only.',
30    '- simple: grep, find, shell one-liners, syntax lookups, log reading, short summaries, git status/log/diff',
31    '- coding: single-file fixes, boilerplate, CRUD endpoints, tests, config changes, small refactors, docs',
32    '- analysis: reviewing a diff or long document, summarising, research questions, commit or changelog text',
33    '- critical: architecture, security-sensitive code, complex multi-service debugging, system design, ADRs',
34    '',
35    'Task: ' + text,
36  ].join('\n')
37}
38
39export function parseLabel(raw) {
40  const match = (raw || '').toLowerCase().match(new RegExp('\\b(' + LABELS.join('|') + ')\\b'))
41  return match ? match[1] : undefined
42}
43
44export function ollamaBody(model, prompt) {
45  return JSON.stringify({
46    model,
47    stream: false,
48    messages: [{ role: 'user', content: prompt }],
49    options: { temperature: 0.1, num_predict: 20 },
50  })
51}
52
53export function ollamaReply(text) {
54  try {
55    return JSON.parse(text).message.content || ''
56  } catch {
57    return ''
58  }
59}
60
hooks/lib/routing.js 52 lines
1// Route resolution, the /route argument grammar, and the summary line. Pure.
2import { LABELS } from './config.js'
3
4export function resolveModel(label, routes, sessionModel) {
5  const model = routes[label]
6  if (!model || model === 'claude') return sessionModel
7  return model
8}
9
10function modelByPrefix(arg, routes) {
11  return Object.values(routes).find((m) => m.startsWith(arg))
12}
13
14export function parseRouteArg(arg, routes) {
15  const word = (arg || '').trim().toLowerCase()
16  if (word === '') return { mode: 'show' }
17  if (word === 'auto' || word === 'off') return { mode: word }
18  if (LABELS.includes(word)) return { mode: 'label', label: word }
19  return { mode: 'model', model: modelByPrefix(word, routes) || word }
20}
21
22function fmtTokens(n) {
23  return (n / 1000).toFixed(1) + 'k'
24}
25
26// The API's input_tokens excludes cached tokens, so the context size is the three summed.
27function contextTokens(usage) {
28  return usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
29}
30
31function cachePercent(usage) {
32  const total = contextTokens(usage)
33  return total ? Math.round((usage.cache_read_input_tokens / total) * 100) : 0
34}
35
36function childNote(child) {
37  if (child.exitCode !== 0) return ' · child failed, main model answered'
38  return ' · child ' + Math.round(child.ms / 1000) + 's'
39}
40
41function usageNote(usage) {
42  if (!usage) return ''
43  return ' · ' + fmtTokens(contextTokens(usage)) + ' in / ' + fmtTokens(usage.output_tokens) + ' out · cache ' + cachePercent(usage) + '%'
44}
45
46// Every turn gets a line: where it ran (child or main session) and what it cost.
47export function summaryLine(decision, usage) {
48  const head = 'route: ' + decision.model + ' · ' + decision.label + ' via ' + decision.source
49  if (decision.child) return head + childNote(decision.child)
50  return head + usageNote(usage) + ' · main session'
51}
52
hooks/lib/child.js 51 lines
1// The delegate: a headless `claude -p` child on the gateway. Argument vector,
2// scoped environment, readiness, env-file parsing and the context brief. Pure.
3
4const MESSAGE_CHARS = 600
5
6export function expandHome(path, home) {
7  return path.startsWith('~/') ? home + path.slice(1) : path
8}
9
10export function parseEnvFile(text) {
11  const out = {}
12  for (const line of (text || '').split('\n')) {
13    const m = line.match(/^\s*(?:export\s+)?([A-Z0-9_]+)=["']?([^"'\n]*?)["']?\s*$/)
14    if (m && !line.trim().startsWith('#')) out[m[1]] = m[2]
15  }
16  return out
17}
18
19// A custom launcher is trusted to bring its own auth; plain `claude` needs the gateway and a key.
20export function childReady(child, key) {
21  if (child.command[0] !== 'claude') return true
22  return Boolean(child.baseUrl && key)
23}
24
25export function childEnv(child, key) {
26  const env = { MAGGY_ROUTER_CHILD: '1' }
27  if (child.baseUrl) Object.assign(env, { ANTHROPIC_BASE_URL: child.baseUrl, ANTHROPIC_API_KEY: key })
28  return env
29}
30
31// --add-dir keeps the project's CLAUDE.md in reach even when --bare skips auto-discovery.
32export function childArgv(child, model, cwd) {
33  return [...child.command, '-p', '--model', model, '--output-format', 'text', '--max-turns', String(child.maxTurns), '--add-dir', cwd, ...child.args]
34}
35
36function describe(m) {
37  const text = m.text.length > MESSAGE_CHARS ? m.text.slice(0, MESSAGE_CHARS) + '…' : m.text
38  const tools = (m.toolUses || []).map((t) => t.name)
39  return m.role + ': ' + text + (tools.length ? ' [tools: ' + tools.join(', ') + ']' : '')
40}
41
42// What the child reads on stdin: the last few exchanges of the main session, then the task.
43export function buildBrief(messages, prompt, limit) {
44  let history = messages.filter((m) => m.text)
45  const last = history[history.length - 1]
46  if (last && last.role === 'user' && last.text === prompt) history = history.slice(0, -1)
47  history = history.slice(-limit)
48  if (!history.length) return prompt
49  return 'Context from the main session:\n' + history.map(describe).join('\n') + '\n\nTask:\n' + prompt
50}
51
hooks/lib/stats.js 14 lines
1// Per-day routing counts kept in $.store, the usage-summary idea from Maggy. Pure.
2
3export function bumpStats(stats, day, model) {
4  const today = { ...(stats[day] || {}) }
5  today[model] = (today[model] || 0) + 1
6  return { ...stats, [day]: today }
7}
8
9export function statsLine(stats, day) {
10  const today = stats[day]
11  if (!today) return 'today: no routed turns yet'
12  return 'today: ' + Object.entries(today).map(([m, n]) => m + ' ×' + n).join(', ')
13}
14
hooks/lib/ui.js 67 lines
1// What the mod draws: the band above the prompt and the provenance tag on a
2// reply. Pure: elements come in as a parameter, trees go out as data.
3
4import { summaryLine } from './routing.js'
5
6const NAME = 'mixture-of-models'
7
8function provider(model) {
9  return model.split('-')[0]
10}
11
12// One button per provider in the routes, framed by auto, claude and off.
13export function bandButtons(routes) {
14  const providers = [...new Set(Object.values(routes).filter((m) => m !== 'claude').map(provider))]
15  const args = ['auto', ...providers, 'claude', 'off']
16  return args.map((arg, i) => ({ key: 'pin-' + arg, label: arg, hotkey: String(i + 1), arg }))
17}
18
19function activeArg(pin) {
20  if (pin.mode === 'model') return pin.model === 'claude' ? 'claude' : provider(pin.model)
21  return pin.mode === 'label' ? '' : pin.mode
22}
23
24function lastDecision(d) {
25  if (!d.model) return 'no prompt yet'
26  return 'last: ' + d.model + ' (' + d.label + ' via ' + d.source + ')'
27}
28
29export function bandText(v) {
30  if (v.disabled) return NAME + ' · inactive inside a child session'
31  if (!v.ready) return NAME + ' · observe-only: no gateway key for the child'
32  if (v.pin.mode === 'off') return NAME + ' · off · everything on ' + v.sessionModel
33  const routed = v.decision.model && v.decision.model !== v.sessionModel
34  if (v.isWorking) return NAME + ' · running ' + (routed ? v.decision.model + ' in a child…' : v.sessionModel + ' in the main session…')
35  if (v.pin.mode === 'model') return NAME + ' · pinned ' + v.decision.model
36  if (v.pin.mode === 'label') return NAME + ' · pinned ' + v.pin.label + ' → ' + v.decision.model
37  return NAME + ' · auto · ' + lastDecision(v.decision)
38}
39
40export function bandTree(el, v, onPin) {
41  const active = activeArg(v.pin)
42  const buttons = bandButtons(v.routes).map((b) =>
43    el.Button({ key: b.key, label: b.label, hotkey: b.hotkey, plain: true, dimColor: b.arg !== active, onPress: () => onPin(b.arg) }),
44  )
45  const row = el.Box({ flexDirection: 'row', columnGap: 2, children: [el.Text({ dimColor: true, children: [bandText(v) + '  '] }), ...buttons] })
46  return el.Box({ flexDirection: 'column', children: [row, v.theirs].filter(Boolean) })
47}
48
49export function tagText(model, sessionModel) {
50  return '⇢ ' + model + (model === sessionModel ? ' · main session' : ' · child on gateway')
51}
52
53export function tagTree(el, text, theirs) {
54  return el.Box({ flexDirection: 'column', children: [el.Text({ dimColor: true, children: [text] }), theirs] })
55}
56
57function pinWord(pin) {
58  if (pin.mode === 'auto') return 'auto'
59  return pin.mode + (pin.model ? ' ' + pin.model : pin.label ? ' ' + pin.label : '')
60}
61
62// The /route answer.
63export function reportText(v) {
64  const live = v.disabled ? 'disabled (child session)' : v.ready ? 'live' : 'observe-only (no gateway key for the child)'
65  return NAME + ' ' + live + ' · mode ' + pinWord(v.pin) + ' · current ' + summaryLine(v.decision, null)
66}
67