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

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:
api.anthropic.com, with your existing claude.ai OAuth login and subscription billing untouched.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.
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./model-router:setup./model-router:setup.delegating-to-models skill that helps Claude pick the right model and effort when delegating.[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.Run /model-router:setup and follow along; it also covers repair and uninstall. model-router doctor diagnoses the whole stack in one shot.
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.
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.
hooks/register.tsx 156 lines1import { 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}
156hooks/notice-band.tsx 92 lines1import 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}
92types/index.d.ts 7 lines1declare 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