SLOPSHOPPER

otari-router

Asks an Otari gateway which model each Claude Code subagent should run on, and starts it there.

newnetworkagents
★ 515v0.1.0Apache-2.0updated 2026-10-09mozilla-ai/otari/plugins/otari-router
A shopper browsing a rack in a slop shop
README

otari-router

Claude Code plugin for Otari, the open-source LLM gateway. When Claude Code spawns a subagent, the plugin asks the gateway which model that subagent should run on, and starts it there.

Switching the model inside a running conversation cold-starts its prompt cache. A new subagent has no cache yet, so the moment it is spawned is where a cheaper model is free. Claude Code can take Otari's pick at that moment even when its own requests go to Anthropic directly.

Installation

In a Claude Code session:

/plugin marketplace add mozilla-ai/otari
/plugin install otari-router@otari

From a terminal, the same two steps are claude plugin marketplace add mozilla-ai/otari and claude plugin install otari-router@otari.

The install asks for two values, and both are required:

OptionWhat it is
otari_urlBase URL of the gateway, such as https://otari.example.com. Plain http only for a gateway on this machine, since the key travels in a header.
otari_api_keyThe key each recommendation is billed to. Stored in secure storage, never in a settings file.

Change them later with /plugin configure otari-router. The plugin is active in the session that installed it and in every session after.

To remove it: claude plugin uninstall otari-router@otari.

What the plugin provides

One hooks module with two hooks:

  1. agent.spawn: sends the spawn (subagent type, task, parent model, the model the caller asked for) to POST /api/v1/routing/recommend and starts the subagent on the model the gateway recommends. A fork is not asked about: Claude Code keeps a fork on its parent's model. When the gateway does not answer within three seconds, or answers with an error, the subagent starts on the model it would have had anyway, and a line in the transcript says so.
  2. turn.complete: when a subagent finishes, logs the model it ran on and its token counts, so the pick can be checked against what it cost.

Each spawn leaves a dim transcript line such as Explore agent-… starts on claude-haiku-4-5, Otari's choice (jev-1.13.0 chose haiku with 81%).

Requirements

  • An Otari gateway. A standalone one needs a decision provider configured, and the recommendation is one decision-model call billed to the key above; on otari.ai a managed recommender answers, with nothing to configure. A hybrid gateway does not serve the route, so the plugin would fall back on every spawn. See Use with Claude Code for what the gateway does with the request.
  • A Claude Code build with the mods API, which hooks modules run on.

Development

claude plugin validate plugins/otari-router
claude plugin test plugins/otari-router

To run the checkout's copy in your own sessions, add the repository folder as a marketplace, claude plugin marketplace add /path/to/otari, and install from it. A plugin installed from a folder is read in place, so an edit reaches an open session with /reload-plugins. Claude Code lays editor typings into .claude-plugin/types/ under the plugin when it loads; that folder is gitignored.

A plugin installed from GitHub is a copy pinned by version in plugin.json. A change to the plugin bumps that version, and users pick it up with claude plugin update otari-router@otari.

License

Apache-2.0

Source 1 files
hooks/register.ts 99 lines
1import type { Register } from 'claude-code'
2
3// Otari decides every subagent's model. This module only carries the question
4// there and the answer back. Where Otari listens and the key the request
5// carries are the plugin's two options; the manifest requires both, so
6// Claude Code collects them at install time and loads nothing without them.
7const RECOMMEND_PATH = '/api/v1/routing/recommend'
8const DEADLINE_MS = 3000
9
10type Recommendation = { model: string; reason?: string }
11
12// The key travels in a header, so plain http is for a gateway on this machine
13// only. A remote http URL is refused at spawn time rather than at load: a
14// module that fails to load says so in the debug log alone, while a refused
15// spawn leaves a line in the transcript until the option is fixed.
16const plainHttpToRemoteHost = (base: string): boolean => {
17  let url: URL
18  try {
19    url = new URL(base)
20  } catch {
21    return false // not a URL at all; the fetch reports that itself
22  }
23  const host = url.hostname
24  const local = host === 'localhost' || host.endsWith('.localhost') || host === '[::1]' || isLoopbackV4(host)
25  return url.protocol === 'http:' && !local
26}
27
28// The URL parser has already turned an IPv4 host into its dotted quad, so a
29// loopback address is four numeric labels led by 127. A name such as
30// 127.example.com keeps its last label and is a remote host.
31const isLoopbackV4 = (host: string): boolean => {
32  const labels = host.split('.')
33  return labels.length === 4 && labels[0] === '127' && labels.every((label) => /^\d{1,3}$/.test(label) && Number(label) <= 255)
34}
35
36export const register: Register = (on, options) => {
37  const base = String(options.otari_url).replace(/\/$/, '')
38  const key = String(options.otari_api_key)
39  const transportProblem = plainHttpToRemoteHost(base) ? 'otari_url must use https unless it points at this machine' : undefined
40
41  on('agent.spawn', async ($, e, next) => {
42    // Claude Code ignores `model` for a fork, which always inherits the
43    // parent's model along with its context. Asking would change nothing.
44    if (e.subagentType === 'fork') return next(e)
45    if (transportProblem !== undefined) throw new Error(transportProblem)
46
47    const question = $.http.fetch(`${base}${RECOMMEND_PATH}`, {
48      method: 'POST',
49      headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
50      body: JSON.stringify({
51        harness: 'claude-code',
52        session_id: await $.session.id(),
53        tool_use_id: e.tool_use_id,
54        agent_type: e.subagentType,
55        description: e.description,
56        prompt: e.prompt,
57        parent_model: e.parentModel,
58        requested_model: e.model ?? null,
59      }),
60    })
61    const deadline = $.clock.sleep(DEADLINE_MS).then(() => {
62      throw new Error(`no answer within ${DEADLINE_MS} ms`)
63    })
64    const response = await Promise.race([question, deadline])
65    if (!response.ok) throw new Error(`HTTP ${response.status}`)
66
67    const recommended = JSON.parse(response.text) as Partial<Recommendation>
68    if (typeof recommended.model !== 'string' || recommended.model === '') throw new Error('the answer names no model')
69
70    const spawned = await next({ ...e, model: recommended.model })
71    if (spawned.deny !== undefined) return spawned
72    const why = recommended.reason === undefined ? '' : ` (${recommended.reason})`
73    $.ui.log(`${e.subagentType} ${spawned.agentId ?? ''} starts on ${spawned.model}, Otari's choice${why}`)
74    return spawned
75  }).catch(($, e, next) => {
76    // Otari did not decide: unreachable, slow, an error, or a bad answer. The
77    // subagent still starts, on the model it would have had without this mod,
78    // and the transcript says so. To refuse the spawn instead, return
79    // `{ deny: reason }` here when `next.called` is false.
80    if (!next.called) {
81      $.ui.log(`otari-router: Otari did not decide (${next.error.message}); ${e.subagentType} starts on its usual model`)
82    }
83    return next(e)
84  })
85
86  on('turn.complete', async ($, e, next) => {
87    if (e.agentId !== undefined && e.usage !== undefined) {
88      const u = e.usage
89      const seconds = Math.round(e.durationMs / 1000)
90      $.ui.log(
91        `subagent ${e.agentId} ran on ${u.model}: in ${u.input_tokens}, out ${u.output_tokens}, ` +
92          `cache read ${u.cache_read_input_tokens}, cache write ${u.cache_creation_input_tokens}, ${seconds}s` +
93          (e.isAborted ? ', aborted' : ''),
94      )
95    }
96    return next(e)
97  })
98}
99