One endpoint. Every model. Free tiers or paid SOTA — routing that learns.
Quick Start · Features · Using the API · How It Works · Bring Your Own Provider
Whether you're stacking free tiers from 18+ providers (~1.7 billion tokens per month across 100+ models) or routing paid premium models through a single intelligent endpoint — API-Gateway handles it the same way. Free or paid, one endpoint or twenty, the routing engine doesn't care. It learns which models deliver and routes accordingly.
The problem:
- Juggling dozens of SDKs, rate limits, and failure modes by hand
- One provider flakes and your app breaks
- Blow through a daily cap at 9 AM and discover it at noon
- Every key exhausted? Most gateways just give up and return an error
- No visibility into which model actually performed best for your workload
API-Gateway solves this:
- One OpenAI-compatible endpoint routes across every provider you configure — free tiers, paid APIs, or both mixed together
- An adaptive engine learns which models deliver and routes accordingly
- Per-key budget tracking before the request goes out — never blow through a cap again
- When every key is exhausted, it drops to recovery mode and self-heals — your app never sees an error
- A dashboard to manage keys, reorder your cascade, edit any model, and watch analytics in real time
| Feature | What it does | Why it matters |
|---|---|---|
| Adaptive routing engine | Scores every model on reliability × speed × intelligence using Thompson sampling. Five presets: balanced, smartest, fastest, most-reliable, or fully custom weights. Or manual priority. | The router gets smarter the more you use it. No static list to maintain. |
| Per-key budget tracking | Tracks RPM, RPD, TPM, TPD per (provider, model, key) before the request goes out. |
You never blow through a daily cap. The router always picks a key with capacity. |
| Self-healing key rotation | Three retries per key. When all keys are exhausted, drops to 1-RPM recovery mode — probing until one recovers. | Your app never sees an error, even when every key hits its daily limit. |
| Intelligent cascade | On 429, 5xx, or timeout: flat 90-second cooldown on the key, cascade to the next model. Cooldowns are per-key, not per-model. | One rate-limited key never benches the whole provider. Other keys stay available. |
| Error-class-aware failover | Classifies each upstream failure: context-window overflows skip models with a smaller window, content-policy refusals skip the model that refused. Other retryable errors cascade as before. | Failover that changes the outcome, not blind retries through the same wall. |
| Context-aware selection | Skips models with insufficient context window, no vision for images, or no tools for tool calls. | Your request never lands on a model that will mangle it. |
| Sticky sessions | Multi-turn conversations stay on the same model for 30 minutes. | No hallucination spike from mid-conversation model switches. |
| Context handoff | When a session must switch models, injects a compact system message so the new model knows it's continuing an existing task. | No "let me start over." Off by default; API_GATEWAY_CONTEXT_HANDOFF=on_model_switch. |
| Key health monitoring | Periodic probes mark keys healthy, rate-limited, invalid, or error. Successful requests promote keys back to healthy. | Dead keys are skipped automatically. The system recovers from transport hiccups without intervention. |
| Concurrency gating | Cap concurrent requests per provider. | A slow endpoint never starves faster ones of connection slots. |
| Encrypted key storage | AES-256-GCM before touching the database. Decryption in-memory at request time only. | Your provider keys never sit in plaintext. |
| Error redaction | Strips key fragments, account IDs, high-entropy tokens, and internal URLs from provider error responses. | Sensitive data never leaks to your client. |
| Per-client-key auth | Issue multiple dashboard-level client keys, each with its own spend cap and label. Apps authenticate with an api-gateway-... bearer token. |
Multi-app isolation without multi-tenant overhead. Each app gets its own key and budget. |
| Tool call repair | Automatic correction for JSON Schema mismatches and rescue for inline tool-call dialects. | Fewer broken tool loops. |
| Embeddings with family routing | /v1/embeddings routes by model family. Cascade only walks providers serving the same model. |
Never silently corrupts your vector store by switching embedding models. |
| Batch audio transcription | /v1/audio/transcriptions and /v1/audio/translations passthrough with family routing. Family-routed ASR catalog: ships with Groq and Mistral whisper/voxtral families; add providers per family in the dashboard or via config import. Client-key allowlists and $-budgets enforced like chat. |
Transcribe or translate audio through the same gateway, keys, and budgets as your chat traffic. |
| Full config export/import | One JSON file: models, cascade, providers, keys (optionally passphrase-encrypted), routing strategy, embeddings. Dry-run preview, atomic import with rollback. | Move your entire setup between machines safely. |
| Response caching | Exact-match cache for temp-0 requests. X-API-Gateway-No-Cache to bypass. |
Zero-latency repeat responses, zero upstream spend on identical requests. |
| Anthropic-format inbound | /v1/messages accepts Anthropic-format requests and translates to OpenAI internally. |
Use the Anthropic SDK directly without switching your endpoint. |
| Prometheus metrics | /metrics endpoint with request counts, latency histograms, routing stats. METRICS_AUTH_TOKEN for external scraping. |
Drop-in for Grafana / alerting without a sidecar. |
| Signed async webhooks | Register webhook receivers for routing events. HMAC-SHA256 signed payloads. | Build automations around key exhaustion, model switches, and routing decisions. |
| Request queueing | Per-provider concurrency queue with fair scheduling. Configurable via /api/queue. |
Burst traffic is queued, not dropped. Slow providers don't starve fast ones. |
| Circuit breaker | Per-provider circuit breaker with configurable thresholds. Open/half-open/closed states via /api/circuits. |
Failing providers are tripped automatically, reducing wasted upstream calls. |
| WebSocket Realtime API | /v1/realtime relays a session to a real upstream realtime websocket. Requires a realtime model in the catalog (/api/realtime), which is seeded empty because availability is per-provider-account. Without one the connection fails with no_realtime_model. |
Audio and streaming conversations through one gateway endpoint. |
| Spend-cap budgets | Set monthly spend limits per client key, or one global cap across all requests. /api/budgets for management. |
Never exceed your budget. Alerts and auto-cutoff when the cap is reached. |
| Tag/metadata filtering | X-API-Gateway-Tags header filters the routing chain to models with matching tags. |
Route to specific model subsets (e.g. "coding only", "fast only") per request. |
| TTFT-per-token routing | Scoring blends time-to-first-token and per-token latency, not just total time. Anti-herd randomization prevents all clients choosing the same model. | Better latency for streaming. Natural load distribution across providers. |
| Outbound Retry-After | On 503, the gateway sends Retry-After to the client and cascades to the next model simultaneously. |
Clients don't retry into the same failing provider. |
| Privacy Layer | Outbound redaction (known secrets → placeholders), AI interceptor (detects new secrets), prompt compression (SmartCrusher + TOON). All default OFF, per-direction. | Secrets never reach upstream providers. Compression cuts tool-output tokens 50-80%. |
What's not supported yet
- Image generation (
/v1/images/*) - Legacy completions (
/v1/completions) — only chat is implemented - Moderation (
/v1/moderations) n > 1(multiple completions per request)- Multi-operator RBAC — the gateway has one admin with per-client-key auth; multiple apps and clients can connect through it, but there's no multi-operator role-based access control
PRs welcome. See Contributing.
Prerequisites: Node.js 20+, npm.
git clone https://github.com/MLuqmanBR/api-gateway.git
cd api-gateway
npm install
cp .env.example .env
# Replace the placeholder encryption key with a real one
node -e "const c=require('crypto');const k=c.randomBytes(32).toString('hex');const fs=require('fs');let e=fs.readFileSync('.env','utf8');e=e.replace('ENCRYPTION_KEY=your-64-char-hex-key-here','ENCRYPTION_KEY='+k);fs.writeFileSync('.env',e);console.log('ENCRYPTION_KEY set to '+k)"
npm run devOpen http://localhost:5173 (the Vite dev UI), add your provider keys on the Keys page, reorder your model cascade to taste, and grab your unified API key from the Keys page header. That unified key is what you point your OpenAI SDK at.
Reaching the dev UI from another device on your LAN? Use
npm run dev:lan— it passes--hostthrough to Vite, which then prints aNetwork: http://<your-ip>:5173URL you can open from a phone or another machine. (Plainnpm run dev -- --hostdoes not work here: the rootdevscript is aconcurrentlywrapper, so the flag never reaches Vite.) API calls go through Vite's dev proxy, so no extra server config is needed.
For a production-like run (server + dashboard both served on :3001):
npm run build
node server/dist/index.jsAPI-Gateway ships a CLI for managing the server as a background process:
# Make the `api` command available in your shell:
# (npm install must be run first — the CLI needs tsx in node_modules)
npm link
# Start/stop/restart the server in the background:
api start
api stop
api restart
api status
api logsThe CLI auto-builds on api start if the build is missing, reads the port from .env, and rotates the server log (capped at 50 MiB with 3 archived copies). For a full command list run api help.
ENCRYPTION_KEY is required for startup. The server only falls back to a database-stored development key when NODE_ENV is not production; do not use that fallback with real provider keys.
Request analytics are retained for 90 days or 100000 request rows by default, whichever limit prunes first. Set REQUEST_ANALYTICS_RETENTION_DAYS=0 or REQUEST_ANALYTICS_MAX_ROWS=0 in .env to disable either retention limit.
Any OpenAI-compatible client works. Point it at http://localhost:3001/v1 and use your unified api-gateway-... key.
Python
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:3001/v1",
api_key="api-gateway-your-unified-key",
)
resp = client.chat.completions.create(
model="auto", # let the router pick; or pin e.g. "google/gemini-2.5-flash"
messages=[{"role": "user", "content": "Summarize the fall of Rome in one sentence."}],
)
print(resp.choices[0].message.content)
print("Routed via:", resp.headers.get("x-routed-via"))curl
curl http://localhost:3001/v1/chat/completions \
-H "Authorization: Bearer api-gateway-your-unified-key" \
-H "Content-Type: application/json" \
-d '{
"model": "auto",
"messages": [{"role": "user", "content": "hi"}]
}'Streaming
stream = client.chat.completions.create(
model="auto",
messages=[{"role": "user", "content": "Stream me a haiku about SQLite."}],
stream=True,
)
for chunk in stream:
print(chunk.choices[0].delta.content or "", end="", flush=True)Tool calling
Pass OpenAI-style tools and tool_choice; the assistant response round-trips back through the proxy exactly like the OpenAI API. Multi-step flows (assistant tool_calls → tool role follow-up → final answer) work across every provider the router can reach.
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a city.",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}]
# 1. Model asks for a tool call
first = client.chat.completions.create(
model="auto",
messages=[{"role": "user", "content": "What's the weather in Karachi?"}],
tools=tools,
tool_choice="required",
)
call = first.choices[0].message.tool_calls[0]
# 2. You execute the tool, feed the result back
final = client.chat.completions.create(
model="auto",
messages=[
{"role": "user", "content": "What's the weather in Karachi?"},
first.choices[0].message,
{"role": "tool", "tool_call_id": call.id, "content": '{"temp_c": 32, "cond": "sunny"}'},
],
tools=tools,
)
print(final.choices[0].message.content)Image, audio and video input
Send media with the standard OpenAI content blocks. Each model carries three independent input flags — image, audio, video — shown as an icon row on the cascade page and in the model edit dialog, where you can toggle them per model.
resp = client.chat.completions.create(
model="auto", # auto-routes to a model that accepts the media you sent
messages=[{
"role": "user",
"content": [
{"type": "text", "text": "What's in this image?"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,<...>"}},
],
}],
)
print(resp.choices[0].message.content)| Modality | Request block | Notes |
|---|---|---|
| Image | {"type":"image_url","image_url":{"url":"..."}} |
data: or http(s) URLs. |
| Audio | {"type":"input_audio","input_audio":{"data":"<base64>","format":"wav"}} |
<base64> is the raw payload, not a data URL. |
| Video | {"type":"video_url","video_url":{"url":"..."}} |
data: or http(s) URLs. Provider support is the narrowest. |
When a request contains media, the router restricts itself to models flagged for every modality it carries, and ignores models that cannot express them. If none is enabled you get a clear 422 naming the modality — no_vision_model (kept for compatibility with earlier releases), no_audio_model, or no_video_model. Pinning a model that cannot accept the media you sent returns 400 model_capability_mismatch rather than silently answering from a different model.
Pinning a model that is enabled in the catalog but not in the enabled fallback chain returns 400 model_not_routable. A pin names one specific model, so the router fails the request instead of quietly answering from another one — enable the model in the Fallback Chain, or send "model":"auto" (or omit it) to auto-route. Both 400 codes are returned by /v1/chat/completions and /v1/responses.
The per-model flags come from a generated index built offline from the public model catalogs (node scripts/gen-modalities.mjs), so they reflect what each model actually accepts rather than a name pattern. Platforms whose wire format cannot express a modality are forced to 0 (Cloudflare for all three, Cohere and CommandCode for audio/video, and Anthropic-format providers for audio/video). CommandCode accepts text and images only — its content union has no audio or video part. Edits you make in the dashboard set modalities_manual on that row and are never overwritten by the index.
Provider translation: OpenAI-compatible endpoints receive the blocks verbatim; Gemini receives inlineData parts (and fileData for YouTube URLs); Anthropic receives image/document blocks; Cohere receives image_url blocks. Requests are only routed to a provider that can express the media they carry.
/v1 accepts request bodies up to 64 MB so base64 media fits; /api/* keeps its 10 MB limit.
Every response carries an X-Routed-Via: <platform>/<model> header so you can see which provider actually served each call. The /v1/responses route also sets X-Fallback-Attempts: N when it cascaded between providers.
model accepts auto (or an omitted field) to let the router pick, or a strict <platform>/<model_id> pin — the same id form /v1/models returns, e.g. groq/llama-3.3-70b-versatile or nvidia/moonshotai/kimi-k2.6. The platform is the segment before the first slash, so model ids that themselves contain slashes stay intact. Anything else — a bare id, an unknown platform, a disabled model — is rejected with 400 model_not_found; nothing is silently re-routed. The OMP extension's api-gateway/ envelope (api-gateway/groq/llama-3.3-70b-versatile) is unwrapped once at ingress, so its advertised ids work unchanged.
A pinned request never falls through to a different model, and the dashboard and live terminal display every accepted spelling as one canonical platform/model_id.
/v1/embeddings is OpenAI-compatible, with one deliberate difference from chat routing: the cascade never crosses models. Vectors from different models live in incompatible spaces — silently switching models would corrupt any vector store built on top of the proxy. So embeddings route by family (one model identity + dimension), and the cascade only walks the providers serving that same family.
resp = client.embeddings.create(
model="auto", # default family; or a family name like "bge-m3"
input=["the quick brown fox", "pack my box with five dozen liquor jugs"],
)
print(len(resp.data), "vectors of", len(resp.data[0].embedding), "dims")curl http://localhost:3001/v1/embeddings \
-H "Authorization: Bearer api-gateway-your-unified-key" \
-H "Content-Type: application/json" \
-d '{"model": "auto", "input": "hello world"}'model accepts auto (the configured default family), a family name, or a provider-specific model id (which resolves to its family). Available families:
Family (model) |
Dims |
|---|---|
gemini-embedding-001 (default) |
3072 |
text-embedding-3-large |
3072 |
text-embedding-3-small |
1536 |
embed-v4.0 |
1536 |
bge-m3 |
1024 |
qwen3-embedding-0.6b |
1024 |
nv-embedqa-e5-v5 |
1024 |
llama-nemotron-embed-1b-v2 |
2048 |
llama-nemotron-embed-vl-1b-v2 |
2048 |
embeddinggemma-300m |
768 |
The default family, per-provider toggles, and priorities live on the dashboard's Models → Embeddings page.
Add any OpenAI-compatible or Anthropic-compatible HTTP endpoint as a provider — a paid SOTA API (frontier models from any lab), a local model server on your LAN, a free-tier cloud service, or anything in between. It gets the same adaptive routing, the same cascade behavior, the same per-key budget tracking as every built-in. From the dashboard's Keys page:
- Add Provider — slug, display name, base URL, API format (OpenAI or Anthropic), optional rate limits and concurrency caps.
- Auto-discovery — models are pulled from
/v1/modelson creation. Re-run anytime. Or register models manually. - Full model editing — every model (built-in or custom) is editable: ranks, tools/vision flags, context window, rate limits. Changes take effect immediately.
- Composite keys —
account_id:api_keyformat with{account_id}URL substitution for providers that need it. - Clean deletion — removing a custom provider cascades: drops all models, keys, and cascade entries.
One versioned JSON envelope exports your entire configuration: models, cascade order, providers, keys (optionally passphrase-encrypted under PBKDF2-SHA256-310k + AES-256-GCM), routing strategy, and embedding families. Import with dry-run preview and atomic rollback.
Config API & merge modes
Endpoints behind /api/config (gated by dashboard session):
| Endpoint | Purpose |
|---|---|
GET /api/config/inventory |
Row counts per exportable section |
POST /api/config/export |
Build an envelope. Body: { sections, passphrase, label, download } |
POST /api/config/preview |
Parse + validate without committing. Returns section counts and encryption status |
POST /api/config/import |
Apply an envelope. Body: { envelope, options: { mode, dryRun, passphrase, sections? } }. Single SQLite transaction, atomic rollback. |
Merge modes:
- skip-existing (default) — never touch rows that already exist. Safest.
- overwrite — update existing rows in place, insert the rest.
- replace — wipe the destination section, then insert from the envelope.
Every import supports dryRun: true — runs inside a SAVEPOINT, rolls back, returns a diff summary. Always dry-run first against a populated database.
flowchart LR
Client["Your app<br/>OpenAI SDK / curl / any client"] -->|"Bearer api-gateway-…"| Gateway["Gateway :3001"]
Gateway --> Router["Adaptive routing engine<br/>score · filter · pick best key"]
Router --> Middle["Middle layer<br/>redact → compress (opt-in)"]
Middle --> Providers["Provider connectors<br/>18 built-in + custom"]
Providers -->|"stream / JSON"| Middle
Middle -->|"un-redact"| Gateway
Providers -->|"429 / 5xx / timeout<br/>cascade to next"| Router
Gateway -->|"response"| Client
- Your app sends a request with a single
api-gateway-...bearer token. - The routing engine scores every enabled model (Thompson-sampling Beta posterior for reliability, blended with speed and intelligence), filters out models that can't handle the request, and picks the best key with capacity.
- Middle layer (opt-in): known secrets are redacted to placeholders before the request leaves; tool outputs are compressed via SmartCrusher/TOON. After the response, placeholders are restored to real values.
- On 429, 5xx, or timeout: cooldown the key, cascade to the next model. On total exhaustion: drop to 1-RPM recovery mode until a key recovers.
- The response streams back with
X-Routed-ViaandX-Attempted-Modelsheaders showing which providers served or were attempted.
Component map
| Subsystem | Source | Role |
|---|---|---|
| Adaptive routing engine | server/src/services/scoring.ts + router.ts |
Thompson sampling, context-aware selection, sticky sessions, concurrency gating |
| Per-key budget ledger | server/src/services/ratelimit.ts |
In-memory RPM/RPD/TPM/TPD counters backed by SQLite, cooldowns |
| Self-healing key rotation | server/src/services/key-exhaustion.ts |
3-retry per key, key cycling, 1-RPM recovery mode |
| Key health monitoring | server/src/services/health.ts |
Periodic probes, auto-promotion on success, reset on startup |
| Provider connectors | server/src/providers/*.ts |
One per built-in; custom providers resolved at request time |
| Context handoff | server/src/services/context-handoff.ts |
Session continuity on model switch, 3-hour TTL |
| Embeddings | server/src/services/embeddings.ts |
Family-routed, same-model cascade only |
| Encryption | server/src/lib/crypto.ts + error-redaction.ts |
AES-256-GCM key storage, provider error sanitization |
| Middle layer | server/src/middle/ |
Redaction (store, spans, session, interceptor), compression (SmartCrusher, TOON, eligibility) |
| Request queue | server/src/services/queue.ts |
Per-provider concurrency queue with fair scheduling |
| Circuit breaker | server/src/services/circuit-breaker.ts |
Per-provider circuit with open/half-open/closed states |
| Webhooks | server/src/services/webhooks.ts |
HMAC-SHA256 signed async event delivery |
| Metrics | server/src/services/metrics.ts |
Prometheus /metrics endpoint |
| Realtime | server/src/services/realtime.ts |
WebSocket /v1/realtime API server |
| Anthropic translate | server/src/routes/messages.ts + server/src/services/anthropic-translate.ts |
/v1/messages inbound translation |
| Response cache | server/src/services/cache.ts |
Exact-match cache for temp-0 requests |
| Budgets | server/src/services/budgets.ts + server/src/routes/budgets.ts |
Spend-cap budgets per client key |
| Dashboard | client/ |
React + Vite + shadcn/ui |
| Storage | SQLite (better-sqlite3) |
AES-256-GCM key encryption |
- Want to aggregate capacity across many providers behind one endpoint — free tiers, paid APIs, or both
- Are building an app and don't want to hardcode a single provider
- Want routing that adapts to real-world performance, not a static priority list
- Need your app to keep working when providers fail or rate-limit
- Want to mix free and paid models in one cascade (free for drafts, paid for hard problems)
Skip it if you:
- Need a managed cloud service — it's self-hosted only
- Need an admin dashboard for multiple operators — the gateway has one admin; multiple apps and clients can connect through it, but there's no multi-operator RBAC
npm install
npm run dev # server on :3001, dashboard on :5173, both with HMR
npm test # server vitest + client typecheck (tsc --noEmit) + client eslint
npm run build # compile server and dashboardPRs should include a test, keep the existing test suite green, and match the tsconfig defaults already in the repo. Issues and discussions are open.
This project is for personal experimentation and learning, not production. Free tiers exist so developers can prototype against them; they aren't a stable, supported inference substrate and shouldn't be treated as one. If you build something real on top of API-Gateway, swap in a paid API before you ship. Your relationship with each upstream provider is governed by the terms you accepted when you created your account — those terms still apply when the traffic is proxied through this project, and you're responsible for complying with them.
Built on tashfeenahmed/freellmapi. Maintained by MLuqmanBR.