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>
4.0 KiB
4.0 KiB
Freeway — notes for whoever works on this next
Self-hosted, OpenAI-compatible gateway in front of free-tier LLM providers. One endpoint, automatic failover, live quota visibility, and no real provider key ever leaving the box.
The one rule
Providers are data, not code. Adding a provider is one JSON file in
providers/ plus one env var. It must never require a TypeScript edit.
If you are adding a provider, read docs/ADDING-A-PROVIDER.md and use the tools — do not hand-write the file:
node scripts/new-provider.mjs <id> --url <baseUrl> --env <ENV_VAR>
node scripts/verify-provider.mjs <id> # your feedback loop; --json for machine output
The schema is the contract: providers/_schema.json.
Layout
packages/core/ registry, meter, store, router, guard, context, cache, probe
packages/gateway/ node:http server + public/index.html dashboard
packages/sdk/ @freeway/sdk client
providers/ the registry — one JSON file per provider, _-prefixed files skipped
scripts/ mock-upstream.js, verify-provider.mjs, new-provider.mjs
Hard constraints
- Node 22+, TypeScript, ESM, npm workspaces. Zero runtime dependencies.
Dev deps are
typescriptand@types/nodeonly. If you think you need a runtime dep, stop and ask. - No TypeScript parameter properties.
constructor(private readonly x: T)throwsERR_UNSUPPORTED_TYPESCRIPT_SYNTAXunder Node's strip-only type stripping, which is how the tests andverify-provider.mjsrun.tssources directly. Declare fields, assign in the constructor. strict+noUncheckedIndexedAccess: indexing returnsT | undefined. Guard or?? fallback; never!. Noanywithout a comment justifying it.- Tests are
node:testonly. Run them againstscripts/mock-upstream.js, never a real provider. - Never log a key. Everything goes through
mask()inpackages/core/src/util.ts. There are tests asserting raw keys cannot appear in errors or/api/*. - Comments explain why. Skip comments that restate the code.
Things that look wrong but are deliberate
nulllimits.nullmeans "unknown or unlimited" and beats a guessed number, because the guard layer overwrites nulls with values observed from upstream rate-limit headers. A wrong number never self-corrects. Mistral ships with nullrps/tpmbecause Mistral stopped publishing them.- Day and month limits are calendar windows, not rolling. Free tiers reset at 00:00 UTC. A rolling 24h window would block for hours after quota refilled.
- Model ids are split on the first slash only.
cloudflare/@cf/meta/llama-3.1-8b-instructis providercloudflare, model@cf/meta/llama-3.1-8b-instruct. - Failover commits after 200 headers, before piping the body. Once a byte of a stream reaches the client you cannot fail over, so the commit point sits exactly there.
- A 400 is never retried and benches nothing. It fails identically everywhere. A 401/403/429 benches the key; a 5xx or network error benches the provider.
- Per-model credit rates, not one per provider. Output tokens cost ~8x input and a 70B ~10x an 8B, so a single flat number is wrong by an order of magnitude.
Commands
npm run build # tsc --build, must be clean
npm test # node:test across all workspaces
npm run smoke # gateway against the mock upstream, every route
node scripts/mock-upstream.js --port 9101 --name alpha # a fake provider you control
Where things live
| Want to change… | File |
|---|---|
| provider file format | packages/core/src/spec.ts + providers/_schema.json |
| quota accounting | packages/core/src/meter.ts |
| which provider serves a request | packages/core/src/router.ts |
| failover / streaming behaviour | packages/gateway/src/proxy.ts |
| routes | packages/gateway/src/server.ts |
| the dashboard | packages/gateway/public/index.html (one file, no build step, no CDN) |