One endpoint for every model.
A self-hostable LLM router / gateway: OpenAI- and Anthropic-compatible, explicit-first
routing with fallbacks, spend limits, and metadata-only cost tracking.
No markup, no third-party proxy — your keys, your box.
The dashboard overview — KPI tiles, a requests-per-hour chart, spend by model, and the live request log (each row shows its routing layer, tokens, snapshot-priced cost, and latency).
polyrouter sits between your AI agents and your LLM providers. Agents talk to one endpoint with one key; polyrouter routes each request to the right model across your providers (BYOK API keys, custom OpenAI/Anthropic-compatible endpoints, local models), retries down a fallback chain when a provider fails, enforces budgets, and records what every request actually cost — storing metadata only by default, never your prompt or response bodies unless you opt in.
Routing & reliability
- Explicit-first routing — naming a model always works; that's the reliable core.
On top of it: per-request tier pinning (
x-polyrouter-tier: fast), configurable tier chains (primary + ordered fallbacks, drag-to-reorder in the dashboard), and opt-in smart layers formodel: "auto"— L1 structural (sub-millisecond local features; harness system prompts are fingerprinted and subtracted so a huge boilerplate prompt can't force everything into the top tier) and L3 cascade (try the cheap model, escalate on a failed quality check). Every smart layer degrades to explicit/default — a request never fails because routing tried to be clever. - Safe mid-stream semantics — fallbacks happen freely before the first token; once streaming has begun the model is committed, and an upstream failure terminates the stream with a clear error. Models are never silently swapped mid-response.
- Per-provider circuit breakers (Redis-backed, shared across instances) with half-open probes that survive long LLM streams; hung connects and stalled reads trip them cleanly.
Protocols
- OpenAI-compatible
/v1/chat/completions+/v1/modelsand Anthropic-compatible/v1/messages, streaming and non-streaming — any SDK that accepts a base URL works unchanged. Cross-protocol requests (OpenAI client → Anthropic provider and vice versa) go through a dedicated translation core covering multi-turn tool calls, system prompts, cache-control passthrough, stop reasons, and usage — locked by a golden-file contract suite.
Cost & limits
- Immutable cost records — every request stores its unit-price snapshot at request
time; later catalog updates never rewrite history. Missing provider usage is flagged as
estimated (
~est), never silently nulled. Prices come from a bundled versioned catalog (auto-refreshed daily from LiteLLM's — one env line opts out), with per-model overrides for custom/local endpoints. When the catalog has no exact match, polyrouter falls back to an adjacent native-family rate, then to the provider's own listed price — each snapshotted as a clearly-marked estimate that never overrides a real catalog price. - Budgets that actually block — day/week/month windows, global or per-agent, alert-or-block at the threshold, enforced via atomic Redis counters that stay correct across multiple proxy instances.
- Async notifications — SMTP and/or Apprise channels for budget alerts/blocks, provider-down, and failure spikes; deliveries are queued off the request path, deduplicated, and a failing channel never blocks a request or budget enforcement.
Dashboard
- SolidJS + uPlot: overview KPIs and request charts, cost breakdowns by model/provider/agent, per-agent usage with one-click key rotation, provider health & catalog sync, routing configuration, and the decision inspector — every request shows its decision layer and human-readable routing reason, tokens, snapshot-priced cost, and latency.
- Accessible by design: fully keyboard-operable (real buttons, visible focus, honest
dialog semantics), WCAG-checked contrast,
prefers-reduced-motionsupport — all enforced by regression test suites, with the visual language pinned inSTYLESEED.md.
Security & privacy
- Metadata only by default — prompt/response bodies are never persisted unless you explicitly opt in (self-host only, off by default, stored encrypted at rest with retention controls). Provider and channel credentials are encrypted at rest.
- First-signup-wins, then invite-only — the first account becomes the admin and public registration closes; teammates join via single-use, 72-hour, hash-stored invite links (emailed when SMTP is configured). Admins manage users, roles, and the registration mode from the dashboard; disabling a user revokes sessions and agent keys in one stroke, and the last enabled admin is undeletable. See Users & registration.
- Two credential planes — dashboard sessions (Better Auth: email/password +
optional Google/GitHub/Discord OAuth, slow-hashed) vs. agent API keys
(
poly_…, HMAC-SHA256 + prefix lookup — fast per-request verification, never bcrypt on the hot path). - SSRF-guarded egress — every user-supplied URL the server fetches (provider base URLs, webhook/Apprise targets) is resolved and checked against private/loopback/ link-local/metadata ranges, IPv6 included, with DNS-rebinding defense; loopback is allowed only for local models in self-host mode.
- Tenant isolation everywhere — every entity access is ownership-scoped through a central guard; covered by a dedicated e2e suite alongside the SSRF, protocol-contract, and cost-immutability suites.
- OpenRouter app attribution — requests to an
openrouter.aiprovider carry polyrouter's identity headers (HTTP-Referer: https://polyrouter.app,X-OpenRouter-Title: polyrouter) so the project appears in OpenRouter's rankings. They are non-secret (an app URL and name — never prompts, keys, or user data), sent only to OpenRouter (every other provider gets neither), and never affect authentication.
Operations
- One container serves the SPA, the API, and the proxy on one port, next to
PostgreSQL 16 + Redis; graceful shutdown drains in-flight streams; streaming applies
backpressure. Prometheus
/metrics+ opt-in OpenTelemetry traces. - CI/CD — every push runs build/lint/typecheck, the unit suites, and e2e against real
Postgres + Redis; tagged releases publish a multi-arch (amd64+arm64) image to
ghcr.io/izzoa/polyrouter.
Precedence order, first match wins:
- Explicit model in the request body — always honored.
x-polyrouter-tierheader → that tier's chain.model: "auto"→ enabled smart layers (L1 structural → L3 cascade).defaulttier — the guaranteed catch-all.
Whatever layer decides, the tier's fallback chain applies on provider failure, budgets are
enforced, and the decision (decision_layer + routing_reason) is recorded for the
inspector. If a smart layer is unavailable, auto silently degrades to the default tier.
flowchart LR
A["Agents<br/>(any OpenAI / Anthropic SDK)"] -- "/v1 + poly_ key" --> P
subgraph S["polyrouter — one container"]
P["Inference proxy<br/>route · fallback · budget"] --- T["Protocol<br/>translation"]
D["Dashboard SPA + API"]
end
T --> O["OpenAI-compatible<br/>providers"]
T --> C["Anthropic-compatible<br/>providers"]
P -.->|"atomic counters · breakers"| R[("Redis")]
P -->|"RequestLog + price snapshots"| PG[("PostgreSQL")]
D --> PG
The smart routing layers all run inside the proxy (not as separate services): L1 structural and L3 cascade ship in the baseline; the optional L2 semantic embedder and its background learning loop — which adapts thresholds from recorded request outcomes — are a flag-gated add-on that reuses the same Redis/PostgreSQL, never in the baseline image (see the semantic embedder).
Monorepo (Turborepo + npm workspaces): packages/shared (types),
packages/control-plane (NestJS — dashboard API, auth, CRUD, analytics),
packages/data-plane (the proxy: routing, translation, recording),
packages/frontend (SolidJS SPA). Architecture overview: the code wiki in
openwiki/; release history: CHANGELOG.md.
Requirements: Docker with Compose v2.
# One-liner (inspect it first if you prefer — see below):
curl -fsSL https://raw.githubusercontent.com/izzoa/polyrouter/main/install.sh | sh
# Or from a checkout (uses your working tree, downloads nothing):
git clone https://github.com/izzoa/polyrouter.git && cd polyrouter && ./install.shThe one-liner executes a remote script. To inspect first: download
install.sh, read it, then run it — or use the checkout path.
The script checks Docker, fetches one pinned source archive (compose file and build
context always the same commit), generates secrets into a mode-600 .env (never
overwritten on re-run), and boots docker compose -p polyrouter-selfhost up -d --build.
The first build takes a few minutes. Manual alternative: copy .env values by hand
(four 32-byte-hex secrets via openssl rand -hex 32, plus POSTGRES_PASSWORD) and run
the same compose command from the repo. Re-running the installer from inside the
created polyrouter/ directory is safe — it refreshes the source and keeps .env.
No checkout, no local build — pull the published multi-arch (amd64 + arm64) image and run it next to Postgres + Redis. Make a directory with two files.
docker-compose.yml — pin a version (or use :latest):
name: polyrouter-selfhost
services:
app:
image: ghcr.io/izzoa/polyrouter:0.8.1 # or :latest
restart: unless-stopped
ports:
- '${POLYROUTER_HOST:-127.0.0.1}:${POLYROUTER_PORT:-3001}:3001' # loopback by default
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
stop_grace_period: 45s # drain in-flight streams on stop
env_file: .env # optional tunables from the .env reference reach the container
environment:
NODE_ENV: production
MODE: selfhosted
BIND_ADDRESS: 0.0.0.0 # bind inside the container; host exposure is `ports`
PORT: '3001'
DATABASE_URL: postgresql://polyrouter:${POSTGRES_PASSWORD}@postgres:5432/polyrouter
REDIS_URL: redis://redis:6379
BETTER_AUTH_URL: ${APP_URL:-http://localhost:${POLYROUTER_PORT:-3001}}
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?set in .env}
API_KEY_HMAC_SECRET: ${API_KEY_HMAC_SECRET:?set in .env}
PROVIDER_CREDENTIAL_KEY: ${PROVIDER_CREDENTIAL_KEY:?set in .env}
NOTIFY_CREDENTIALS_SECRET: ${NOTIFY_CREDENTIALS_SECRET:?set in .env}
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: polyrouter
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set in .env}
POSTGRES_DB: polyrouter
volumes: ['polyrouter-pg:/var/lib/postgresql/data']
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U polyrouter -d polyrouter']
interval: 5s
timeout: 3s
retries: 12
redis:
image: redis:7-alpine
restart: unless-stopped
volumes: ['polyrouter-redis:/data']
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 3s
retries: 12
apprise: # optional notification fan-out (see below)
image: caronc/apprise:latest
profiles: ['apprise'] # only starts with `--profile apprise`
restart: unless-stopped
volumes: ['polyrouter-apprise:/config']
networks:
default:
ipam:
config:
- subnet: ${POLYROUTER_SUBNET:-172.28.5.0/24} # deterministic CIDR for NOTIFY_ALLOWED_ENDPOINTS
volumes:
polyrouter-pg:
polyrouter-redis:
polyrouter-apprise:.env — the app aborts at boot if any of the five secrets is missing. Generate real
values straight into the file (compose does not run shell substitution inside .env, so
these must be literal), then lock it down:
{
for k in BETTER_AUTH_SECRET API_KEY_HMAC_SECRET PROVIDER_CREDENTIAL_KEY \
NOTIFY_CREDENTIALS_SECRET POSTGRES_PASSWORD; do
echo "$k=$(openssl rand -hex 32)"
done
} > .env
chmod 600 .envThen boot it — migrations run on start, no build step:
docker compose up -d
docker compose logs -f app # watch it come up, then sign up at http://localhost:3001Upgrade by bumping the image: tag (or tracking :latest) and pulling:
docker compose pull && docker compose up -d # migrations run on bootThis is the repo's
docker-compose.ymlwith two doc-friendly changes: a pinnedimage:tag instead of a localbuild:, and the long list of optional pass-through vars collapsed intoenv_file: .env. The service names, volumes, pinned subnet, andappriseprofile all match, so the Operations and Apprise notes below apply unchanged — every variable in the.envreference is optional and defaults when unset. To go public, expose the port and setAPP_URLas in Claim the instance below.If
docker compose pullreturnsunauthorized/denied, the image is private — either the maintainer hasn't flipped the GHCR package to public yet, or rundocker login ghcr.iofirst.
Already used the installer or a checkout? Skip the local build by setting
POLYROUTER_IMAGE=ghcr.io/izzoa/polyrouter:latest(or a pinned:X.Y.Z) in.envand running the compose command without--build. On a fetch install the compose flags go before the subcommand, exactly as the installer prints:docker compose -p polyrouter-selfhost --env-file .env -f src/docker-compose.yml --project-directory src pull.Maintainer note (one-time, first release): GHCR packages are created private by default — after the first
v*tag publishes, set thepolyrouterpackage to public (package settings → Danger zone → Change visibility) or anonymous pulls will fail.
Compose commands below — checkout vs. one-line install. The bare
docker compose -p polyrouter-selfhost …form shown below assumes a checkout (compose file at the repo root). A one-line (fetch) install keeps the compose file undersrc/with.envbeside it, so run the commands from inside thepolyrouter/directory with the--env-file .env -f src/docker-compose.yml --project-directory srcflags placed before the subcommand — exactly the manage command the installer prints when it finishes.
Claim the instance, then expose it. The app publishes on loopback only by
default and the first account to sign up becomes the admin — sign up at
http://localhost:3001 before exposing anything. To go public, set in .env:
POLYROUTER_HOST=0.0.0.0 # or keep loopback and use a reverse proxy
POLYROUTER_PORT=3001
APP_URL=https://polyrouter.example.com # the real origin (auth callbacks/cookies)then docker compose -p polyrouter-selfhost up -d. Put TLS and access control in
front with your reverse proxy — /api/health and /metrics are unauthenticated
by design (orchestration + Prometheus); restrict them at the proxy if the port is
public, or set METRICS_ENABLED=false.
| Variable | Default | Purpose |
|---|---|---|
BETTER_AUTH_SECRET, API_KEY_HMAC_SECRET, PROVIDER_CREDENTIAL_KEY, NOTIFY_CREDENTIALS_SECRET |
generated | Required 32-byte-hex secrets (sessions, agent-key HMAC, credential + channel encryption at rest) |
POSTGRES_PASSWORD |
generated | Database password — initialization-only: changing it later does NOT rotate the role password in postgres |
POLYROUTER_HOST / POLYROUTER_PORT |
127.0.0.1 / 3001 |
Host interface/port the app is published on |
APP_URL |
http://localhost:3001 |
Public origin (Better Auth base URL) — set it when exposing |
METRICS_ENABLED |
true |
Prometheus /metrics (404 when false) |
OTEL_ENABLED / OTEL_EXPORTER_OTLP_ENDPOINT |
false / SDK default |
OpenTelemetry traces for the proxy path (batched OTLP/HTTP export) |
GOOGLE_/GITHUB_/DISCORD_CLIENT_ID+_SECRET |
unset | Optional OAuth sign-in providers |
APPRISE_API_URL + NOTIFY_ALLOWED_ENDPOINTS |
unset | Optional Apprise fan-out — see below |
SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS / SMTP_FROM / SMTP_SECURE |
unset (PORT 587, SECURE starttls) |
Server-wide SMTP for password-reset and invite email — active only when both SMTP_HOST and SMTP_FROM are set; otherwise password reset silently never sends and invites must be delivered by copying the link. Rely on OAuth if you don't set it |
ROUTING_AUTO_LAYERS |
structural |
Which smart-routing layers are on. Cascade (cheap→escalate) is OFF until you set structural,cascade — the dashboard toggle just shows it greyed out otherwise |
ROUTING_STRUCTURAL_WEIGHTS |
built-ins | JSON override for the Layer-1 classifier. Ambient keys (size code tools schema depth multimodal maxTokens) merge over the defaults and normalize to sum 1; the reasoning key is the declared-hint adjustment magnitude in [0, 0.5] (default 0.1), NOT normalized. A declared reasoning_effort/thinking steers the score; a maximal declaration routes auto_high directly |
CALIBRATION_SCHED_ENABLED / CALIBRATION_SCHED_CRON |
true / 0 4 * * * |
The per-tenant threshold-calibration sweep (opt-in PER TENANT from the Routing page; this pair gates the background worker instance-wide) |
CALIBRATION_WINDOW_DAYS / CALIBRATION_MIN_EDGE_SAMPLES / CALIBRATION_STEP / CALIBRATION_MAX_DRIFT |
14 / 50 / 0.02 / 0.1 |
Calibration rails: evidence window, minimum fresh edge-zone samples (hard floor 50 — only raisable), bounded per-run step, and the max total drift from the instance thresholds. Every move is audited and one click from reverted |
BUDGET_FAIL_OPEN |
true |
On a Redis/enforcement fault, block budgets admit the request (availability-first). Set false for a hard cap that returns 503 instead |
TRUSTED_PROXY_CIDRS |
unset | CIDRs of reverse proxies allowed to set X-Forwarded-For (rate-limit client-IP trust) — set it when behind a proxy |
NOTIFY_APPRISE_EGRESS_CONFIRMED |
false |
Cloud-mode (MODE=cloud) acknowledgement before Apprise delivery runs — the SSRF allowlist (NOTIFY_ALLOWED_ENDPOINTS) is still enforced independently |
PRICING_REFRESH_URL |
LiteLLM catalog | Source for pricing refreshes (a bundled snapshot ships by default; the Settings page shows catalog status + a Refresh-now button for admins) |
PRICING_REFRESH_SCHED_ENABLED / PRICING_REFRESH_SCHED_CRON |
true / 30 4 * * * |
Daily automatic pricing refresh — ON by default (self-host only): one outbound GET of LiteLLM's public price catalog per day; no tenant data is sent. Set PRICING_REFRESH_SCHED_ENABLED=false to opt out; manual refresh keeps working |
PROXY_FIRST_EVENT_TIMEOUT_MS / PROXY_IDLE_TIMEOUT_MS |
30000 / 30000 |
Time-to-first-token / buffered-read idle bound — raise both for slow local models (a 30s prefill would otherwise 503 and trip the breaker) |
SEMANTIC_MODEL_PATH |
unset | Opt-in Layer 2 semantic embedder: path to a local model bundle (see the semantic-layer section) — pair it with semantic in ROUTING_AUTO_LAYERS. Unset = the module is absent entirely; a set-but-broken path fails boot loudly |
SEMANTIC_TIMEOUT_MS / SEMANTIC_MAX_INPUT_CHARS / SEMANTIC_CONCURRENCY |
50 / 2000 / 2 |
Embedder bounds: per-embed hard timeout, input cap before tokenization, concurrent-inference cap (saturation skips the layer for that request). Out-of-bounds values reject boot |
POLYROUTER_SUBNET / POLYROUTER_IMAGE |
172.28.5.0/24 / built |
Compose network CIDR (change on a collision) / prebuilt image override |
The optional tunables are compose pass-through: set one in
.envand it reaches the container (the compose file sets the deploy-invariant ones — bind address, mode,NODE_ENV, DB/Redis URLs — itself). The config registry in the source (packages/*/src/**config schemas) is the exhaustive list — defaults, required-in-production secrets, and dev fallbacks are declared there.
Secret rotation caveat: PROVIDER_CREDENTIAL_KEY and NOTIFY_CREDENTIALS_SECRET
encrypt stored provider/channel credentials — rotating them orphans those rows (you
would re-enter the credentials). This is why the installer never regenerates .env.
The optional semantic stack embeds request text locally (CPU ONNX, ~5–20 ms)
so the auto-router can classify what the structural layer finds ambiguous.
It is never part of the baseline install: the runtime is an optional peer
dependency and no model ships in the baseline image (CI asserts this). The
routing behavior that consumes it arrives with the semantic-routing
capability; a batteries-included -semantic image variant (runtime +
reference model pre-baked) ships with the semantic dashboard change.
Every tagged release also publishes a multi-arch -semantic image with the
ONNX runtime and the reference embedding model
(sentence-transformers/all-MiniLM-L6-v2, Apache-2.0, 384-dim) baked in at
build time and SEMANTIC_MODEL_PATH preset. It boots with semanticAvailable
already true — nothing is downloaded at runtime. Layer it over the base stack:
docker compose -f docker-compose.yml -f docker-compose.semantic.yml up -dor run the published image directly (set the capability so semanticAvailable
is on):
docker run … -e ROUTING_AUTO_LAYERS=structural,semantic,cascade \
ghcr.io/izzoa/polyrouter:latest-semanticThe baseline image is unchanged — it carries no ONNX runtime and no model files, and CI gates that on every build. The model's weights are the glibc build's only reason for a Debian base (the runtime's prebuilt binaries do not run on Alpine/musl).
Bring your own model: mount a bundle over the baked one and repoint the env — the same fail-fast boot contract applies:
# in .env
SEMANTIC_MODEL_DIR=/abs/path/to/your/bundle # holds model.onnx + vocab + manifest.json
SEMANTIC_MODEL_PATH=/app/models/custom
# then uncomment the `volumes:` mount in docker-compose.semantic.ymlTo enable it on a source install instead:
npm install onnxruntime-node@1.27.0 # the optional peer, exact-pinnedThen set BOTH the model path and the capability flag (semanticAvailable
requires the layer token as well as a loaded bundle):
SEMANTIC_MODEL_PATH=/path/to/models/minilm
ROUTING_AUTO_LAYERS=structural,semanticThe model bundle directory looks like:
models/minilm/
manifest.json # the v1 bundle contract (below)
vocab.txt # WordPiece vocabulary, one token per line
model.onnx # the embedding model (MiniLM/bge-small class, 384-dim)
{
"schemaVersion": 1,
"tokenizer": {
"type": "wordpiece", "vocabFile": "vocab.txt", "lowercase": true,
"unkToken": "[UNK]", "clsToken": "[CLS]", "sepToken": "[SEP]",
"padToken": "[PAD]", "maxTokens": 256
},
"model": {
"file": "model.onnx",
"inputNames": { "inputIds": "input_ids", "attentionMask": "attention_mask", "tokenTypeIds": "token_type_ids" },
"outputName": "last_hidden_state", "outputKind": "token_embeddings",
"dims": 384, "pooling": "mean", "normalize": true
}
}Boot semantics: unset path → module absent, zero overhead; valid bundle → load + warmup at startup (requests never pay first-inference JIT); broken bundle → boot fails fast naming the file and reason (an explicit opt-in never runs silently degraded). Nothing is fetched over the network at boot or runtime; embedded text and vectors are never logged or persisted.
docker compose -p polyrouter-selfhost --profile apprise up -dand add both lines to .env (the SSRF guard requires a port-bounded allowlist
entry for a private-range host — by design, spec §10.1):
APPRISE_API_URL=http://apprise:8000
NOTIFY_ALLOWED_ENDPOINTS=apprise,172.28.5.0/24,8000The compose network is pinned to 172.28.5.0/24 so that CIDR is deterministic;
change both places if it collides with your network.
- Upgrade: pull/re-download the source, then
docker compose -p polyrouter-selfhost up -d --build— or, on the prebuilt image,docker compose -p polyrouter-selfhost pull && docker compose -p polyrouter-selfhost up -d. Migrations run on boot either way. - Backup: the
polyrouter-pgvolume is the data;docker compose exec postgres pg_dump -U polyrouter polyrouter > backup.sql. - Stop/restart: in-flight streaming responses are drained on
docker stop(45s grace period) — deploys don't sever live completions. - One app replica only: boot migrations take no advisory lock — do not
--scale app. - Verify an install:
scripts/selfhost-smoke.shruns the end-to-end smoke pass (health, admin bootstrap, live-stream drain, metadata-only persistence) against a throwaway stack. - Compliance note: using flat-rate consumer subscriptions (ChatGPT Plus, Claude Max) programmatically likely violates those providers' ToS — polyrouter supports the provider kind but surfaces the risk; BYOK API keys and local models don't carry it.
Subscription providers can connect through a guided OAuth wizard instead of pasting a
token by hand: pick a preset (Claude Pro/Max or ChatGPT Plus/Pro), sign in at the
provider's link, and paste the redirect URL — or the code#state string it shows — back
into the dashboard. polyrouter verifies the state, exchanges the code (PKCE), and stores
the access + refresh tokens encrypted at rest. Tokens auto-refresh before expiry
(safe across multiple requests and instances); if the provider revokes the grant, the card
shows "reauthorize required" with a one-click reconnect, and your fallback chain keeps
serving traffic meanwhile.
Honest caveats:
- These integrations ride undocumented contracts. The OAuth endpoints and what the
provider accepts from subscription tokens are ecosystem-known, not published APIs — the
provider can change them at any time. Each preset ships enabled only after its own live
verification — both passed on 2026-07-18 (
scripts/verify-claude-oauth.md,scripts/verify-chatgpt-oauth.mdrecord the runs and the pinned constants); failures surface as a clear provider error, and polyrouter never impersonates the first-party client beyond the documented headers — no client-fingerprint headers and no imitation system prompts, even if that means a preset stays disabled. - ChatGPT specifics: the ChatGPT preset speaks the backend's Responses API with
store: falseon every call (nothing is retained server-side by request), and any reasoning items the backend emits are dropped, never persisted or replayed — a deliberate metadata-only trade that can reduce multi-turn tool-use quality on reasoning-heavy models. The backend also rejectsmax_tokensand sampling parameters (temperature/top_p) — requests through this provider ignore them (verified live; usage is flat-rate, so no billing surprise), and it only serves streaming upstream (polyrouter buffers transparently for non-streaming clients). - The ToS compliance note above applies — pair a subscription with a pay-per-token fallback provider.
- Key rotation: changing
PROVIDER_CREDENTIAL_KEYinvalidates stored credentials; OAuth-connected providers will then ask to be reauthorized.
The first account to sign up owns the instance: it becomes the admin and registration immediately closes to invite-only (racing sign-ups during that first moment are refused — exactly one bootstrap winner). Everything after that is managed from the admin-only Users page (account menu, bottom of the sidebar):
- Invites — single-use links pinned to an email, expiring after 72 h. With
server SMTP configured (
SMTP_HOST+SMTP_FROM) the invite is emailed automatically; without it, copy the link from the dashboard and deliver it yourself — issuing never depends on SMTP. Only a token hash is stored, and the raw token travels in the link's#fragment, which browsers never send to servers or proxies. - Roles — promote/demote admins. The last enabled admin can never be
deleted, demoted, or disabled (the API refuses with
409). - Disable — cuts both credential planes at once: dashboard sessions are
revoked immediately and every agent API key the user owns stops working on
/v1. Re-enabling requires a fresh sign-in. - Registration mode — reopen public sign-up (
open) or keep itinvite_only, live from the dashboard.
Upgrading an existing instance closes public sign-up (the migration seeds
invite_only); reopen it under Users → Registration if you want walk-in
sign-ups back. Break-glass if you ever lock yourself out (no enabled admin
left): fix the row directly in Postgres, then sign in again —
UPDATE "user" SET disabled = false, role = 'admin' WHERE email = 'you@example.com';polyrouter speaks the OpenAI and Anthropic wire protocols, so any tool that lets you
set a base URL and API key works with no other changes. Create an agent key in
the dashboard (Agents → New — it looks like poly_… and is shown once), then point
your client at your instance:
- Base URL: an OpenAI SDK/client uses
https://<your-instance>/v1; an Anthropic SDK useshttps://<your-instance>(it appends/v1/messagesitself). The raw endpoints are/v1/chat/completions,/v1/messages, and/v1/models - API key: the
poly_…key from the dashboard (sent asAuthorization: Bearer poly_…) - Model: an explicit model id (e.g.
gpt-4o),auto(let the router pick), or a tier via thex-polyrouter-tierheader (e.g.fast/heavy)
# OpenAI-compatible
curl https://<your-instance>/v1/chat/completions \
-H "Authorization: Bearer poly_your_key" \
-H "Content-Type: application/json" \
-d '{"model":"auto","messages":[{"role":"user","content":"hi"}]}'
# Anthropic-compatible
curl https://<your-instance>/v1/messages \
-H "Authorization: Bearer poly_your_key" \
-H "Content-Type: application/json" \
-d '{"model":"claude-3-5-sonnet","max_tokens":256,"messages":[{"role":"user","content":"hi"}]}'
# Pin a routing tier instead of a model:
# -H "x-polyrouter-tier: fast" (with "model":"auto")The router applies your configured fallbacks, spend limits, and cost tracking on every
call. Explicit routing (a named model) is the reliable core; auto and tier routing are
opt-in and always degrade back to explicit/default.
Terminal-native coding agents are configured with a config file rather than SDK code.
Both speak the OpenAI-compatible endpoint, so point their base_url at
https://<your-instance>/v1 with your poly_… key and let the router pick the model
(auto). The dashboard's Agents → New picks the harness and shows the exact block once;
the equivalents are:
OpenClaw — ~/.openclaw/config.toml:
[llm]
base_url = "https://<your-instance>/v1"
api_key = "poly_your_key"
model = "auto"Hermes Agent — ~/.hermes/config.yaml:
model:
default: auto
provider: custom
base_url: "https://<your-instance>/v1"
api_key: "poly_your_key"Prefer to keep the key out of the YAML? Hermes supports env substitution — use
api_key: ${POLYROUTER_KEY} in ~/.hermes/config.yaml and put POLYROUTER_KEY=poly_your_key
in ~/.hermes/.env.
Each OpenAI-compatible provider has a maxTokensSpelling setting (DB max_tokens_spelling)
that controls which wire field polyrouter sends the output-token cap under:
auto(default) — kind-derived: a local provider emitsmax_tokens(older self-hosted runtimes like llama.cpp / LM Studio accept only that and silently ignore the newer field, which would drop your cap); every other kind emitsmax_completion_tokens, required by OpenAI's o-series and other reasoning models.max_completion_tokens/max_tokens— force one field. Setmax_tokensfor a custom endpoint that only understands the legacy field;max_completion_tokensfor a reasoning endpoint reached through a custombase_url.
It only applies to OpenAI-compatible providers (Anthropic-compatible always uses max_tokens),
and is set on the provider create/update API (a dashboard control is a follow-up).
Requirements: Node.js 24.x (see .nvmrc), npm 10–11, Docker (for the dev database).
# 1. dependencies
npm ci
# 2. dev infrastructure (PostgreSQL 16 + Redis 7)
docker compose -f docker-compose.dev.yml up -d
# 3. run: control-plane API on :3001, dashboard (Vite) on :3000
npm run devOn a fresh self-hosted instance the first account you sign up becomes the admin.
For a pre-seeded dev admin, boot with SEED_DATA=true (loopback-bound, non-production,
self-hosted only) — it creates admin@polyrouter.local with password changeme-dev-admin
(change it immediately). Auth secrets (BETTER_AUTH_SECRET, API_KEY_HMAC_SECRET,
32-byte hex) are required for any network-reachable or production instance.
Useful commands (see CLAUDE.md for the full set):
| Command | What it does |
|---|---|
npm run dev |
control-plane (watch) + frontend together |
npm run build |
production build via Turborepo |
npm start |
production server (SPA + API + proxy, one port) |
npm test -w packages/<pkg> |
unit tests for one package |
npm run test:e2e -w packages/control-plane |
e2e suites (needs the dev compose up) |
npm run db:generate / npm run db:migrate |
Drizzle migrations (also run automatically on boot) |
npm run lint / npm run format |
ESLint / Prettier |
Development is spec-driven (OpenSpec change proposals):
CLAUDE.md pins the stack and the non-negotiable invariants,
CHANGELOG.md records every user-facing change (each release links its
GitHub notes), and STYLESEED.md locks the dashboard's visual language
(UI changes must pass its /ss-score gate). CI enforces build, lint, typecheck, the unit suites, and e2e
— including the protocol-contract (golden files), SSRF, tenant-isolation, and
cost-immutability suites — on every push.
An auto-generated code wiki lives in openwiki/: start at
openwiki/quickstart.md for the architecture overview,
source maps, request flow, and runbook notes. The scheduled
OpenWiki workflow regenerates it daily and
opens a PR with the refresh — don't hand-edit generated wiki pages; change the source and
let the next run regenerate them. (Maintainer: the workflow needs the
OPENROUTER_API_KEY repo secret.)
AGPL-3.0. Run it, self-host it, fork it — if you offer a modified polyrouter as a network service, the AGPL asks you to offer its users your modified source.