Documentation
Run every client through one gate.
How to point clients at OrbioGate, what the key pool does when a key fails, every route it serves, and how to keep it private.
On this page
01 Quick start
Point the base URL at the gate
The gate serves the upstream /v1/* API at its own root. Change only the client's base URL.
claude CLI / Anthropic SDK
export ANTHROPIC_BASE_URL=http://127.0.0.1:8787
export ANTHROPIC_AUTH_TOKEN=<GATE_TOKEN or any placeholder>
export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
claudeOpenAI SDK / OpenAI-compatible tools
export OPENAI_BASE_URL=http://127.0.0.1:8787/v1
export OPENAI_API_KEY=<GATE_TOKEN or any placeholder>Plain HTTP
# free model (stealth models can vanish; re-check with: venv/bin/python -m orbiogate.cli models space-bunny)
curl -s http://127.0.0.1:8787/v1/chat/completions \
-H 'content-type: application/json' \
-d '{"model":"stealth/space-bunny-alpha","max_tokens":20,"messages":[{"role":"user","content":"Say OK"}]}'The client key is replaced
Whatever key the client sends is dropped and a key from the pool is put in its place, so the client-side
key can be any placeholder. When GATE_TOKEN is set, the client sends that token
as its key instead, and the gate still swaps it for a pool key before going upstream. Every operator response
carries x-orbiogate-key: <label>, the label of the key that served it; holder requests never
get that header. The orbio-style base http://127.0.0.1:8787/api works too.
02 Key pool & failover
Sticky until it fails
Keys come from keys.json; their order is the failover order. Requests stick to the current key
until it fails. Then that key is cooled, the same request is retried on the next healthy key, and the gate
stays on the new key even after the old one recovers. The sticky label and all cooldowns are stored in SQLite
and survive restarts.
Cooldown matrix
| Upstream answer | Class | Cooldown | Then |
|---|---|---|---|
402, or a body containing “balance cannot cover” | balance | 30 min | retried on the next key in the same request |
401, “Not logged in”, or an invalid API key | auth (shown as dead) | 24 h | retried on the next key in the same request |
429 | rate_limit | 60 s | retried on the next key in the same request |
5xx, or a network error | upstream | 5 min | retried on the next key in the same request |
any other 4xx | none | none | relayed to the client as-is; the key stays sticky |
When every key is cooling
A request that finds no healthy key makes no upstream call. The client gets the last upstream error
verbatim (status, body, content-type) plus x-orbiogate: all-keys-cooling and a
retry-after with the seconds until the next key comes back. If every key fails during one request,
the last upstream answer is relayed with x-orbiogate: all-keys-failed. The gate only makes up its
own 502 when the last failure was a pure network error, so there is no upstream body to relay.
Getting a key back early
The balance poller runs every 10 minutes; a balance that went up clears a balance cooldown. To force it, call
POST /admin/keys/refresh, or clear cooldowns directly with POST /admin/keys/reset.
03 Endpoints
Every route the gate serves
token needs Authorization: Bearer <GATE_TOKEN> or x-api-key: <GATE_TOKEN>
when GATE_TOKEN is set, and is open when it is not. holder key means a holder's og_… key
works there too, inside the holder limits, once holder access is on. open never needs a token and carries
labels only.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| Public Pages, assets and status: never need a token and carry labels only. | |||
| GET | / | This site's landing page | open |
| GET | /dashboard | Live dashboard; asks for GATE_TOKEN only when the gate requires it | open |
| GET | /access | Holder access: check a wallet, sign, get a key | open |
| GET | /docs | This page | open |
| GET | /health | Gate status, key count, available keys, sticky label, uptime, version | open |
| GET | /api/health | Public alias of /health — same JSON, no auth | open |
| GET | /assets/site.css | Shared stylesheet for every page (cached, versioned) | open |
| GET | /assets/og.png | Social preview image, 1200×630 (cached) | open |
| Operator The API and the admin routes: GATE_TOKEN when it is set. Holder keys open the API rows only. | |||
| POST | /v1/messages | Anthropic Messages pass-through (SSE streamed) | token holder key |
| POST | /v1/chat/completions | OpenAI chat completions pass-through (SSE streamed) | token holder key |
| GET | /v1/models | Model catalog from upstream, cached 10 min | token holder key |
| ANY | /v1/* | Any other upstream route, forwarded as-is | token holder key |
| ANY | /api/v1/* | Alias of /v1/* for orbio-style …/api base URLs | token holder key |
| GET | /admin/keys | Per key: status, cooldown, balance, usage, requests today (labels only) | token |
| GET | /admin/keys/history | Balance history per key, bucketed to about 40 points, newest last; ?hours= (default 24, max 720) | token |
| POST | /admin/keys/refresh | Poll every balance now; a balance rise clears a balance cooldown | token |
| POST | /admin/keys/reset | Clear cooldowns: ?label=<label> for one key, none for all | token |
| POST | /admin/keys/reload | Re-read keys.json without a restart | token |
| GET | /admin/usage | Requests, errors and est. USD per key and model; ?hours= (default 24, max 720) | token |
| GET | /admin/requests | Newest requests first; ?limit= (default 30, max 200) | token |
| GET | /admin/holders | Holder wallets (truncated): status, balance and its age, requests and est. USD today, key fingerprint; plus the runway estimate | token |
| POST | /admin/holders/refresh | Re-read every holder balance and the runway now | token |
| Holder Holder access, off while HOLDER_TOKEN is empty: public and rate-limited per IP. | |||
| POST | /gate/nonce | Holder access: a single-use message to sign for {"address"} (10 min, 5 per minute per IP) | open, rate-limited |
| POST | /gate/claim | Holder access: {"address", "signature"} → the wallet's API key | open, rate-limited |
| GET | /gate/eligibility | Holder access: ?address= → eligible, balance, minimum (read-only) | open, rate-limited |
Any other path outside /v1/, /api/, /admin/ and /gate/ gets the
styled 404 page; API paths keep their JSON errors.
04 Holder access
Hold the token, get a key
Holders of the operator's ERC-20 token on Robinhood Chain (chain ID 4663) can claim their own API key for this
gate on the access page. Upstream costs are paid by the operator. The feature is
off while HOLDER_TOKEN is empty: the access page then shows a “coming soon” state,
the /gate/* routes answer 404 holder_access_off, and authentication works exactly as
described above.
The flow
- Check (optional).
GET /gate/eligibility?address=0x…readsbalanceOfon chain and returns{"eligible", "balance", "minimum"}. No signature; 20 checks per minute per IP. - Nonce.
POST /gate/noncewith{"address": "0x…"}returns a message that contains the wallet, the chain ID and a fresh nonce. The nonce is bound to that wallet, works once and expires after 10 minutes; each IP gets 5 per minute. - Sign. The wallet signs the message exactly as given with
personal_sign(EIP-191). That is free: no transaction, no gas, nothing approved. Any tool with a “sign message” function works (a browser wallet, MyEtherWallet,cast wallet sign). - Claim.
POST /gate/claimwith{"address", "signature"}(plus the optional"nonce"). The gate checks the signature and the nonce, reads the balance and, if it is at or above the minimum, returns{"api_key": "og_…"}. The page shows the key once; the gate stores only its SHA-256 hash. - Use. Send the key like any API key:
Authorization: Bearer og_…orx-api-key: og_…, with the same base URLs as in the quick start. Holder keys work on/v1/*and/api/v1/*only, never on/admin/*.
Rules
- Same key every time. The key is derived from the wallet with HMAC-SHA256 and the
server's
SERVER_SECRET, so a lost key is recovered by claiming again. ChangingSERVER_SECRETchanges every holder key; holders then claim again. - Revocation on sell. The balance is re-read at most every
HOLDER_CHECK_TTLseconds per wallet, both on use and in the background. Below the minimum, the key answers403 {"error": {"type": "holder_balance"}}; back above it, the key works again. If the chain RPC fails, the last known state is kept and the read is retried in the next window. An RPC failure never blocks operator traffic. - Rate limit.
HOLDER_RATE_PER_MINrequests per wallet over a sliding 60-second window, then429 holder_rate_limitwithretry-after. - Daily spend cap.
HOLDER_DAILY_CAP_USDof estimated upstream spend per wallet per UTC day, then429 holder_daily_capwith the reset time (00:00 UTC). The estimate is the larger of two numbers: the operator ledger's method (balanceusagedeltas split by response bytes), and the sum ofusage.costthat upstream reports on each response. A request already in flight can finish above the cap. - A minimum of 0 still requires a non-zero balance: a wallet without any tokens is not a holder.
- Same pool. Holder requests use the operator's key pool and failover. Holders never see pool key labels.
Config (.env, all optional)
| Name | Default | Meaning |
|---|---|---|
HOLDER_TOKEN | empty | Token contract address; empty keeps holder access off |
HOLDER_RPC | https://robinhood-rpc.publicnode.com | JSON-RPC endpoint for balanceOf |
HOLDER_CHAIN_ID | 4663 | Checked against the RPC's eth_chainId; also part of the signed message |
HOLDER_MIN_BALANCE | 0 | Minimum balance in whole tokens (0 = any non-zero balance) |
HOLDER_DECIMALS | 18 | Token decimals |
HOLDER_DAILY_CAP_USD | 0.5 | Per-wallet spend cap per UTC day (0 = no cap) |
HOLDER_RATE_PER_MIN | 10 | Per-wallet requests per minute (0 = no limit) |
HOLDER_CHECK_TTL | 300 | Seconds between on-chain balance re-checks per wallet |
SERVER_SECRET | generated | HMAC secret for holder keys; appended to .env (mode 600) on first boot with holder access on |
OPS_WALLET | empty | Wallet that receives dev fees; its ETH balance is included in runway alerts |
Runway watch
With holder access on, the gate estimates daily burn every hour: spend over the last 7 days (operator and
holders together), divided by the days of history actually covered (1 to 7). If the pool balance lasts fewer
than 3 days at that rate, Telegram gets “gate runway: N days — top up” (at most once per hour, and only when
Telegram is configured). GET /admin/holders shows the latest estimate.
05 Security
Keep the gate private
- What GATE_TOKEN protects.
/v1/*,/api/v1/*and/admin/*. The pages,/healthand/assets/*stay open; they carry key labels only, never key values. With holder access on,og_…holder keys also open/v1/*and/api/v1/*(never/admin/*), and/gate/*is public and rate-limited per IP. - Where it is stored. On the server, in
.envasGATE_TOKEN=…. In the browser, the dashboard stores it inlocalStorageand sends it only as anAuthorizationheader to this same origin. “Forget saved token” in the dashboard clears it. The landing page and these docs never ask for it. - Pasting a dashed token. The dashboard strips dashes and whitespace from what you paste,
so a token copied in chunks (
a1b2-c3d4-…) still works. Generate the token as hex (openssl rand -hex 32): a token that really contains dashes will not work from the dashboard. - Loopback binding.
GATE_HOSTdefaults to127.0.0.1, so only processes on this machine can reach the gate. Bound anywhere else without a token, it logs a warning at startup. - Never run the gate publicly without GATE_TOKEN. An open
/v1/*is a free proxy on your paid keys. For remote access use an SSH tunnel (ssh -L 8787:127.0.0.1:8787 <host>), or TLS in front with GATE_TOKEN set first. - What the pages load. A content security policy limits every page to this origin plus the Google Fonts stylesheet and font files. Without them the pages fall back to local fonts.
Your GATE_TOKEN is kept only in this browser's localStorage and is sent only to this gate; no page ever receives or shows a key value.
06 Troubleshooting
When something looks wrong
- The claude CLI gets 404s
- Use
ANTHROPIC_BASE_URL=http://127.0.0.1:8787(the/apisuffix also works). - Everything returns 401 from the gate
- GATE_TOKEN is set. Give it to the client as its API key or auth token.
- A stream arrives all at once
- Something between the client and the gate is buffering. Talk to uvicorn directly.
- Spend looks odd
- orbio reserves the cost upfront (
limitdips, then recovers). Estimates useusagedeltas, which only go up. - A key is stuck “cooling” after a top-up
POST /admin/keys/refresh(a balance rise clears a balance cooldown) orPOST /admin/keys/reset?label=<label>.