What it does
Most providers hand each key its own small rate limit: requests per minute, tokens per day. pool-anything gathers N keys for the same provider into one pool and does four things with them:
- Pool — collect keys under one named pool, per provider.
- Rotate — hand out keys round-robin so quota use spreads evenly.
- Proxy — forward your request with the right key injected, header or query param.
- Track — record tokens per key, rolled up per pool.
It ships with 37 preconfigured providers, a web UI for pools and keys, and a JSON API for everything else. Zero runtime dependencies: Node built-ins and one SQLite file.
Run it
You need Node.js 22 or newer (the app uses the built-in node:sqlite module and native fetch). Nothing else — there are no runtime npm dependencies.
npm install # dev deps only: tsx, typescript
npm run dev # http://localhost:3000, reloads as you edit
Check it's alive:
curl localhost:3000/health # → ok
curl localhost:3000/api/db/ping # → {"ok":true,"path":"…/data/pool-anything.db","items":0}
Your first pool
- Open localhost:3000 and search a provider — say, groq.
- Click it. The setup dialog creates a pool for that provider.
- Paste an API key, press Enter. Repeat for every key you own.
- Press Done. The pool now appears on Pools and in the API key manager, where you can view, edit, or remove any key.
- Send traffic through it:
curl -X POST localhost:3000/api/pools/1/proxy \
-H 'content-type: application/json' \
-d '{"path":"/openai/v1/models","method":"GET"}'
Scripts & environment
| Script | What it does |
|---|---|
npm run dev | Main server on :3000 with file watching. |
npm start | Main server once, no watch. |
npm run build | Type-check and compile to dist/. |
npm run start:dist | Run the compiled output. |
npm run db:init | Create the local database (-- --seed adds a sample row). |
npm run sandbox | Sandbox server on 127.0.0.1:4000 with watching. |
PORTMain server port, default 3000.SANDBOX_PORTSandbox port, default 4000.LOCAL_DB_PATHOverride where the SQLite file lives, default data/pool-anything.db.HTTP API
Base URL http://localhost:3000. Bodies and responses are JSON; errors are { "error": "…" } with a 4xx/5xx status. List endpoints always mask keys — only the single-key view returns a raw value. The API has no auth of its own; keep it local or put your gate in front.
Every provider in the registry, with base URL, auth style, quota notes, docs link, and logo.
All pools, newest first: id, provider, name, cursor, base_url, key_header, key_prefix, created_at.
Create a pool. provider and name are required; the three override fields fall back to the registry when empty.
{ "provider": "groq", "name": "groq pool",
"base_url": "", "key_header": "", "key_prefix": "" }
Pool summary with usage: key count, tokens used, quota and remaining (null unless the provider has a known token quota), plus a per-key breakdown.
{ "id": 1, "provider": "groq", "cursor": 12, "keys": 3,
"used": 1520, "quota": null, "remaining": null,
"perKey": [ { "id": 1, "label": "key 1", "used": 520 }, … ] }
Delete the pool with its keys and usage records.
Rotation endpoints
Return the next key, masked, and advance the cursor. 400 if the pool has no keys, 404 if it doesn't exist.
{ "key_id": 2, "label": "key 2", "masked": "gsk_…9f", "info": "" }
Rotate to the next key and record usage against it. tokens must be a positive integer.
{ "tokens": 250 }
→ { "key_id": 2, "label": "key 2", "masked": "gsk_…9f", "tokens": 250 }
Usage rollup: pool total, quota, remaining, and per-key sums.
The proxy
Pick the next key, inject its credentials, forward the request, record usage, report back. path is appended to the pool's base URL; method defaults to POST; headers and tokens are optional.
{ "path": "/chat/completions", "method": "POST",
"body": { … }, "tokens": 24 }
{ "key_id": 2, "label": "key 2", "masked": "gsk_…9f",
"status": 200, "body": "…" } // body capped at 4000 chars
How the key gets injected, from the provider's keyHeader:
| Shape | Injection | Seen on |
|---|---|---|
Authorization + prefix | Authorization: Bearer <key> | Groq, OpenAI, OpenRouter, Cohere |
| Plain header + prefix | <name><prefix><key> | x-api-key (Anthropic), xi-api-key (ElevenLabs), Token (Deepgram) |
query:<param> | appended as ?<param>=<key> | Mapbox, Google Maps, OpenWeather |
Provider extras (like anthropic-version) merge in automatically. Network failures return 502 { "error": "upstream unreachable" }; a pool with no base URL returns 400.
Key endpoints
List keys, masked: id, label, masked, info, created_at.
Add a key. Send label and api_key (plus optional info) → { "id": 7 }.
One key in full — the only endpoint that returns the raw value. The UI's View button uses it.
Update any of label, api_key, info. Omitted fields stay put; an empty key is rejected.
Remove a key from the pool → { "ok": true }.
Also answering
GET /health | Plain-text ok liveness probe. |
GET /api/db/ping | Database path and row count. |
GET /logos/<name> | Provider logos, one-hour cache. |
GET /pools · GET /keys | The pools overview and key manager pages. |
Project layout
Two HTTP servers, one SQLite file, one JSON registry. Both servers import the same core, so the sandbox behaves exactly like the main app.
pool-anything/
├── src/
│ ├── server.ts # UI pages + JSON API + proxy (:3000)
│ ├── pool/index.ts # schema, providers, rotation, usage
│ ├── pool/rotation.ts # cursor step
│ ├── proxy/ # forward + credential injection
│ ├── db/ # secondary table + db:init CLI
│ └── sandbox/ # localhost-only test server (:4000)
├── data/
│ ├── providers.json # the registry
│ └── pool-anything.db # gitignored
├── public/logos/ # provider logos
└── landing/ # this site: index.html + docs.html
Storage
One SQLite file through Node's built-in DatabaseSync — a local stand-in for Cloudflare D1. The schema creates itself at startup; extra columns arrive through guarded ALTER TABLE migrations.
CREATE TABLE pools (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL, -- registry id, e.g. 'groq'
name TEXT NOT NULL,
cursor INTEGER NOT NULL DEFAULT 0, -- rotation position
base_url TEXT NOT NULL DEFAULT '', -- custom-provider override
key_header TEXT NOT NULL DEFAULT 'X-API-Key',
key_prefix TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE pool_keys (
id, pool_id → pools, label,
api_key TEXT NOT NULL, -- stored plaintext
info TEXT NOT NULL DEFAULT '', created_at …
);
CREATE TABLE usage (
id, pool_id → pools, key_id → pool_keys,
tokens INTEGER NOT NULL, created_at …
);
Totals are computed on read with SUM() and joins — no counters to drift out of sync. The items table behind /api/db/ping is a separate demo table for exercising the D1 path; pools don't touch it.
Provider registry
data/providers.json is a flat array. Malformed rows are filtered out, and a small built-in fallback list applies if the file can't be read at all.
| Field | Meaning |
|---|---|
id · name | Registry key and display name. |
baseUrl | Upstream root; the proxy appends the caller's path. |
keyHeader · keyPrefix | Where the key goes — a header name plus optional prefix, or query:<param>. |
extraHeaders | Always-sent headers, like anthropic-version. |
quota | Human-readable free-tier notes, shown in the UI. |
keyFields | Input shape: one api_key, or several (Twilio: SID + token). |
hint · docsUrl | Where to get a key; the official docs link. |
poolable | true by default, false for control-plane only (Neon), "conditional" for Supabase and Appwrite. |
Adding a provider is one JSON row. Adding one at runtime is a pool with your own base_url, key_header, and key_prefix.
Security
- Keys are stored plaintext in SQLite; the file and its WAL siblings stay out of version control.
- List endpoints mask keys. Only GET /keys/:id returns a raw value.
- There is no auth on the API — run it on localhost or behind your own gate. The sandbox binds 127.0.0.1 only.
- Logo paths are validated against ^[a-z0-9-]+\.(svg|png)$; request bodies cap at 1 MB.
- Upstream responses pass through as text, capped at 4000 chars — treat them as untrusted.
pool-anything