SLOPSHOPPER

model-router

Experimental: non-Claude models (GPT, Grok) as native Claude Code subagents via a local routing gateway

newbandtoastprocess
★ 26v0.1.37no licenseupdated 2026-10-09Crazytieguy/alignment-hive/plugins/model-router
A shopper browsing a rack in a slop shop
README

model-router

Experimental. This routes all your Claude Code traffic through a local gateway; a bug here breaks your sessions until you remove one settings line (ANTHROPIC_BASE_URL), which always restores direct Anthropic access.

Remote Control doesn't work while model-router is installed.

model-router lets Claude Code delegate to GPT models as native subagents, alongside Claude models, in the same session. It runs a small loopback Anthropic-format gateway on your machine:

  • Requests for Claude models pass through byte-exact to api.anthropic.com, with your existing claude.ai OAuth login and subscription billing untouched.
  • Requests for an explicit allowlist of GPT routing IDs (e.g. gpt-6-astra) are stripped of all Anthropic credentials and forwarded to a supervised local CLIProxyAPI instance, which translates them to the Codex protocol under your separate Codex subscription OAuth. No API keys required on either side.

Routed GPT models receive an honest-identity system block, so they never present themselves as Claude.

What you get

  • GPT subagents — gpt-6-astra, gpt-6.1-sol, gpt-6-luna — usable from any project at any reasoning effort (Claude picks it per call; Claude Code 2.1.292+), plus dynamic per-invocation model + effort choice in Workflow orchestration.
  • Optional open-weights subagents — add Kimi, GLM, or other models from any OpenAI-compatible host you have an API key for during /model-router:setup.
  • Optional Grok family — grok-4.7 under your own xAI subscription OAuth login (no API key), riding the same CLIProxyAPI instance. Off by default; opt in during /model-router:setup.
  • A delegating-to-models skill that helps Claude pick the right model and effort when delegating.
  • Adversarial code and plan reviewers on GPT-6.1 Sol — a second model with prompts tuned to push back on what Claude wrote.
  • Working WebSearch on GPT sessions: Claude Code's WebSearch runs a side LLM call that loses its links through the Codex translation (and takes 20–70s). The gateway answers it from the Codex search backend instead — structured links in ~1–3s ([web-search] in the config to opt out). The backend follows whichever agent asked: a GPT subagent's search uses the Codex backend even under a Claude main session, a Grok agent's search uses xAI's, and a Claude subagent's search stays on Anthropic even under a GPT main session.
  • An OS service (launchd/systemd user unit) that keeps the router alive. macOS and Linux only.

Setup

Run /model-router:setup and follow along; it also covers repair and uninstall. model-router doctor diagnoses the whole stack in one shot.

Compliance

Both sides ride supported paths. The Claude side is a standard ANTHROPIC_BASE_URL gateway configuration: requests are forwarded byte-exact and billed to your own subscription, with nothing impersonated. The GPT side spends your own Codex subscription through its own OAuth login — an arrangement OpenAI has publicly blessed regardless of the harness driving it: Codex lead Thibault Sottiaux posted the CLIProxyAPI-into-Claude-Code recipe himself ("we don't care about the harness"), and ChatGPT sign-in is a documented Codex auth method.

Development

Point MODEL_ROUTER_DEV at a locally built binary to bypass the release download in scripts/bootstrap.sh. docs/experiments.md records the measured Claude Code behavior this design rests on.

Source 3 files
hooks/register.tsx 156 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { noticeRows } from './notice-band'
5import type { Notice, NoticeAction } from './notice-band'
6
7const PLUGIN = 'model-router'
8const notices = atom({ plugin: 'model-router', key: 'notices' } as const, [] as Notice[])
9// Rows dismissed this session; a row with a `version` stays dismissed in the store until the next.
10const dismissed = atom({ plugin: 'model-router', key: 'dismissed' } as const, [] as string[])
11// A row whose Try again is running: its buttons give way to "restarting…" until the check settles.
12const retrying = atom({ plugin: 'model-router', key: 'retrying' } as const, null as string | null)
13
14// The directory the session started in, for the check's CLAUDE_PROJECT_DIR and project settings.
15let projectDir = ''
16
17/**
18 * Runs the plugin's check and keeps its rows; none when it fails. The check reads
19 * ANTHROPIC_BASE_URL from the environment Claude Code passes on, settings' `env` included, and
20 * restarts a stopped router.
21 */
22async function check($: EngineInterface): Promise<void> {
23  try {
24    const { exitCode, stdout } = await $.process.run(['bash', `${$.plugin.root}/scripts/check.sh`], {
25      env: { CLAUDE_PLUGIN_ROOT: $.plugin.root, CLAUDE_PROJECT_DIR: projectDir },
26    })
27    const found = exitCode === 0 && stdout.trim() ? (JSON.parse(stdout) as { notices: Notice[] }).notices : []
28    await update($, notices, () => found)
29  } catch {
30    // A check that fails shows nothing rather than a wrong row.
31  }
32}
33
34async function retry($: EngineInterface, row: string): Promise<void> {
35  await update($, retrying, () => row)
36  try {
37    await check($)
38  } finally {
39    await update($, retrying, () => null)
40  }
41}
42
43/**
44 * A settings edit by the binary (`model-router settings`, the setup skill's own), as the toast
45 * that says what it did. The edits take effect when Claude Code next starts.
46 */
47async function editSettings($: EngineInterface, operation: 'models' | 'bypass'): Promise<string> {
48  const args = operation === 'models' ? ['models', '--apply'] : ['bypass']
49  const { exitCode, stdout } = await $.process.run(
50    ['bash', `${$.plugin.root}/scripts/bootstrap.sh`, 'settings', ...args, '--project-dir', projectDir],
51    { env: { CLAUDE_PLUGIN_ROOT: $.plugin.root } },
52  )
53  if (exitCode !== 0 || !stdout.trim()) return 'Could not edit your settings: run /model-router:setup'
54  const result = JSON.parse(stdout) as {
55    applied?: boolean
56    added?: string[]
57    windows?: Record<string, number>
58    reason?: string
59    status?: 'done' | 'shared-file' | 'not-found' | 'changed'
60    files?: string[]
61    file?: string
62  }
63
64  if (operation === 'models') {
65    if (!result.applied) return `${result.reason ?? 'Nothing to add'}: run /model-router:setup`
66    const added = (result.added ?? []).map(id => {
67      const window = result.windows?.[id]
68      return window ? `${id} (compacts at ${Math.round(window / 1000)}K)` : id
69    })
70    // Only retired rows dropped: nothing added to name.
71    if (added.length === 0) return 'Updated /model. Restart Claude Code to see it.'
72    return `Added ${added.join(', ')} to /model. Restart Claude Code to pick it; Customize runs setup.`
73  }
74  if (result.status === 'done') {
75    return `Claude Code now talks to Anthropic directly (${(result.files ?? []).join(', ')}). Restart it to get back online, then run /model-router:setup.`
76  }
77  // Another writer kept changing a file: its edit is not done, nothing of theirs was overwritten.
78  if (result.status === 'changed') {
79    const done = result.files?.length ? ` (changed so far: ${result.files.join(', ')})` : ''
80    return `Your settings changed while editing; Bypass router is not finished${done}. Try again.`
81  }
82  if (result.status === 'shared-file') {
83    const done = result.files?.length ? ` (changed so far: ${result.files.join(', ')})` : ''
84    return `${result.file} routes through model-router and collaborators share it: remove ANTHROPIC_BASE_URL (and a GPT "model") there, then restart Claude Code${done}`
85  }
86  return 'ANTHROPIC_BASE_URL is not in your settings files: unset it where you set it, then restart Claude Code'
87}
88
89export const register: Register = on => {
90  // The SessionStart hook's lifecycle, startup and /clear, awaited: a stopped router's restart
91  // finishes before the session's first request.
92  on('classic.SessionStart', async ($, e, next) => {
93    if (e.source === 'startup' || e.source === 'clear') {
94      projectDir = e.cwd
95      await check($)
96    }
97    return next(e)
98  })
99
100  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
101    const [below, found, hidden, running] = await Promise.all([
102      next(e),
103      read($, notices),
104      read($, dismissed),
105      read($, retrying),
106    ])
107    if (e.props.hasSurvey) return below
108
109    const candidates = (found as Notice[]).filter(notice => !hidden.includes(notice.id))
110    const stored = await Promise.all(
111      candidates.map(notice => (notice.version === undefined ? undefined : $.store.get(`dismissed:${notice.id}`))),
112    )
113    const rows = candidates
114      .filter((notice, index) => notice.version === undefined || stored[index] !== notice.version)
115      .map(notice => (running === notice.id ? { ...notice, busy: 'restarting…' } : notice))
116    if (rows.length === 0) return below
117
118    const dismiss = (notice: Notice) => update($, dismissed, list => [...list, notice.id])
119    const onAction = async (notice: Notice, action: NoticeAction) => {
120      if (action.id === 'retry') {
121        await retry($, notice.id)
122        return
123      }
124      // The settings edits take effect at the next start: the row's work is done this session.
125      if (action.id === 'add-models' || action.id === 'bypass') {
126        const said = await editSettings($, action.id === 'add-models' ? 'models' : 'bypass').catch(
127          () => 'Could not edit your settings: run /model-router:setup',
128        )
129        await dismiss(notice)
130        $.ui.toast(said)
131        return
132      }
133      // Running setup (or Customize) dismisses the row for this session; the next session's
134      // check decides whether it comes back.
135      await dismiss(notice)
136      if (action.kind === 'command' && action.command) {
137        await $.command.run({ command: action.command.slice(1) }).catch(() => $.ui.toast(`Could not run ${action.command}`))
138      }
139    }
140    const onDismiss = async (notice: Notice) => {
141      await dismiss(notice)
142      if (notice.version !== undefined) await $.store.set(`dismissed:${notice.id}`, notice.version)
143    }
144
145    const ui = $.ui.resolve(e)
146    const drawn = noticeRows(ui, PLUGIN, rows, { onAction, onDismiss })
147
148    return (
149      <ui.Box flexDirection="column">
150        {drawn}
151        {below}
152      </ui.Box>
153    )
154  })
155}
156
hooks/notice-band.tsx 92 lines
1import type { EngineInterface, RenderElement } from 'claude-code'
2
3// One plugin's rows in the band above the prompt, drawn without `$`: each
4// plugin's own `ui.render` hook reads its state and stacks this over what the
5// plugins beneath drew, so third-party plugins on the band keep working.
6//
7// A plugin cannot import another's files, so each plugin that shows notices
8// carries this file; plugins/notice-band.test.ts keeps the copies identical.
9// The `notices` its check prints are this file's `Notice` as JSON.
10
11export type Severity = 'urgent' | 'problem' | 'action' | 'info'
12
13export type NoticeAction = {
14  id: string
15  label: string
16  // command: a slash command; copy: `command` goes on the clipboard, for a
17  // terminal command that is interactive; plugin: the plugin's own handler decides.
18  kind: 'command' | 'copy' | 'plugin'
19  command?: string
20}
21
22export type Notice = {
23  id: string
24  severity: Severity
25  // The whole message: rows wrap rather than hide what the person needs to act.
26  text: string
27  actions?: NoticeAction[]
28  // Set on "updated" notices: a dismissal holds until the plugin's next
29  // version. Without it, a dismissal holds for this session only.
30  version?: string
31  // What a pressed button is doing ("restarting…"): drawn after the text in
32  // place of the buttons, so the row never looks inert. Set by the plugin.
33  busy?: string
34}
35
36export type NoticeHandlers = {
37  onAction: (notice: Notice, action: NoticeAction) => unknown
38  onDismiss: (notice: Notice) => unknown
39}
40
41const MARK: Record<Severity, { rank: number; glyph: string; color: string }> = {
42  urgent: { rank: 0, glyph: '✖', color: 'error' },
43  problem: { rank: 1, glyph: '!', color: 'warning' },
44  action: { rank: 2, glyph: '›', color: 'suggestion' },
45  info: { rank: 3, glyph: '↑', color: 'ide' },
46}
47
48export function noticeRows(
49  ui: ReturnType<EngineInterface['ui']['resolve']>,
50  plugin: string,
51  notices: Notice[],
52  { onAction, onDismiss }: NoticeHandlers,
53): RenderElement[] {
54  const { Box, Button, Text } = ui
55
56  return [...notices]
57    .sort((a, b) => MARK[a.severity].rank - MARK[b.severity].rank)
58    .map(notice => {
59      const mark = MARK[notice.severity]
60      const key = `${plugin}:${notice.id}`
61
62      const buttons = (notice.busy ? [] : (notice.actions ?? [])).map((action, index) => (
63        <Button
64          key={`${key}:${action.id}`}
65          label={action.label}
66          variant={index === 0 ? 'primary' : 'secondary'}
67          onPress={() => onAction(notice, action)}
68        />
69      ))
70      // An info row is a state, not a request: the band's own collapse hides it.
71      if (notice.severity !== 'info' && !notice.busy) {
72        buttons.push(<Button key={`${key}:dismiss`} label="Dismiss" dimColor onPress={() => onDismiss(notice)} />)
73      }
74
75      // The buttons wrap under the text where the row does not fit.
76      return (
77        <Box key={key} flexDirection="row" flexWrap="wrap" justifyContent="space-between" columnGap={2}>
78          <Text>
79            <Text color={mark.color} bold>
80              {mark.glyph}{' '}
81            </Text>
82            <Text bold>{plugin}</Text> {notice.busy ? `${notice.text} · ${notice.busy}` : notice.text}
83          </Text>
84          {/* Grows to fill its line, so the buttons stay right-aligned when they wrap. */}
85          <Box flexDirection="row" flexGrow={1} justifyContent="flex-end" columnGap={1}>
86            {buttons}
87          </Box>
88        </Box>
89      )
90    })
91}
92
types/index.d.ts 7 lines
1declare module 'claude-code' {
2  interface PluginState {
3    // `notices`: the check's rows, hooks/notice-band.tsx's Notice as parsed JSON.
4    'model-router': { notices: unknown[]; dismissed: string[]; retrying: string | null }
5  }
6}
7