Self-hosted, OpenAI-compatible LLM router. Sends each request to the cheapest model capable of handling it, using your own API keys — no middleman markup.
Unlike keyword-only routers, routing here is data-driven: models live in a
table (the vademécum) with tier_max / cost / weight / mcp_native, and the
cheapest sufficient model wins. A lightweight heuristic classifier is only a
fallback for requests that don't declare their own tier.
This project is a Go reimplementation inspired by openfreerouter/freerouter (TypeScript, MIT) — the 14-dimension classifier and the OpenAI-compatible drop-in idea come from there. FreeRouter itself is forked from ClawRouter.
It is not a 1:1 port. Two concrete bugs in the original were fixed here, and the routing model was changed to match how my own Pillbox agent system already routes:
| Original (freerouter, TS) | FreeRouter-Go |
|---|---|
Keyword match via naive text.includes(kw) (substring) |
Word-boundary \b…\b match — no more art matching start |
Savings baseline hardcoded to claude-opus pricing |
Baseline = most-expensive enabled model in your own table |
| Tiers + model choice from static JSON | Vademécum in SQLite, CRUD + health-scan at runtime |
| Routing driven by classifier only | Caller may declare tier / requires_mcp; classifier is the fallback |
Two patterns are borrowed directly from my existing Go/agent work:
scopeCandidatesForordering (tier_max ASC, cost ASC, weight DESC) from Pillbox — the cheapest sufficient model wins.requires_tooling≠requires_mcp(Pillbox commit5e2448c): plain tool use must not pin a request to an MCP-native model; only genuine agentic orchestration filters onmcp_native.Message.Contentaccepts string or array of blocks — a real bug from cc_bridge (2026-06-17): OpenClaw's WhatsApp channel sends content as an array, Telegram as a string.
Go 1.25 · Gin · Gorm · SQLite (pure-Go glebarez/sqlite, builds with
CGO_ENABLED=0 into a single static binary, same as cc_bridge).
CGO_ENABLED=0 go build -o freerouter .
cp freerouter.config.example.json freerouter.config.json # optional; runs on defaults otherwise
./freerouter # FRGO_CONFIG_PATH overrides config locationOpenAI-compatible surface + a small admin API for the vademécum.
| Method | Path | Purpose |
|---|---|---|
| GET | /health |
liveness |
| GET | /v1/models |
list routable models (OpenAI shape) |
| POST | /v1/chat/completions |
classify → pick model → proxy (streaming passes through) |
| GET | /admin/models |
list vademécum |
| POST | /admin/models |
add a model |
| PUT | /admin/models/:id |
update a model |
| DELETE | /admin/models/:id |
remove a model |
| POST | /admin/models/:id/scan |
probe endpoint, record health |
| GET | /admin/tokens |
list issued dev tokens (prefix, enabled, last used) |
| POST | /admin/tokens |
issue a token {"name":"dev"} → plaintext shown ONCE |
| POST | /admin/tokens/:id/revoke |
disable a token |
| POST | /admin/tokens/:id/enable |
re-enable a token |
| GET | /admin/usage |
usage aggregated by user+model (?user=&model=&from=&to=) |
| GET | /admin/usage/recent |
raw usage records (?limit=) |
| GET | /admin/secrets |
list stored provider secrets (masked: name, preview, updated_at) |
| POST | /admin/secrets |
set/rotate a secret {"name":"DEEPSEEK_KEY","value":"sk-…"} |
| DELETE | /admin/secrets/:name |
remove a stored secret |
| GET | /admin/keys |
list the API-key refs models use, and whether each resolves |
Every /v1/chat/completions response carries the routing decision in headers:
X-FreeRouter-Model, X-FreeRouter-Tier, X-FreeRouter-Savings.
FreeRouter is a gateway: hand each dev a single frgo_… token instead of
every provider's key. Tokens are stored hashed (sha256); the plaintext is shown
once at issue time.
/v1/*requiresAuthorization: Bearer frgo_…(per-dev token)./admin/*requires the static admin token (FRGO_ADMIN_TOKENenv oradmin_tokenin config). If unset,/adminis open and a warning is logged.- Every billed request records
{user, model, tier, prompt/completion/total tokens, cost}. Token counts are the upstream's real numbers; streamed requests getstream_options.include_usageinjected so they bill too.
Query "who used how many tokens of which model": GET /admin/usage returns
buckets grouped by user + model.
Each model references its upstream key by name (api_key_ref). A ref resolves
from the DB secret store first (POST /admin/secrets), then falls back to an
environment variable of the same name — so you can add or rotate provider
keys through the API (or dashboard) without editing the VM's .env or
restarting. GET /admin/keys shows which refs the models need and whether each
one currently resolves. Secret values are stored server-side and never returned
(listings are masked to a short preview).
The request body may declare routing intent, which overrides the classifier:
{ "model": "auto", "tier": 2, "requires_mcp": false,
"messages": [{ "role": "user", "content": "..." }] }Or use a prompt-prefix mode override: /simple, /medium, /complex,
/reason, /max (also bracket form, e.g. [simple]).
MIT, matching the upstream FreeRouter / ClawRouter lineage.