npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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.json

or 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 line

Escape 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 parseable model) → 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.
  • --check validates 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_tokens and GETs 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 in guard-state.json next 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 to defaultUpstream is untouched) or "all".
  • A breach answers 403 permission_error — deliberately not 429/5xx, which SDK retry logic turns into a request storm; 403 surfaces once, terminally, with a message telling the agent to stop and hand off to a human.
  • Circuit breaker (on whenever guard is set): breakerThreshold (default 10) consecutive provider 429s open the circuit for breakerCooloffMinutes (default 15) — the router answers 403 locally, 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.
  • /healthz reports 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 502 from 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.1 means those bytes never leave the machine, and streaming costs ~0 added latency.
  • Non-loopback binds are refused unless ROUTER_AUTH_TOKEN is set; with it set, every request must carry x-router-token: <token> (constant-time compared, stripped before forwarding; /healthz stays 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-solclaude-ext-gpt-5.6-sol); override with "exposeForeign": { "prefix": "claude-…" } (must start with claude/anthropic).
  • Only models from a routed (non-default) upstream whose id isn't already native are encoded; a model also served by defaultUpstream is left under its real id. hide is applied before encoding.
  • aliases key 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_CAPABILITIES for a specific id, or rely on CLAUDE_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 as match (exact id, or trailing-* prefix); matching ids are removed from the merged /v1/models response.
  • aliases — model id → the display_name pickers 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 is catalog.exposeForeign: when a client sends an encoded id, the model field 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 /healthz answers locally: {ok, version, defaultUpstream, routes}.
  • Unreachable upstream → 502 with an Anthropic-shaped error body.
  • Long-lived streaming responses are the normal case: no request timeout.

Testing

npm test    # = node test/router.test.mjs

Hermetic 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