One endpoint in front of every free LLM tier, with automatic failover and no provider key ever leaving the box. Providers are data, not code: adding one is a JSON file plus an env var, never a TypeScript change. Ships 12 providers pre-written; `verify-provider --apply` derives the live model list from your own key. - core: registry, bucketed quota meter (rolling + UTC-calendar windows), key-pool store, 6-step model router with diagnostic rejection reasons - gateway: node:http, failover that commits to a stream only after 200 headers, SSE passthrough with usage sniffing, virtual keys with admin/inference roles - context engine: sqlite sessions and a 5-rung refit ladder, so a 128k thread keeps working when it fails over to an 8k model - cache: exact (sha256, safe-by-default) + opt-in semantic (flat Float32Array) - guard: inbound limits, quota forecasting, limit calibration from upstream headers, key health, secret/PII/injection scanning - dashboard: one self-contained HTML file, live SSE request log, token usage - sdk: zero-dep typed client, drop-in for the OpenAI SDK Zero runtime dependencies. 326 tests, 27-check smoke suite against a scripted mock upstream so routing is testable without spending quota. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 KiB
Adding a provider
You do not edit TypeScript to add a provider. You write one JSON file and set
one environment variable. If you find yourself opening a .ts file, something
has gone wrong — see When you think you need code.
This document is written to be usable by a person or by an AI agent working unattended, including for a provider nobody here has ever heard of.
The 60-second version
node scripts/new-provider.mjs acme --url https://api.acme.ai/v1 --env ACME_API_KEY
export ACME_API_KEY=sk-...
node scripts/verify-provider.mjs acme
The verifier tells you what is wrong, what models it found, and what the real
rate limits are. Paste its suggestion into the file, run it again until it says
PASS, and the provider is live.
The loop
new-provider.mjs ──▶ providers/acme.json ──▶ verify-provider.mjs
▲ │
└────── paste suggestion ◀────┘
verify-provider.mjs is the feedback signal. It checks five things in order and
stops at the first that fails, so you always get one actionable problem rather
than a wall of noise:
| Step | Checks | Typical failure |
|---|---|---|
| 1. file | parses, matches the schema | limits.rpm: expected a positive number or null |
| 2. environment | ${ENV} resolves, a key exists |
no API key found (set one of: ACME_API_KEY) |
| 3. live probe | auth accepted, /models readable |
auth invalid — GET /models → 401 |
| 4. limits | headers vs declared limits | rpm: file says 60, provider reports 30 |
| 5. suggestion | prints paste-ready JSON | — |
Flags: --validate also sends a one-token request per model (spends real quota,
tells you which models the key can genuinely reach). --json for machine output
— use this if you are an agent.
The fields that actually matter
Everything else has a working default. Full contract:
providers/_schema.json.
baseUrl — with ${ENV_VAR} interpolation
"baseUrl": "https://api.groq.com/openai/v1"
// Some providers put an account id in the path. Interpolation is why that
// stays data instead of becoming a special case in code:
"baseUrl": "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1"
A missing variable is not a crash. The provider renders on the dashboard as a
card reading missing environment variable: CLOUDFLARE_ACCOUNT_ID.
auth — four shapes cover everything so far
{ "type": "bearer", "envKeys": ["ACME_API_KEY"] } // Authorization: Bearer <key>
{ "type": "header", "header": "x-api-key", "envKeys": ["ACME_KEY"] } // a named header
{ "type": "query", "query": "key", "envKeys": ["ACME_KEY"] } // ?key=<key>
{ "type": "none", "envKeys": [] } // local Ollama, no credential
{ "type": "bearer", "prefix": "", "envKeys": ["ACME_KEY"] } // bare token, no "Bearer "
Key pools. One variable may hold several comma-separated keys:
ACME_API_KEY=sk-aaa,sk-bbb,sk-ccc
Quota is tracked per key. A 429 on one benches only that key and the request
retries on the next; a 5xx benches the whole provider because every key would
fail identically. Commas are the grammar precisely so this works from a plain
docker run -e with no extra syntax.
limits — null is a real answer
"limits": { "rps": 1, "tpm": 500000, "tpmo": 1000000000 }
| Key | Window |
|---|---|
rps rpm tpm |
rolling |
rpd tpd creditsPerDay |
calendar day, resets 00:00 UTC |
tpmo |
calendar month |
Do not guess a number.
nullmeans "unknown or unlimited" and is always the right answer when you have not verified it. The guard layer overwrites nulls with values read from the provider's own rate-limit response headers, so an honest null becomes accurate on its own. A wrong number does not — it silently misroutes, and nothing ever corrects it.
Set "limitsSource": "unverified" when you are unsure. The dashboard badges it
and everything still works. Mistral's file does exactly this, because Mistral
stopped publishing free-tier figures.
models — a few verified beats a long guess
{
"id": "acme-turbo", // exactly as the provider spells it; slashes are fine
"alias": ["fast", "cheap"], // several providers sharing an alias is what enables failover
"context": 128000, // null if unpublished
"caps": ["chat", "tools", "json"],
"priority": 10, // lower is tried first
"credits": { "perMTokIn": 2457, "perMTokOut": 18252 } // only for credit-metered providers
}
caps drives routing and is not decorative:
| Cap | Effect |
|---|---|
chat |
eligible for /v1/chat/completions |
embed |
eligible for /v1/embeddings — and never for chat |
tools |
required when the request body has tools |
json |
required when response_format.type is json_object |
vision |
required when a message contains an image part |
reasoning code long-context |
informational, filterable in the dashboard |
Leave models: [] and let discovery populate it. That is better than a stale
list — see together.json and github-models.json.
modelsPath — for non-OpenAI response shapes
"modelsEndpoint": "/models",
"modelsPath": "data[].id" // {"data":[{"id":"..."}]} ← OpenAI standard
"modelsPath": "[].id" // [{"id":"..."}] ← GitHub Models
"modelsPath": "models[].name" // {"models":[{"name":...}]}
"modelsPath": "result[].name" // {"result":[{"name":...}]}
Set "modelsEndpoint": null when a provider has no catalog endpoint —
Cloudflare's OpenAI-compatible surface exposes /chat/completions and
/embeddings but not /models.
dropParams — params a provider rejects
"dropParams": ["frequency_penalty", "presence_penalty"]
Find them empirically: send a request, read the 400, add the offending field.
Worked example: a provider that does not exist
Say Acme AI offers a free tier. OpenAI-compatible, key in x-api-key,
account id in the URL, and a /models endpoint returning {"result":[...]}.
1. Scaffold.
node scripts/new-provider.mjs acme \
--url 'https://api.acme.ai/v1/org/${ACME_ORG}' \
--auth header --header x-api-key --env ACME_API_KEY
2. Fix the response shape in providers/acme.json:
"modelsPath": "result[].name"
3. Verify.
export ACME_API_KEY=sk-live-xxxx
export ACME_ORG=org_123
node scripts/verify-provider.mjs acme
1. file
✓ providers/acme.json parses
2. environment
✓ baseUrl resolves to https://api.acme.ai/v1/org/org_123
✓ 1 key in the pool: sk-l…xxxx (ACME_API_KEY)
3. live probe
✓ auth accepted (142ms)
✓ discovery found 3 models via /models → result[].name
4. limits
✓ observed from response headers: rpm=60, tpm=40000
+ rpm: file says null, provider reports 60 — consider recording it
5. suggested edits
{ "models": [...], "limits": {...}, "limitsSource": "observed" }
PASS — 0 problems, 0 warnings
4. Paste the suggestion in, add aliases and capabilities the verifier could
not know, and re-run with --validate to confirm each model really answers.
5. Use it. No restart needed:
curl -X POST localhost:8787/api/reload
curl localhost:8787/v1/chat/completions -H 'content-type: application/json' \
-d '{"model":"acme/acme-turbo","messages":[{"role":"user","content":"hi"}]}'
Common failures
| Symptom | Cause |
|---|---|
| Provider missing from the dashboard entirely | filename starts with _ (reserved), or the JSON failed to parse — check the red banner, it names the file and the syntax error |
unknown field — did you mean "baseUrl"? |
a typo like baseURL. Warnings are surfaced, never silently ignored |
Card shows no API key found |
env var unset, or set in a shell the gateway did not inherit |
Card shows missing environment variable: X |
a ${X} in baseUrl/headers did not resolve. Pasting a key will not fix this |
modelsPath matched nothing |
wrong response shape — curl the endpoint and read it |
| Model never gets routed to | missing a required cap, or context too small for minContext. The 429 body's blocked[] says which |
| Everything 429s immediately | a limit is set too low. Set it to null and let calibration find the truth |
Every rejection reason appears in the blocked array of a 429 response and in
the dashboard. You should never have to guess why a provider was skipped.
When you think you need code
Nearly always, you do not:
| "I need code because…" | Actually |
|---|---|
| the URL contains an account id | ${ENV_VAR} in baseUrl |
| auth is a custom header | auth.type: "header" |
the token has no Bearer prefix |
auth.prefix: "" |
/models is shaped differently |
modelsPath |
there is no /models |
modelsEndpoint: null |
| it rejects a parameter | dropParams |
| it needs an extra header | headers: {} (supports ${ENV} too) |
| it bills in credits, not tokens | limits.creditsPerDay + per-model credits |
| I have several keys | comma-separate them in one env var |
A genuinely new wire format — not OpenAI-compatible at all — is the one case
that needs a new adapter. That field exists and is validated, but only
"openai" is implemented. Adding one is a real code change; everything above is not.
Repo conventions that will bite you
- No TypeScript parameter properties.
constructor(private readonly x: T)throwsERR_UNSUPPORTED_TYPESCRIPT_SYNTAXunder Node's strip-only type stripping. Declare fields explicitly and assign in the constructor. - Zero runtime dependencies. Dev deps are
typescriptand@types/node. Stop and ask before adding anything. noUncheckedIndexedAccessis on: indexing returnsT | undefined. Use guards or?? fallback, never!.- Never log a key. Everything goes through
mask()inpackages/core/src/util.ts. - Tests are
node:test. Point them atscripts/mock-upstream.js, never at a real provider — that is what it is for.