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

@dianshuv/copilot-api-router

v0.7.0

Published

Local aggregating router fronting N independent copilot-api backends: deterministic session/subagent affinity with least-outstanding fallback spread.

Readme

copilot-api-router

A local aggregating router that fronts N independent copilot-api backends — each bound to its own GitHub account (concurrency quota + prompt cache) on its own VM — behind a single Anthropic/OpenAI-compatible interface.

The router keeps each logical parent/subagent session on one backend with deterministic Rendezvous affinity, while completion requests without a session signal still spread by least-outstanding in-flight count. It binds loopback only and has no inbound auth (single-user host assumption); it presents each backend's Proxy API key outbound. Backends are expected to be reachable on the router's own private network (same-subnet LAN, VPN, etc.) — deployment topology is the operator's responsibility.

Status: in progress. Implemented so far: config-driven backend list, loopback bind, session/subagent Rendezvous affinity, least-outstanding fallback spread, raw-byte request forwarding with byte-for-byte SSE streaming, non-completion passthrough, a minimal GET /_router/status plane, periodic deep health checks (authenticated GET /v1/models) with ejection/recovery, a passive connection-level circuit breaker that cools a backend after repeated hard failures, request-path resource limits (max body size + bounded-concurrency backpressure), and client-specified backend pinning via a /backends/<alias>/… URL prefix.

Stateful Responses items and encrypted reasoning are scoped to the backend that created them. Parent sessions and independent subagents therefore receive separate stable affinity keys: parallel agents distribute statistically, while every agent's own continuation stays on its original backend. Explicit URL pins still override affinity.

Install

Requires Bun installed locally (the bin's shebang is #!/usr/bin/env bun; the bundled binary calls Bun-specific APIs at runtime).

Install once and run:

npm install -g @dianshuv/copilot-api-router
copilot-api-router --config ./router.config.json --port 4141

Or run without installing:

bunx @dianshuv/copilot-api-router --config ./router.config.json --port 4141

Run from source

bun install
bun run src/main.ts --config ./router.config.json --port 4141

--host / ROUTER_HOST override the bind address; the default is 127.0.0.1 (an unconfigured instance is never exposed on the network).

Config

The backend list is config-driven — the count N is never hardcoded. Adding or removing an account is a config-only change:

{
  "backends": [
    { "url": "http://10.0.0.1:4141", "apiKey": "proxy-key-1", "name": "acct-a" },
    { "url": "http://10.0.0.2:4141", "apiKey": "proxy-key-2", "name": "acct-b" }
  ]
}

Each backend carries its own Proxy API key, which the router attaches outbound (Authorization: Bearer … / x-api-key).

Each backend may also carry an optional name — a stable alias used by client-specified backend pinning so a client's URL survives reordering backends[]. name must be a non-empty string, unique across backends[], and NOT all decimal digits (an all-digits name would collide with the numeric-index alias form).

Optional knobs

All optional; an unset knob falls back to the documented default below. Health cadence and the request hard-ceiling come from earlier slices; the resource limits (max body size + backpressure) are Issue 07; the passive connection-level circuit breaker (breaker*) is ADR 0002. v0.2.0 removed the endpoints, prefixTtlMs, and prefixMaxEntries knobs; a config file that still carries any of these will fail to load with a migration hint.

| Key | Default | Meaning | |---|---|---| | healthCheckIntervalMs | 20000 | Per-backend deep-probe cadence (authenticated GET /v1/models). | | healthCheckFailureThreshold | 3 | Consecutive failed probes that eject a backend. | | breakerFailureThreshold | 3 | Consecutive connection-level failures (refused/reset before first byte) on real traffic that trip a backend's cooldown. | | breakerCooldownMs | 30000 | How long (ms) a breaker-tripped backend is skipped by selection before auto-re-admission. | | requestHardTimeoutMs | 900000 | Hard ceiling (15 min) for one completion; never failed over. | | maxBodyBytes | 8388608 | Max inbound request body (8 MiB). A larger body is rejected with 413 and never buffered/forwarded. | | maxConcurrentRequests | 64 | Max completions streaming through the router at once (backpressure ceiling). | | maxQueueDepth | 256 | Max requests allowed to wait once the ceiling is reached; past this the router sheds load with 503 (set 0 to reject immediately, never queue). | | recentLogsLimit | 500 | Retained-history capacity for /_router/logs/recent (the remote log tail snapshot). Integer in [0, 10000]; 0 disables retention (snapshot stays empty but live SSE still fires and any Last-Event-ID reconnect gets event: gap). |

Logs

The router prints two lines per dispatched request — a dim [....] start line the moment the router commits to a backend for this request, then a [ OK ]/[FAIL] completion line when the request settles — so long-running streams are visible in flight instead of silent until they finish. Requests that fail before the router commits (413 oversize body, 503 no-healthy-backend, pin 404/503, queue-overflow) emit only the completion line — nothing was in flight, so there is no start line to pair.

14:23:00  [....]  /v1/messages          spread    #2/acct-a                   claude-opus-4
14:23:01  [ OK ]  /v1/messages          spread    #2/acct-a     200   1.243s  claude-opus-4
14:23:01  [....]  /v1/messages          spread    #0/acct-b                   claude-opus-4
14:23:02  [ OK ]  /v1/messages          spread    #0/acct-b     200   0.932s  claude-opus-4
14:23:05  [FAIL]  /v1/messages          none      --            503   0.004s
14:23:06  [....]  /v1/models            passthru  #1/acct-b
14:23:06  [ OK ]  /v1/models            passthru  #1/acct-b     200   0.088s

Columns are locked to the same fixed widths for both line variants so start and end lines align cell-for-cell: local time · [....]/[ OK ]/[FAIL] prefix · path (min-padded, never truncated) · routing outcome (spread / passthru / pinned / none) · backend (#index/alias if the backend was named in config, #index otherwise, or -- when no backend served) · HTTP status · total duration (seconds, 3 decimals) · client-requested model (from the request body, when a completion carried one). The start line leaves the status and duration columns blank — a start has neither yet. For a streamed completion the duration runs to the last byte (the whole request); for a passthrough or an error response it runs to the response headers. Map a backend index to its URL via GET /_router/status, which also reports per-backend inflight/health and the most recent routing decisions.

Failover (e.g. [....] spread #0 followed by [ OK ] spread #1) is diagnostic gold, not a bug: it means the first backend refused the connection and the router transparently retried on another. See ## Client-specified backend pinning below for the case where failover is intentionally suppressed.

When stdout is a TTY the router additionally keeps a sticky footer on the last line showing the live backpressure state — e.g. [....] 3 in flight, 12 queued requests — refreshed on every request completion. Piping the router's output (e.g. into a file or less) suppresses the footer so captured logs stay one pair per request; NO_COLOR=1 disables ANSI colouring, and FORCE_COLOR=1 enables it even when stdout is not a TTY (e.g. under systemd with StandardOutput=append:..., so tail -F piped to a terminal still renders colour).

Remote tail

copilot-api-router --tail <URL> opens the router's observation plane and streams the SAME per-request lines the server writes to its stdout — useful when the router runs headless (systemd, container, another host on the LAN). Two forms of credential are accepted, matching the reverse-proxy contract:

copilot-api-router --tail http://router.lan:4141 --token $ROUTER_KEY
ROUTER_API_KEY=$ROUTER_KEY copilot-api-router --tail http://router.lan:4141

The observation plane is two HTTP endpoints on the router:

  • GET /_router/logs/recent — the newest N completed requests as JSON, so a freshly-opened tail immediately shows history (capacity governed by recentLogsLimit).
  • GET /_router/logs/stream — a live text/event-stream of every dispatch START and completion END frame. Honours Last-Event-ID for resume-across- reconnect, and issues a visible event: gap when a reconnect crossed a server restart or fell behind retention.

--max-reconnects <n|infinity> / ROUTER_TAIL_MAX_RECONNECTS sets the between-session reconnect budget; the default is infinity so the tail survives a router restart. NO_COLOR / FORCE_COLOR are honoured on the same rules as the server's own stdout renderer.

Session and subagent affinity

Unpinned completion requests use a stable key when the client supplies one:

| Client | Parent key | Subagent key | |---|---|---| | Pi | Responses prompt_cache_key | child session prompt_cache_key | | OpenCode | prompt_cache_key or x-session-affinity | child session key | | Codex | Responses prompt_cache_key | x-codex-turn-metadata.thread_id | | Claude Code | x-claude-code-session-id | __SUBAGENT_MARKER__ session + agent IDs |

The router applies Rendezvous hashing over the key and each backend's stable name (falling back to URL). Independent parent/child keys distribute statistically; the same key ignores inflight fluctuations and remains on one backend. Codex child requests also have their forwarded prompt_cache_key rewritten to the child thread key, while encrypted reasoning is preserved.

Affinity is strict: if the owning backend is unhealthy, cooling, or refuses the connection, the router does not move that continuation to another backend. It returns 503/502 so connection-bound state is never silently replayed on the wrong account. Requests with no usable affinity signal retain the original least-outstanding spread and connection-level failover behavior.

Client-specified backend pinning

Some callers want to bypass affinity/spread entirely — "for THIS request, use backend X". The router exposes a URL-prefix channel for that:

POST /backends/<alias>/v1/messages
POST /backends/<alias>/v1/chat/completions
POST /backends/<alias>/v1/responses
GET  /backends/<alias>/v1/models       # passthrough paths are pinned too

<alias> is either a backends[].name from config or the numeric index (/backends/0/…). The prefix is a router-side selector: it is stripped before the request is forwarded, so the upstream backend receives the original path (/v1/messages, not /backends/0/v1/messages). Everything else — request body, headers (minus router-rewritten auth), streaming — is unchanged.

Contract

| Situation | Response | |---|---| | Alias resolves to a healthy backend | Request forwarded to that backend; response streamed back | | Unknown alias (misspelt / deleted / OOB index) | 404 {"error":{"message":"unknown backend \"…\""}} | | Alias resolves but backend is unhealthy | 503 {"error":{"message":"pinned backend \"…\" is unhealthy"}}never silently rerouted | | Pinned backend refuses connection at request time | 502 (upstream error) — no failover to any other backend | | /backends/<alias>/_router/status | 404 (router-owned paths cannot be pinned) |

Semantics

  • Pin bypasses session affinity and fallback spread — the addressed backend is used unconditionally.
  • Pinned requests are NOT failed over. The plain (unpinned) path retries a connection-level failure on another backend (per US-14); a pinned request surfaces the upstream error instead, because silently delivering the request to a different backend violates the pin's whole point.
  • Pinned rounds show up on GET /_router/status as kind: "pinned" and in the CLI access log as the pinned column value, so an unbalanced-looking spread is diagnosable as "the client pinned it, not a router bug".
  • Pin is session/process level, not per-request-within-a-CLI. The three supported CLIs (Claude Code / Codex / opencode) all bake base_url at startup and do not expose per-request overrides. Switch backend by starting a fresh CLI process (or, in Codex, by re-invoking codex exec with a different -c override).

Client configuration — point the CLI's base URL at the router's pin prefix:

# Claude Code — env or settings.json
export ANTHROPIC_BASE_URL=http://127.0.0.1:4141/backends/acct-a
claude

# Codex — ~/.codex/config.toml
# [model_providers.copilot-router]
# base_url = "http://127.0.0.1:4141/backends/acct-a/v1"

# opencode — opencode.json
# "provider": {
#   "gh-router": {
#     "npm": "@ai-sdk/openai-compatible",
#     "options": { "baseURL": "http://127.0.0.1:4141/backends/acct-a/v1" }
#   }
# }

Omit the prefix entirely (point at http://127.0.0.1:4141) to use automatic session affinity (or least-outstanding spread when no key exists) — pin is strictly opt-in.

Test

bun run test               # unit (pure logic: selection, config, bind-host)
bun run test:integration   # integration (spawn router + controllable fake backends)
bun run test:all           # both
bun run typecheck

Integration tests follow the copilot-api prior art: spawn the router as a child process with a readiness probe and ambient-env stripping, in front of controllable fake backends, and assert end-to-end behaviour (which backend was chosen, byte-for-byte stream fidelity, in-flight spread, credentials forwarded).

End-to-end (Makefile)

A Makefile wraps the router for end-to-end checks (it's what the e2e-verify workflow drives). The router is a loopback-only API service with no frontend, so verification is backend-only — curl the status plane.

make up          # start the router in the foreground (`make down` to stop)
make smoke       # probe a running router: GET /_router/status
make e2e         # one-shot: fake-backend stack + a real completion, then tear down
make down        # stop the router (or e2e-stack) started above

make up needs a config with at least one backend. If none exists it writes a placeholder to .tmp/router.config.json pointing at an unreachable backend: the router still binds and GET /_router/status confirms it is live, but completions cannot be served (no reachable backend). Point it at a reachable backend to exercise real forwarding (all knobs overridable):

make up CONFIG=./router.config.json   # also: PORT=4242 HOST=127.0.0.1

For real forwarding without a live backend, make e2e stands up N controllable fake backends (the integration-test fakes), points the router at them, fires one /v1/messages, and asserts the SSE hello from <backend> came back — then tears the whole stack down. make e2e-stack leaves that stack running in the foreground so you can poke it by hand (make down stops it):

make e2e BACKENDS=3      # assert real forwarding across 3 fake backends
make e2e-stack           # leave the stack running; curl it, then `make down`

Release

Bundled via tsdown to a single dist/main.mjs and published to npm. The package ships only the built artifact (dist/*.mjs) plus README.md / package.json — no source, tests, or sourcemaps.

NPM_TOKEN=<your-npm-token> bun run release

prepublishOnly gates the publish on typecheck + the full test suite (test:all); prepack (re)builds dist/. .npmrc reads NPM_TOKEN from the environment, so no token lives on disk.