pool-anything

Docs

Everything the pool does, how to run it, and every endpoint it answers to. Written for the version in this repository.

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.

New here? Run it below, search a provider on localhost:3000, paste a couple of keys, then send one request through /proxy. Five minutes, start to finish.

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

  1. Open localhost:3000 and search a provider — say, groq.
  2. Click it. The setup dialog creates a pool for that provider.
  3. Paste an API key, press Enter. Repeat for every key you own.
  4. Press Done. The pool now appears on Pools and in the API key manager, where you can view, edit, or remove any key.
  5. 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"}'
Watch it turn: every proxy call returns the key_id and label that served it, so you can see keys take turns. Add "tokens": N to count usage against that key too.

Scripts & environment

ScriptWhat it does
npm run devMain server on :3000 with file watching.
npm startMain server once, no watch.
npm run buildType-check and compile to dist/.
npm run start:distRun the compiled output.
npm run db:initCreate the local database (-- --seed adds a sample row).
npm run sandboxSandbox 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.
Sandbox: npm run sandbox then open 127.0.0.1:4000/ui — a minimal UI on the same database for exercising rotation without touching the main app. It binds to localhost only.

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.

GET/api/providers

Every provider in the registry, with base URL, auth style, quota notes, docs link, and logo.

GET/api/pools

All pools, newest first: id, provider, name, cursor, base_url, key_header, key_prefix, created_at.

POST/api/pools

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": "" }
GET/api/pools/:id

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/api/pools/:id

Delete the pool with its keys and usage records.

Rotation endpoints

GET/api/pools/:id/next

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": "" }
POST/api/pools/:id/consume

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 }
GET/api/pools/:id/usage

Usage rollup: pool total, quota, remaining, and per-key sums.

The proxy

POST/api/pools/:id/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:

ShapeInjectionSeen on
Authorization + prefixAuthorization: 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

GET/api/pools/:id/keys

List keys, masked: id, label, masked, info, created_at.

POST/api/pools/:id/keys

Add a key. Send label and api_key (plus optional info) → { "id": 7 }.

GET/api/pools/:id/keys/:keyId

One key in full — the only endpoint that returns the raw value. The UI's View button uses it.

PATCH/api/pools/:id/keys/:keyId

Update any of label, api_key, info. Omitted fields stay put; an empty key is rejected.

DELETE/api/pools/:id/keys/:keyId

Remove a key from the pool → { "ok": true }.

Also answering

GET /healthPlain-text ok liveness probe.
GET /api/db/pingDatabase path and row count.
GET /logos/<name>Provider logos, one-hour cache.
GET /pools · GET /keysThe 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.

FieldMeaning
id · nameRegistry key and display name.
baseUrlUpstream root; the proxy appends the caller's path.
keyHeader · keyPrefixWhere the key goes — a header name plus optional prefix, or query:<param>.
extraHeadersAlways-sent headers, like anthropic-version.
quotaHuman-readable free-tier notes, shown in the UI.
keyFieldsInput shape: one api_key, or several (Twilio: SID + token).
hint · docsUrlWhere to get a key; the official docs link.
poolabletrue 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.