A lightweight Go proxy/router for LLM APIs. Accepts OpenAI-compatible requests (/v1/chat/completions, /v1/responses, /v1/models) and routes them across multiple upstream providers with multi-key failover and automatic Anthropic↔OpenAI translation.
| Method | Path | Description |
|---|---|---|
| GET | /health |
Health check ({"status":"ok"}) |
| GET | /v1/models |
List configured models |
| POST | /v1/chat/completions |
OpenAI chat completions (stream or non-stream) |
| POST | /v1/messages |
Anthropic /v1/messages (translated to OpenAI) |
| POST | /v1/responses |
OpenAI responses (only openai-style providers) |
- Go 1.26.4+ (build from source)
- Docker + Docker Compose (container run, optional)
git clone <repo> && cd RouterLLM
copy routerllm.yaml.example routerllm.yaml
copy .env.example .envEdit .env with your provider API keys:
OPENCODE_API_KEY=sk-...
FREEMODEL_API_KEY=key1,key2.env is loaded automatically at startup. Real environment variables take precedence over .env.
go run ./cmd/routerllmServer starts on port 1765 by default.
curl http://localhost:1765/health
curl http://localhost:1765/v1/modelsConfig is read from routerllm.yaml (override with ROUTERLLM_CONFIG_FILE env var).
port: 1765 # listen port
cooldown: 60s # key cooldown after 401/402/403
force_stream: false # force Anthropic SSE output format
system_prompt_file: system_prompt.txt # prepended as system messageEach provider maps to an upstream API. All fields must be specified — no defaults.
providers:
- name: forge # unique name, referenced in routes
api_key: ${FORGE_API_KEY} # env var or list: [key1, key2]
base_url: https://forge-gateway-api.fly.dev
style: openai # openai | anthropic | cline | google | alysis | codex
auth_mode: bearer # bearer | x-api-key | both (ignored for google)
headers: # optional extra headers
Content-Type: application/json
share: mygroup # optional key-sharing group name
query: "?beta=true" # optional query stringapi_key supports:
${ENV_VAR}— reads from environment- Comma-separated env var values auto-split into multiple keys
[key1, key2, key3]— YAML list of static keys (not recommended in public repos)
style: cline authenticates with Cline accounts instead of API keys. Log in first:
routerllm --cline-loginThe command prints a login URL and user code, waits for authorization, then stores the
account refresh token in cline-accounts.json next to the executable (0600). Override the
location with CLINE_ACCOUNTS_FILE. Access tokens are refreshed automatically and kept in
memory only. Run the command again to add more accounts — RouterLLM rotates them and fails
over when one is rejected.
The example config ships the cline provider commented out: an enabled style: cline
provider refuses to start until at least one account is logged in, so uncomment the provider
and its route only after --cline-login has created the accounts file.
In Docker, run --cline-login on the host first, then uncomment the
cline-accounts.json mount in docker-compose.yml — the host file must exist before the
container starts, because Docker silently auto-creates a missing bind-mount path as a
directory, which then breaks both the login and the account loader.
providers:
- name: cline
style: cline
base_url: https://api.cline.bot/api
routes:
- model_id: cline-free/glm-5.2
routes:
- provider: cline
model: cline-free/glm-5.2style: alysis authenticates with Alysis gateway accounts instead of API keys. Log in first:
routerllm --alysis-loginThe command prints a device-authorization URL and user code, waits for approval, then stores the
issued long-lived gateway key in alysis-accounts.json next to the executable (0600). Override
the location with ALYSIS_ACCOUNTS_FILE. The key is sent as Authorization: Bearer and is never
refreshed; run the command again to add more keys — RouterLLM rotates them through the normal key
manager. The gateway is plain OpenAI passthrough, so no api_key is needed.
The example config ships the alysis provider commented out: an enabled style: alysis
provider refuses to start until at least one account is logged in, so uncomment the provider
and its route only after --alysis-login has created the accounts file.
In Docker, mount alysis-accounts.json only after the host file exists — a missing bind-mount
path is silently auto-created as a directory, which breaks the account loader.
providers:
- name: alysis
style: alysis
base_url: https://vzigujbcjjmpntxhmyvr.supabase.co/functions/v1/llm/v1
routes:
- model_id: deepseek-v4-flash
routes:
- provider: alysis
model: deepseek-v4-flashstyle: codex authenticates with Codex/ChatGPT accounts instead of API keys, via OpenAI's
OAuth device flow. Log in first:
routerllm --codex-loginThe command prints a verification URL and user code, waits for authorization, then stores the
account refresh token in codex-accounts.json next to the executable (0600). Override the
location with CODEX_ACCOUNTS_FILE. Access tokens are refreshed automatically and kept in
memory only. Run the command again to add more accounts — RouterLLM rotates them and fails over
when one is rejected.
The example config ships the codex provider commented out: an enabled style: codex
provider refuses to start until at least one account is logged in, so uncomment the provider
and its route only after --codex-login has created the accounts file.
In Docker, run --codex-login on the host first, then uncomment the codex-accounts.json
mount in docker-compose.yml — the host file must exist before the container starts, because
Docker silently auto-creates a missing bind-mount path as a directory, which then breaks the
account loader.
Requests are stateless per turn — no conversation chaining, prompt-cache reuse or WebSocket transport — so very long agentic sessions may behave differently than with OpenAI-style providers.
providers:
- name: codex
style: codex
base_url: https://chatgpt.com/backend-api
routes:
- model_id: gpt-5.6-codex
routes:
- provider: codex
model: gpt-5.6-codexstyle: google speaks the native Gemini API (generativelanguage.googleapis.com): requests are
translated to :streamGenerateContent?alt=sse and the SSE back to OpenAI shape. The key is sent
as x-goog-api-key (multi-key failover works); auth_mode/query are ignored for this style.
System prompts, tool calls, base64 images and stop are translated both ways.
providers:
- name: google
api_key: ${GEMINI_API_KEY}
style: google
base_url: https://generativelanguage.googleapis.com
routes:
- model_id: gemini-3.8-flash
routes:
- provider: google
model: gemini-3.8-flash
defaults:
reasoning_effort: high # → generationConfig.thinkingConfig.thinkingLevel
# thinking_budget: 8192 # → generationConfig.thinkingConfig.thinkingBudget/v1/responses and /v1/files are OpenAI-only — google-style providers are filtered out there,
same as anthropic ones.
Map client-facing model names to upstream provider+model pairs. Routes are tried in order until one succeeds.
routes:
- model_id: my-model # ID clients send in requests
routes:
- provider: forge # must match a provider name above
model: gpt-4 # upstream model name
defaults:
reasoning_effort: high # none|minimal|low|medium|high|xhigh|max
thinking_budget: 32000 # max reasoning tokens (dialect-translated)Reasoning settings arrive in several dialects. RouterLLM folds all of them — client-sent and route defaults alike — into two canonical keys and re-emits whichever dialect the target provider speaks.
| System | Parameter shape |
|---|---|
| OpenAI | reasoning_effort: none minimal low medium high xhigh max (ultra → max) |
| OpenRouter | reasoning map {enabled, effort, max_tokens, exclude} |
| Qwen / DashScope | top-level enable_thinking: bool + thinking_budget: int |
| Anthropic | thinking: {type: enabled, budget_tokens} |
| Gemini | thinkingConfig (google style) |
| Cline gateway | reasoning_effort string |
Precedence: client reasoning map > client reasoning_effort/enable_thinking > route defaults.
openai-style providers pick their outbound dialect with reasoning_style:
reasoning_style: openai # openai | openrouter | qwen | raw (default openai)Client dialect keys are consumed and stripped — they are no longer forwarded raw to openai-style upstreams. anthropic/google/cline styles have fixed dialects and ignore reasoning_style.
Route defaults stay canonical (reasoning_effort, enable_thinking, thinking_budget) and are dialect-translated per provider.
| Field | Values | Notes |
|---|---|---|
reasoning_effort |
none / minimal / low / medium / high / xhigh / max |
Canonical effort key; ultra is normalized to max. Re-emitted in the target provider's dialect. |
enable_thinking |
true / false |
false maps to effort none. |
thinking_budget |
integer (tokens) | Canonical budget key; dialect-translated (Anthropic thinking.budget_tokens, Gemini thinkingConfig.thinkingBudget). |
Single provider, no defaults:
- model_id: deepseek-v4-flash
routes:
- provider: opencode
model: deepseek-v4-flash-freeMulti-provider fallback with defaults (thinking off):
- model_id: gpt-5.5
routes:
- provider: freemodel-api
model: gpt-5.5
- provider: forge
model: gpt-5.5
defaults:
reasoning_effort: max # re-emitted as reasoning_effort for openai style- A request arrives with a
modelname. - Router looks up matching routes in config order.
- For each route, tries available API keys round-robin.
- On success → returns response immediately.
- On HTTP 401/402/403 → key marked dead (cooldown period), skips to next key.
- On HTTP 408/429/5xx → retries up to 3 times with exponential backoff (100ms, 200ms, 400ms), then skips to next route.
- On all keys exhausted → tries next route.
- On all routes exhausted → returns last error with its HTTP status.
Shared keys (share: in provider config): a key marked dead in one provider is dead for all providers sharing that group.
| Variable | Default | Description |
|---|---|---|
ROUTERLLM_PORT |
1765 |
Server listen port |
ROUTERLLM_CONFIG_FILE |
routerllm.yaml |
Config file path |
ROUTERLLM_DEBUG |
false |
Per-request routing trace (model, chosen provider). Failures — dead keys, retries, exhausted providers — are always logged. |
ROUTERLLM_DEBUG_ADVANCED |
false |
Log client/system headers and bodies to the log file |
ROUTERLLM_LOG_FILE |
— | Also write operational logs to file |
ROUTERLLM_TELEMETRY_FILE |
beside config | Request-event jsonl path (default routerllm-telemetry.jsonl next to the config) |
ROUTERLLM_TELEMETRY_USAGE |
on |
off disables the stream_options.include_usage injection used for tok/s |
AUTHTOKEN |
— | Bearer token(s) for /v1 write endpoints (comma-separated); unset = open |
ROUTERLLM_ADMIN_TLS_PORT |
— | Serve the admin console over HTTPS on this port; main port drops /admin |
ROUTERLLM_ADMIN_TLS_CERT / _KEY |
auto | TLS cert/key paths; self-signed pair generated beside the config when missing |
| Provider API keys | — | *_API_KEY vars per your config |
.env files are loaded automatically (real env vars take precedence).
The console never sends ROUTERLLM_ADMIN_TOKEN over the wire. It proves knowledge of the secret instead:
POST /admin/api/auth/challenge→{"challenge_id", "nonce", "expires_in":60}(nonce is single-use).- Compute
proof = hex(HMAC-SHA256(secret, nonce))client-side. POST /admin/api/auth/verify {"challenge_id", "proof"}→{"session", "expires_in":43200}.- Call every admin API with
Authorization: Bearer <session>. Sending the raw secret is a 401.
Sessions are in-memory (≤100 live, 12h TTL) — a server restart requires logging in again.
HTTPS for the console (recommended outside localhost): set ROUTERLLM_ADMIN_TLS_PORT=1766 and open https://<host>:1766/admin/. A self-signed certificate is generated on first start (admin-tls.crt/admin-tls.key beside the config — the browser warns once; import the .crt as a trusted root to silence it, or point ROUTERLLM_ADMIN_TLS_CERT/_KEY at your own cert). With TLS on, the main port stops serving /admin so the console never exists over plain HTTP.
/v1 auth: set AUTHTOKEN in .env and pass Authorization: Bearer $AUTHTOKEN on every write call — POST /v1/chat/completions, /v1/responses, /v1/messages, file uploads. GET /v1/models and /health stay open. Unset keeps the old open behaviour.
Scripting example:
NONCE_JSON=$(curl -s -X POST http://127.0.0.1:1765/admin/api/auth/challenge)
CID=$(echo "$NONCE_JSON" | jq -r .challenge_id)
NONCE=$(echo "$NONCE_JSON" | jq -r .nonce)
PROOF=$(printf %s "$NONCE" | openssl dgst -sha256 -hmac "$ROUTERLLM_ADMIN_TOKEN" -hex | awk '{print $2}')
SESSION=$(curl -s -X POST http://127.0.0.1:1765/admin/api/auth/verify -d "{"challenge_id":"$CID","proof":"$PROOF"}" | jq -r .session)
# With ROUTERLLM_ADMIN_TLS_PORT set, reach the console over HTTPS instead:
# curl -s https://127.0.0.1:1766/admin/api/status --cacert admin-tls.crt -H "Authorization: Bearer $SESSION"
curl -s http://127.0.0.1:1765/admin/api/status -H "Authorization: Bearer $SESSION"For the proxy API, pass the AUTHTOKEN value on write endpoints:
curl -s http://127.0.0.1:1765/v1/chat/completions \
-H "Authorization: Bearer $AUTHTOKEN" \
-d '{"model":"my-model","messages":[{"role":"user","content":"hi"}]}'With a session, the console gains two write paths on Signal paths:
+opens a dialog (Provider, Upstream model, Reasoning Effort, Disabled) that appends a fallback leg torouterllm.yaml— same surgery as a hand edit, comments and${VAR}placeholders survive — and reloads.✕on a leg removes it. The last leg of a model cannot be removed (disable it or delete the model instead).
- Model toggle (the switch on a model card) persists to
routerllm.yamlasdisabled: trueon the route rule: the model disappears from/v1/modelsand requests for it 404, while the whole chain stays in the file. Endpoint:POST /admin/api/routes/{model} {"disabled":bool}. - Key toggle (in a provider's detail drawer) is runtime-only: it flips a manual disable inside the key manager that survives hot-reload but not a process restart. Keys arrive as
${ENV}placeholders so there is nothing durable to write — to retire a key for good, remove it from the env var. Endpoint:POST /admin/api/providers/{name}/keys/{index} {"disabled":bool}.
The provider drawer also has a Test button that fires a one-shot chat request pinned to that provider — it bypasses route chains and uses the provider's real key pool and the same request translation as normal proxying (anthropic/google/cline/openai dialects). You pick the upstream model and can adjust four fields: test prompt, max_tokens, reasoning effort and a timeout (default 20s, clamped to 5–120s). Results render inline — status, TTFT, duration, tok/s, token count and the returned content, or the upstream error body on failure. Every run is recorded as a telemetry event too, so tests show up in Request logs alongside proxied traffic.
Every proxied request records one event — key always masked (…abcd), request bodies and successful response bodies are never stored. Failed requests capture the upstream's error response body (2 KB cap, per attempt) for debugging:
{"seq":1,"time":"…","model":"my-model","status":200,"provider":"google","upstream_model":"gemini-3.8-flash",
"key":"…abcd","ttft_ms":388,"duration_ms":4120,"tokens_out":612,"request_id":"…",
"attempts":[{"provider":"google","model":"gemini-3.8-flash","key":"…abcd","status":503,"latency_ms":312,"note":"non-200"}]}GET /admin/api/requests?since=<seq>returns the buffered events (ring of 2000) pluslatest— the console's request-log table and trace drawer are built from this.GET /admin/api/metricsreturns rolled-up views: per-provider/per-leg/per-model 24h summaries (requests, errors, success %, TTFT p50/p95, uptime %, tok/s), 24×1h hourly windows and 7×24h daily windows (anchored to local midnight) for the dashboard charts.- Persistence: events append to
routerllm-telemetry.jsonlbeside the config (override withROUTERLLM_TELEMETRY_FILE) and are replayed on start, so dashboards survive restarts. The file rotates at ~10 MB and events older than 7 days are dropped. - tok/s: usage is sniffed from the upstream stream (
completion_tokens/output_tokens/candidatesTokenCount); for OpenAI-style relaysstream_options.include_usageis injected on/v1/chat/completionsso streams carry a usage chunk. SetROUTERLLM_TELEMETRY_USAGE=offto disable the injection. stream_optionsinjection can be turned off withROUTERLLM_TELEMETRY_USAGE=offif a relay rejects the field.
docker compose up --build -d
docker compose logs -f routerllm
docker compose downMounts routerllm.yaml (read-write, so the admin console can persist toggles) and
system_prompt.txt as volumes. Reads secrets from .env — the file must exist before
docker compose up (compose env_file), even though host runs read it too.
The image runs as non-root UID 65532, so on Linux the host ./routerllm.yaml must be
readable and writable by that UID — otherwise the container cannot read the config and
admin-console writes fail: sudo chown 65532:65532 routerllm.yaml. Create the file before the
first docker compose up: a missing bind-mount source is auto-created as a directory, and
compose then fails on is a directory.
The config is hot-reloaded (3s content poll), but on Docker Desktop a single-file bind
mount does not propagate host-side edits into the container — hand-editing
routerllm.yaml on the host goes unnoticed no matter how long you wait. Two reliable
options:
- Edit through the admin console at
/admin/— writes happen inside the container, reload immediately, and sync back to the host file. docker compose restart routerllmafter hand edits.
On a plain host install (binary / go run) hand-edit hot reload works as documented.
The repository includes requests.http for VS Code REST Client or JetBrains HTTP Client.
- Start the server:
go run ./cmd/routerllm - Open
requests.http - Set your
AUTHTOKENvariable (e.g.Authorization: Bearer <key>) - Send requests via the inline "Send Request" links
Or use curl directly:
# Health check
curl http://localhost:1765/health
# List models
curl http://localhost:1765/v1/models
# Chat completion (streaming)
curl -N http://localhost:1765/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"my-model","messages":[{"role":"user","content":"Hello"}],"stream":true}'
# Chat completion (non-streaming)
curl http://localhost:1765/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"my-model","messages":[{"role":"user","content":"Hello"}]}'
# Anthropic /v1/messages
curl -N http://localhost:1765/v1/messages \
-H "Content-Type: application/json" \
-d '{"model":"my-model","messages":[{"role":"user","content":"Hello"}],"max_tokens":1024}'go build -o routerllm.exe ./cmd/routerllm # single binary
go build ./... # compile-check all packages
go test ./... # run tests
go vet ./... # static analysis- Hot-reload: config is polled every 3s and swapped atomically on change; invalid edits are rejected and the previous config keeps serving. Caveat: on Docker Desktop, host-side hand edits don't propagate through the single-file bind mount — use the admin console or restart (see "Docker + hot reload" above).
- Streaming is forced: all outbound requests to upstreams have
stream=true. Non-streaming clients receive a buffered response. - Request body limit: 10 MB.
/v1/responsesonly works withopenai-style providers;anthropicandgoogleproviders are filtered out./v1/messagesaccepts Anthropic-format requests, translates them to OpenAI, and translates the response back to Anthropic SSE — including forgoogleupstreams (Gemini → OpenAI chunks → Anthropic SSE).
- Never commit API keys to git. Use
.envor environment variables. - Add
routerllm.yamlto.gitignoreif it contains secrets. - Review headers and logging before deploying to production.
- The binary
routerllm.exeat the repo root is a gitignored build artifact — delete freely.