@wiklob/claude-model-router
v0.5.2
Published
Tiny local router for the Anthropic API surface: peek at the model id, forward the bytes. Claude ids pass through to Anthropic untouched; foreign ids go to your translating proxy.
Maintainers
Readme
claude-model-router
A tiny local router for the Anthropic API surface. It looks at the model id of each request and forwards the original bytes to the upstream that serves that model:
Claude Code / SDK app / curl
│ ANTHROPIC_BASE_URL=http://localhost:8399 (set once)
▼
model-router reads config.json, hot-reloads on edit
├─ claude-* (unmatched) → api.anthropic.com your auth passes through untouched
└─ gpt-* (routed) → http://localhost:8317 a translating proxy (e.g. CLIProxyAPI)After that, model choice is just --model / /model — per session, switchable mid-session, from any launcher (terminal, desktop app, background agents). No per-session env juggling, and a foreign model id can structurally never be sent to Anthropic: every request resolves at the router.
What it deliberately is not
Routing is trivial and stable; API translation is hard and churns. This router does routing only — no Anthropic↔OpenAI translation, no body rewriting, no auth brokering. For non-Anthropic models, point a route at a proxy that does the translating (e.g. CLIProxyAPI); the router's job is to keep that proxy off the path of your normal Claude traffic, so your primary provider never depends on it. Proxy down = foreign routes down, Claude unaffected.
Quickstart
Via npm:
npm install -g @wiklob/claude-model-router
model-router install-launchd # macOS: persistent LaunchAgent (KeepAlive),
# seeds ~/.config/claude-model-router/config.jsonor from a checkout:
git clone https://github.com/wiklob/claude-model-router.git
cd claude-model-router
node bin/model-router.mjs --config config.example.json # foreground trial run
bash install-launchd.sh # persistent install
bash install-launchd.sh --uninstall(npx @wiklob/claude-model-router works for a foreground trial too, but don't install-launchd from npx — the LaunchAgent would point into the disposable npx cache. Persistence wants a global install or a checkout.)
Point Claude Code at it — add a top-level env key in ~/.claude/settings.json:
{ "env": { "ANTHROPIC_BASE_URL": "http://localhost:8399" } }or per shell: export ANTHROPIC_BASE_URL=http://localhost:8399. This URL is a loopback pointer, not a secret — it's fine in a tracked settings file; credentials never go in the env block. New sessions only: already-open sessions keep their environment.
Verify the chain end-to-end:
curl http://127.0.0.1:8399/healthz
claude -p "Reply with exactly: VIA-ROUTER" # no env prefix — settings supplies it
tail -3 ~/Library/Logs/claude-model-router.log # your request is the last lineEscape hatch: delete the env line and new sessions go direct to Anthropic again.
Configuration
~/.config/claude-model-router/config.json (or --config / $MODEL_ROUTER_CONFIG):
{
"listen": { "host": "127.0.0.1", "port": 8399 },
"defaultUpstream": "https://api.anthropic.com",
"routes": [
{ "match": ["gpt-*", "chatgpt-*", "codex-*", "o1*", "o3*", "o4*"], "upstream": "http://localhost:8317" }
]
}match— one pattern or an array of them; a pattern is an exact model id, or a prefix ending in*. First matching route wins; no match (and any request without a parseablemodel) →defaultUpstream.upstream— any base URL speaking the Anthropic API surface; a path prefix is preserved.- Edits apply on the next request (content-compare reload); a broken edit is ignored with a warning and the last good config keeps serving.
--checkvalidates a config and prints the resolved table.
No credentials, ever, in this file. The router carries the caller's own headers through untouched.
Budget guard (runaway-fleet protection)
Optional hard stop for metered upstreams — built after an agent fleet burned a weekly provider quota in one afternoon (~25k completions). Add a guard block:
{
"guard": { "maxConcurrent": 12, "dailyBudget": 3000 }
}- Counts completion POSTs (
/v1/messages;count_tokensandGETs are free) per upstream. maxConcurrent— in-flight ceiling per upstream; breaches self-heal as requests drain.dailyBudget— completions per local calendar day per upstream; persisted inguard-state.jsonnext to the config, so restarts don't reset it. A human resets a tripped budget by deleting the state file.scope—"routed"(default: only route upstreams are guarded; Claude traffic todefaultUpstreamis untouched) or"all".- A breach answers
403 permission_error— deliberately not429/5xx, which SDK retry logic turns into a request storm;403surfaces once, terminally, with a message telling the agent to stop and hand off to a human. - Circuit breaker (on whenever
guardis set):breakerThreshold(default 10) consecutive provider429s open the circuit forbreakerCooloffMinutes(default 15) — the router answers403locally, sending nothing upstream, then lets one probe through after the cool-off. Retry loops and job respawners die at the exit instead of hammering a provider whose limit is already exhausted. Restarting the router resets it. /healthzreports today's per-upstream counts.
Pairing with a translating proxy (foreign models)
For non-Anthropic models, point a route at a proxy that speaks the Anthropic API surface and translates behind it — e.g. CLIProxyAPI on localhost:8317, holding your OpenAI/provider login. Two things to know:
- Run it with inbound auth off (loopback-only). Your sessions present Anthropic auth headers, and the router forwards them as-is to whichever upstream wins the route — a proxy demanding its own inbound API key would reject them.
- Until the proxy is running, its routed models fail fast with a clean
502from the router; your default-upstream (Claude) traffic is unaffected either way.
Then a foreign model is just claude --model gpt-… or /model gpt-… — billed by whatever account the proxy is signed into.
Security model
- Loopback by default. The router sits on-path for every request — including your Anthropic subscription/API credentials in the headers. Keeping it on
127.0.0.1means those bytes never leave the machine, and streaming costs ~0 added latency. - Non-loopback binds are refused unless
ROUTER_AUTH_TOKENis set; with it set, every request must carryx-router-token: <token>(constant-time compared, stripped before forwarding;/healthzstays open). Terminate TLS in front of it (reverse proxy) before exposing it beyond localhost — an unprotected relay would hand your route upstreams to anyone who can reach it. - No bodies or credentials are ever logged. The access log is method, path, upstream host, status, duration.
Model discovery (/model picker integration)
GET /v1/models answers with the merged catalog of every upstream — default plus all routes, deduped, unreachable upstreams skipped. Pair it with Claude Code's gateway discovery (≥ 2.1.129):
{ "env": {
"ANTHROPIC_BASE_URL": "http://localhost:8399",
"CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "1"
} }Single-model lookups (GET /v1/models/<id>) route by the id in the path. The merged list flattens pagination (has_more: false).
Discovery needs an API-key-style credential. Claude Code only fetches /v1/models when ANTHROPIC_AUTH_TOKEN or an API key is present; under a Claude subscription OAuth login it returns early and the env var above is a no-op — the picker never gains your routed models no matter what the router serves. Symptoms: no GET /v1/models line in the router log, and no ~/.claude/cache/gateway-models.json. (ANTHROPIC_CUSTOM_MODEL_OPTION still adds a single entry by hand and is unaffected.)
A catalog can be served blind. The same subscription OAuth token makes api.anthropic.com answer 401 on /v1/models, so the default upstream contributes nothing and the merged list is 100% routed models — a valid-looking 200 that tells a discovering client no Claude models exist. Since 0.5.2 the router warns once on stderr and every access line carries a per-upstream breakdown, so this is visible instead of silent:
GET /v1/models -> merged catalog (8 models from 2/2 upstreams, 5 hidden, 8 exposed) [api.anthropic.com=0(401) localhost:8317=8]The catch for foreign models. Claude Code's gateway discovery adds the returned models to the /model picker alongside its built-in list, but it silently ignores any id that doesn't start with claude or anthropic (it reads only id and display_name per entry). So your gpt-*/codex-*/etc. routes are fetched but never shown — the picker looks unchanged, and a Claude Code update can't fix it because the filter is client-side. Native Claude ids are unaffected: they come from both the built-in list and discovery, so new Claude models keep appearing across updates on their own.
Surfacing foreign models — catalog.exposeForeign
To make routed non-Claude models appear in the picker anyway, catalog.exposeForeign advertises each one under a claude--prefixed encoded id that passes the discovery filter, then strips the prefix back to the real id on the way in — so routing, the forwarded body, and the upstream never see the rename, and the response's model/id is renamed back to what the client picked.
"catalog": {
"exposeForeign": true,
"aliases": { "claude-ext-gpt-5.6-sol": "GPT 5.6 Sol (Codex Pro)" }
}- Default prefix is
claude-ext-(e.g.gpt-5.6-sol→claude-ext-gpt-5.6-sol); override with"exposeForeign": { "prefix": "claude-…" }(must start withclaude/anthropic). - Only models from a routed (non-default) upstream whose id isn't already native are encoded; a model also served by
defaultUpstreamis left under its real id.hideis applied before encoding. aliaseskey on the encoded id to set a clean picker label (otherwise the label falls back to the real id).- Zero-touch across updates: any model your proxy advertises is encoded automatically, so new foreign models appear with no config change — and because the ids start with
claude, they survive Claude Code updates. A brand-new provider still just needs its route. - Caveat: gateway-discovered models carry no capability metadata, so the effort/reasoning selector won't attach from discovery alone — declare it via
ANTHROPIC_CUSTOM_MODEL_OPTION_SUPPORTED_CAPABILITIESfor a specific id, or rely onCLAUDE_CODE_ALWAYS_ENABLE_EFFORT=1.
Curating the catalog
Upstream catalogs are often noisy: some proxies advertise the same model under alias ids (e.g. a bare luna alongside gpt-5.6-luna), and because the harness derives the picker label from the id, both collapse to one name — a duplicate /model entry. The optional catalog config block shapes the merged list — discovery/display only, routing is never affected: a hidden model still routes and completes, it just stops appearing in pickers.
"catalog": {
"hide": ["gpt-image-*", "sol", "terra", "luna"],
"aliases": { "gpt-5.6-sol": "GPT 5.6 Sol (Codex Pro)" }
}hide— same match grammar asmatch(exact id, or trailing-*prefix); matching ids are removed from the merged/v1/modelsresponse.aliases— model id → thedisplay_namepickers show for it.
Edits hot-reload like the rest of the config; reopen the /model picker (new session) to see the effect.
Note: Claude Code's own variant tags such as sonnet[1m] are client-side ids that never appear in any upstream catalog, so under gateway discovery the picker classifies them as "custom model". That's cosmetic — the request still routes to defaultUpstream unchanged; the router can't fix the label.
Related, and also not something the router can influence: a session resumed after a while can come back on the [1m] variant of the model you picked. Claude Code's stored-model → picker-option lookup normalizes with replace(/\[(1|2)m\]/gi, ""), so claude-opus-5 and claude-opus-5[1m] are the same key, and the server-supplied "additional model options" (the entries at the bottom of /model) carry [1m] in their values — so the suffixed one can win the match. The [1m] never reaches the wire (the client strips it and sends a beta header instead), so the router sees a plain id either way. Set CLAUDE_CODE_DISABLE_1M_CONTEXT=1 if you want the suffix never applied.
Behavior details
- Bytes in, bytes out: the request body is buffered once (to peek
model), then forwarded verbatim; responses — including SSE streams — are piped through unbuffered. The sole exception iscatalog.exposeForeign: when a client sends an encoded id, themodelfield is rewritten to the real id in the request and renamed back in the response (skipped for content-encoded bodies); every other request is byte-for-byte. - Hop-by-hop headers are dropped per RFC 9110; everything else (auth,
anthropic-version, beta flags, compression negotiation) passes through untouched in both directions. GET /healthzanswers locally:{ok, version, defaultUpstream, routes}.- Unreachable upstream →
502with an Anthropic-shaped error body. - Long-lived streaming responses are the normal case: no request timeout.
Testing
npm test # = node test/router.test.mjsHermetic probe: fake upstreams that record what they receive, a real router process in front, assertions on routing, byte fidelity, header passthrough, incremental SSE delivery, hot reload, token enforcement, and unsafe-bind refusal. Loopback only, no real credentials.
Requirements
node ≥ 18. No dependencies. The LaunchAgent installer is macOS; on Linux, run it under systemd or any supervisor (node bin/model-router.mjs --config …).
License
MIT
