Files
freeway/README.md
T
Aryan SinghandClaude Opus 5 e7b51c6fc9
Docker / build & push image (push) Canceled after 0s
CI / build & test (node 22) (push) Canceled after 0s
CI / build & test (node 24) (push) Canceled after 0s
Stop running the full pipeline on documentation commits
A README change was building a multi-arch Docker image. Both workflows now
ignore markdown, docs/, demo/, .gitignore and LICENSE. providers/*.json is
deliberately not ignored: CI fails if one stops parsing, which is the one data
change that can break a running gateway.

arm64 is cross-built under QEMU and dominates the Docker job, so it is now only
built for tagged releases; main and PRs build native amd64 only. QEMU setup is
skipped entirely when it is not needed.

Anchors would have kept the paths-ignore lists DRY, but Actions has been
unreliable with them and a broken workflow costs more than five duplicated
lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:44:01 -05:00

518 lines
19 KiB
Markdown

# Freeway
**Self-hosted OpenAI-compatible gateway for free-tier LLM providers. Automatic
failover, quota metering, context refit, two-tier caching, zero runtime deps.**
Your apps point at `http://localhost:8787/v1` and never see a provider key. When
one free tier runs out, the next one takes over — mid-conversation, even if it
has a sixteenth of the context window. Ask the same thing twice and the second
answer is free.
![Freeway demo](demo/freeway-demo.gif)
**The dashboard, reacting to that same traffic:**
![Freeway dashboard](demo/freeway-dashboard.gif)
*Both are real. The terminal is `./scripts/demo.sh`; the dashboard is headless
Chrome screenshotting the actual page via `node scripts/record-dashboard.mjs`,
between real requests. Watch the token totals, cache hit rate and per-model
counts climb — nothing is mocked up.*
```
your app ──▶ Freeway ──┬──▶ Mistral 1B tokens/month
├──▶ Cloudflare 10k neurons/day
├──▶ Groq 30 rpm, very fast
├──▶ Cerebras 1M tokens/day
└──▶ …8 more, each one JSON file
```
---
## 60-second quickstart
```bash
git clone <your-fork> freeway && cd freeway
npm install && npm run build
cp .env.example .env # fill in whatever keys you have; none are required
npm start
```
Open <http://localhost:8787> for the dashboard, then:
```bash
curl localhost:8787/v1/chat/completions \
-H 'content-type: application/json' \
-d '{"model":"auto","messages":[{"role":"user","content":"hello"}]}' -D-
```
```
HTTP/1.1 200 OK
x-freeway-provider: mistral ← who served it
x-freeway-model: mistral-small-latest
x-freeway-route: auto ← how the model string was resolved
x-freeway-attempts: 1
x-freeway-ms: 412
```
Or with Docker:
```bash
docker compose up -d
```
No keys yet? It still starts. Every provider shows up in the dashboard as a card
telling you exactly which variable to set and where to get one.
---
## Adding a provider
**This is the headline feature, so it gets the headline section.**
Adding a provider is **one JSON file plus one environment variable**. Never a
code change. The gateway ships with twelve providers already written — most are
just waiting for a key.
### A provider that's already bundled
Set the variable and reload. That's the whole procedure.
```bash
echo 'GROQ_API_KEY=gsk_...' >> .env
curl -X POST localhost:8787/api/reload
```
Or paste it straight into the dashboard card — it writes `data/keys.json` at
mode `0600`, masks it everywhere, never logs it, and probes the key immediately
to show you which models it can reach.
### A provider nobody has heard of
```bash
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 is the feedback loop:
```
1. file
✓ providers/acme.json parses
2. environment
✓ baseUrl resolves to https://api.acme.ai/v1
✓ 1 key in the pool: sk-l…xxxx (ACME_API_KEY)
3. live probe
✓ auth accepted (142ms)
✓ discovery found 3 models via /models → data[].id
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
```
Paste the suggestion in, re-run, done. Add `--validate` to send a one-token
request per model and see which ones the key can genuinely reach. Add `--json`
if you're an agent.
### The file itself
```jsonc
{
"id": "acme",
"baseUrl": "https://api.acme.ai/v1", // ${ENV_VAR} works here
"auth": { "type": "bearer", "envKeys": ["ACME_API_KEY"] },
"limits": { "rpm": 60, "tpm": null }, // null = unknown; NEVER guess
"modelsPath": "data[].id", // for non-OpenAI response shapes
"models": [
{ "id": "acme-turbo", "alias": ["fast"], "context": 128000, "caps": ["chat", "tools"] }
]
}
```
Full walkthrough, including every escape hatch for odd providers:
**[docs/ADDING-A-PROVIDER.md](docs/ADDING-A-PROVIDER.md)**.
Machine-readable contract: [`providers/_schema.json`](providers/_schema.json).
> **On `null` limits.** A `null` means "unknown or unlimited" and is always
> better than a guess: 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 never self-corrects. Mistral ships with
> `null` rps and tpm for exactly this reason — they stopped publishing them.
### Bundled catalog
| Provider | Free tier | Variable |
|---|---|---|
| Mistral | ~1B tokens/month, phone verification | `MISTRAL_API_KEY` |
| Cloudflare Workers AI | 10,000 neurons/day | `CLOUDFLARE_ACCOUNT_ID` + `CLOUDFLARE_API_TOKEN` |
| Groq | ~30 rpm / 14.4k rpd, very fast | `GROQ_API_KEY` |
| Cerebras | ~1M tokens/day | `CEREBRAS_API_KEY` |
| Google AI Studio | ~15 rpm / 1500 rpd, multimodal · not in EU/UK/CH | `GOOGLE_AI_API_KEY` |
| OpenRouter | `:free` models only | `OPENROUTER_API_KEY` |
| GitHub Models | needs `models:read` scope | `GITHUB_MODELS_TOKEN` |
| Together | `-Free` models only | `TOGETHER_API_KEY` |
| Hugging Face | small monthly credits | `HF_TOKEN` |
| NVIDIA NIM | credits, not a free tier | `NVIDIA_API_KEY` |
| Scaleway | EU-hosted (Paris) | `SCALEWAY_API_KEY` |
| Ollama | your own machine | `OLLAMA_HOST` |
---
## Aliases
Point at a capability, not a vendor. Several providers sharing an alias is what
makes failover work.
```bash
curl … -d '{"model":"fast", …}' # whichever fast model is healthy
curl … -d '{"model":"auto", …}' # anything that can serve this request
```
| Model string | Meaning |
|---|---|
| `auto`, `freeway`, `""` | any healthy chat model under quota |
| `fast`, `cheap`, `big`, `code` | aliases from `freeway.config.json` or a provider file |
| `mistral/mistral-small-latest` | pin one model |
| `mistral/*` | pin a provider, any model |
| `llama-3.1-8b` | fuzzy — finds `@cf/meta/llama-3.1-8b-instruct` |
Model ids are split on the **first** slash only, so Cloudflare's
`@cf/meta/llama-3.1-8b-instruct` survives being pinned.
---
## Strategies
Set `strategy` in `freeway.config.json` or `FREEWAY_STRATEGY`.
| Strategy | Picks | Use when |
|---|---|---|
| `priority` | lowest `priority` number first | you have a preferred provider (default) |
| `round-robin` | rotates between calls | you want to spread load evenly |
| `least-used` | emptiest quota window | you want to drain every free tier evenly |
| `fastest` | lowest EWMA latency | latency matters more than which provider |
`fastest` uses optimistic initialisation — an untried provider sorts first so it
gets sampled once and earns a real number, rather than never being tried.
---
## Failover
Walks candidates up to `maxAttempts` (default 3). What gets benched depends on
what went wrong, and getting this wrong is what makes key pools useless:
| Upstream says | Blame | Effect |
|---|---|---|
| 401, 403 | **the key** | bench that key for 15+ min; the pool's others still serve |
| 429 | **the key** | bench that key for `Retry-After`; try the next key |
| 5xx, timeout, network | **the provider** | bench the provider; skip its other keys |
| 400, 404, 422 | **the request** | return immediately — it fails identically everywhere |
When nothing is viable you get a 429 whose body *is* the diagnosis:
```json
{ "error": { "code": "all_providers_blocked" },
"freeway": { "retryAfterMs": 23410, "blocked": [
{ "provider": "mistral", "reason": "tpm limit reached (500.0K/500.0K last 60s)" },
{ "provider": "cloudflare", "reason": "all 2 keys unavailable — cloudflare#0 (cf-t…9a21) cooling down for 24s: 429" }
]}}
```
---
## Sessions and the context engine
Send a `session` id and only the new message. The gateway rebuilds the thread —
and reshapes it to fit whichever model actually serves the call.
```bash
curl localhost:8787/v1/chat/completions -H 'content-type: application/json' -d '{
"session": "proj-42", "model": "auto",
"messages": [{"role":"user","content":"and now?"}]
}'
```
```
x-freeway-provider: cloudflare
x-freeway-model: @cf/meta/llama-3.1-8b-instruct
x-freeway-context: refit=compact tokens=41200->7100 kept=8 dropped=52 summaries=1
```
That's a 128k conversation continuing on an 8k model. The refit runs **per
candidate**, after the router picks one — each has a different window, so a
history that fit Mistral has to be reshaped again on failover.
The ladder escalates only as far as it must:
| Rung | Does | Costs |
|---|---|---|
| `passthrough` | nothing; it already fits | — |
| `drop-tool-noise` | clears stale tool results, leaves a marker | free |
| `window` | keeps system + first turn + last N turns | free |
| `compact` | summarises the dropped middle | one LLM call, cached forever by range |
| `hard-truncate` | deterministic character-level cut | free, always terminates |
Guarantees: the system message always survives; a tool call is never split from
its result; the newest user message is never mangled — if it alone exceeds the
window you get a **413** saying so. And if a summary comes back *longer* than
what it replaced (a real failure mode), it's discarded and the ladder escalates.
Sessions are optional. Omit the id and it's a plain OpenAI endpoint.
---
## Caching
Two tiers, and the point of both is the same: **a hit costs no quota, no
upstream call, and no time.** On a free tier that is not an optimisation, it is
extra headroom you did not have.
| Tier | Default | Catches | Cost of a miss |
|---|---|---|---|
| **exact** | on (`safe`) | the identical request | nothing |
| **semantic** | off | the same question, worded differently | one embedding call |
### Exact tier
The key is a SHA-256 over a **canonical** form of everything that could change
the answer:
```
messages · input · temperature · top_p · top_k · max_tokens · stop
tools · tool_choice · response_format · seed · presence_penalty · frequency_penalty
```
Canonical matters. Object keys are sorted before hashing, so two clients that
serialise the same request in a different order still share one entry. Anything
not on that list — `user`, tracing metadata, your own annotations — is ignored,
because it cannot change what the model says.
The key is built from the **resolution**, not the raw model string. `auto` and
`fast` resolving to the same model share a cache entry rather than each keeping
their own copy.
### `safe` mode, and why it is the default
```jsonc
"cache": { "mode": "safe" } // "off" | "safe" | "aggressive"
```
`safe` caches only requests the provider would be expected to answer identically
twice: `temperature <= 0.2`, or an explicit `seed`. A `temperature: 0.9` call is
*supposed* to vary — replaying one answer forever turns a creative endpoint into
a broken record, which is worse than not caching at all. `aggressive` caches
everything, including streams, when you know your workload is deterministic.
### Semantic tier
Off by default, because every miss spends a real embedding call.
Turned on, it embeds the newest user turn through **your own** `/v1/embeddings`
(so it routes and fails over like any other request), normalises the vector, and
matches on cosine similarity above `0.92` — the threshold the GPTCache
literature settles on for factual workloads.
Vectors live in **one flat `Float32Array`** with a parallel id array. Search is a
linear scan of normalised dot products. 5,000 entries at 1,024 dims is about
20 MB and a scan is well under a millisecond, so a vector database would be a
runtime dependency solving a problem this size does not have. The slab adopts
its width from the first embedding it sees, so `bge-large` (1024) and
`bge-small` (384) both work without configuration.
**Entries are scoped, not global.** The scope is a fingerprint of the resolved
model set *and* the system prompt. Without that, a "you are a pirate" answer
could be served under "you are a lawyer" — the same question genuinely has a
different correct answer. A miss is also remembered for 60s, so an identical
miss is not re-embedded twice.
### Details that make it usable rather than merely present
- **A cached stream is still a stream.** Hits replay as SSE in 24-character
frames with the usage block and trailing `[DONE]` intact, so a client
rendering token-by-token cannot tell a hit from a live call.
- **Hits are never metered.** Counting them against a provider's quota would
make the dashboard bars lie about what is left.
- **Hits are logged distinctly** — `x-freeway-cache: exact|semantic`, plus
`x-freeway-cache-score` on a semantic hit, and a tile on the dashboard showing
what fraction of traffic cost nothing.
- **Eviction is TTL + true LRU.** Ordering by millisecond timestamp looked fine
and was wrong: entries written in the same millisecond tie, and eviction stops
being least-recently-used. A monotonic access counter fixes it.
- **Compaction summaries are cached too**, keyed by the exact turn range they
cover. Re-compacting a thread never pays for the same summary twice — the most
expensive thing the context engine can do, done at most once.
```jsonc
"cache": {
"mode": "safe",
"ttlMs": 86400000,
"maxEntries": 5000,
"semantic": { "enabled": true, "threshold": 0.92, "model": "embed", "maxEntries": 5000 }
}
```
`GET /api/cache` reports hit rate, entries and tokens saved. `DELETE /api/cache`
clears both tiers.
### What it deliberately is not
The cache is **shared across virtual keys**. Two keys asking the same question
share an entry — not a content leak, since the same question has the same
answer, but `x-freeway-cache: exact` does reveal that *someone* asked it before.
On a personal gateway that is the right trade; if it is not yours alone, set
`cache.mode: "off"`. There is no distributed tier and no cross-process sharing:
one gateway, one sqlite file, on purpose.
---
## Rate limiting and threat analysis
| Piece | What it does |
|---|---|
| **inbound limiter** | per-virtual-key and per-IP rpm/tpm/rpd/concurrency. Stops one runaway app draining every free tier |
| **quota forecasting** | `mistral: tpmo exhausted in ~4h at current burn` — a trajectory, not a level |
| **calibration** | learns real limits from `x-ratelimit-*` headers and overwrites `null`s |
| **tier drift** | flags a provider enforcing *less* than its file claims — a silently downgraded key |
| **key health** | re-probes every 6h: valid / revoked / forbidden / unreachable |
| **content scanning** | regex tripwires for leaked secrets, PII (Luhn-checked cards), and prompt injection |
Scanning is `flag` by default and only *blocks* on high-severity findings even
in `block` mode — failing a request over a stray email address would make the
mode unusable. Findings store the match **redacted**; a scanner that logs the
secret it found has done more damage than the thing it was watching for.
All of it surfaces at `GET /api/alerts` and in the dashboard's Alerts panel.
---
## SDK
```bash
npm i @freeway/sdk
```
```ts
import { createFreeway } from '@freeway/sdk';
const fw = createFreeway({ baseUrl: 'http://localhost:8787/v1', apiKey: 'fw-…' });
await fw.chat('summarize this', { model: 'fast' }); // → string
const res = await fw.chat.raw({ model: 'auto', messages }); // → full response
res.route.provider; // → 'mistral'
for await (const text of fw.streamText({ model: 'fast', messages })) process.stdout.write(text);
await fw.embed(['a', 'b']); // → number[][]
await fw.models();
```
Already using the OpenAI SDK? Drop it in:
```ts
import OpenAI from 'openai';
const openai = new OpenAI(fw.openai()); // { baseURL, apiKey }
```
Errors carry the diagnosis:
```ts
catch (err) {
if (err instanceof FreewayError) {
err.blocked; // per-provider reasons
err.retryAfterMs;
err.suggestions; // on a 404
}
}
```
---
## Dashboard
<http://localhost:8787> — one self-contained HTML file. No framework, no build
step, no CDN (there's a test asserting it references nothing external).
Provider cards with health dots, EWMA latency, per-key state and masked keys ·
quota bars coloured by fill with reset countdowns · unconfigured providers shown
as cards with the variable to set and a paste box · searchable model catalog ·
alerts panel · **a live request log pushed over SSE**, not polled — entries
appear the instant a request completes, with failed attempts expandable.
---
## Environment variables
| Variable | Default | Meaning |
|---|---|---|
| `FREEWAY_KEYS` | *empty* | **inference-only** `fw-` keys apps present (`/v1/*`). Empty = open, and the gateway then refuses to bind anything but loopback |
| `FREEWAY_ADMIN_KEY` | — | **full access** (`/api/*`): stored conversations, provider credentials, config. Never give this to an app |
| `FREEWAY_CORS_ORIGINS` | *empty* | browser origins allowed to call the gateway. `*` is rejected |
| `HOST` / `PORT` | `127.0.0.1` / `8787` | bind address |
| `FREEWAY_STRATEGY` | `priority` | routing strategy |
| `FREEWAY_ROOT` | cwd | base for the paths below |
| `FREEWAY_PROVIDERS` | `./providers` | provider JSON directory |
| `FREEWAY_DATA` | `./data` | quota counters, sessions, cache, pasted keys |
| `FREEWAY_CONFIG` | `./freeway.config.json` | config file |
| *provider keys* | — | see [.env.example](.env.example) |
A `.env` file in `FREEWAY_ROOT` is read at startup. A real environment variable
always wins over the file.
---
## Exposing it
Read **[SECURITY.md](SECURITY.md)** first. The short version:
```bash
FREEWAY_KEYS=fw-app-one,fw-app-two # one per app — inference only
FREEWAY_ADMIN_KEY=fw-admin-… # separate, never given to an app
```
Then put TLS in front — Freeway speaks plain HTTP by design.
Inference keys reach `/v1/*` and nothing else. `/api/*` reads stored
conversations and writes provider credentials, so it needs the admin key; with
no admin key set it is allowed on loopback and refused everywhere else. Binding
a public interface also switches on inbound rate limits automatically, and the
gateway flatly refuses to bind one with no keys at all.
---
## Development
CI runs on code changes only — a README or `demo/` commit skips it. `[skip ci]`
in a commit message skips it outright, and the arm64 image is only cross-built
for tagged releases, since emulating it is most of the Docker job.
```bash
./scripts/demo.sh # scripted walkthrough against your providers
node scripts/record-dashboard.mjs # re-record the dashboard GIF
npm run build # tsc --build, must be clean
npm test # node:test across all workspaces
npm run smoke # every route against mock upstreams — run this after adding a provider
npm run mock -- --port 9101 --name alpha # a fake provider you control
```
`scripts/mock-upstream.js` is the reason all of this is testable without
spending quota: scenario-scripted status codes, per-key behaviour, injectable
rate-limit headers, SSE, and a `POST /__control` plane to re-script it mid-test.
Working on this with an AI agent? Start it at [CLAUDE.md](CLAUDE.md).
**Zero runtime dependencies.** Dev deps are `typescript` and `@types/node`.
Everything else is `node:http`, `node:sqlite`, `fetch`, and `node:test`.
---
## Out of scope
No user accounts, billing, multi-tenancy, external database, React frontend, or
prompt templating. This is a personal gateway. If a users table appears,
something went wrong.