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
claude

OpenAI 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 answerClassCooldownThen
402, or a body containing “balance cannot cover”balance30 minretried on the next key in the same request
401, “Not logged in”, or an invalid API keyauth (shown as dead)24 hretried on the next key in the same request
429rate_limit60 sretried on the next key in the same request
5xx, or a network errorupstream5 minretried on the next key in the same request
any other 4xxnonenonerelayed 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.

MethodPathPurposeAuth
Public Pages, assets and status: never need a token and carry labels only.
GET/This site's landing pageopen
GET/dashboardLive dashboard; asks for GATE_TOKEN only when the gate requires itopen
GET/accessHolder access: check a wallet, sign, get a keyopen
GET/docsThis pageopen
GET/healthGate status, key count, available keys, sticky label, uptime, versionopen
GET/api/healthPublic alias of /health — same JSON, no authopen
GET/assets/site.cssShared stylesheet for every page (cached, versioned)open
GET/assets/og.pngSocial 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/messagesAnthropic Messages pass-through (SSE streamed)token holder key
POST/v1/chat/completionsOpenAI chat completions pass-through (SSE streamed)token holder key
GET/v1/modelsModel catalog from upstream, cached 10 mintoken holder key
ANY/v1/*Any other upstream route, forwarded as-istoken holder key
ANY/api/v1/*Alias of /v1/* for orbio-style …/api base URLstoken holder key
GET/admin/keysPer key: status, cooldown, balance, usage, requests today (labels only)token
GET/admin/keys/historyBalance history per key, bucketed to about 40 points, newest last; ?hours= (default 24, max 720)token
POST/admin/keys/refreshPoll every balance now; a balance rise clears a balance cooldowntoken
POST/admin/keys/resetClear cooldowns: ?label=<label> for one key, none for alltoken
POST/admin/keys/reloadRe-read keys.json without a restarttoken
GET/admin/usageRequests, errors and est. USD per key and model; ?hours= (default 24, max 720)token
GET/admin/requestsNewest requests first; ?limit= (default 30, max 200)token
GET/admin/holdersHolder wallets (truncated): status, balance and its age, requests and est. USD today, key fingerprint; plus the runway estimatetoken
POST/admin/holders/refreshRe-read every holder balance and the runway nowtoken
Holder Holder access, off while HOLDER_TOKEN is empty: public and rate-limited per IP.
POST/gate/nonceHolder access: a single-use message to sign for {"address"} (10 min, 5 per minute per IP)open, rate-limited
POST/gate/claimHolder access: {"address", "signature"} → the wallet's API keyopen, rate-limited
GET/gate/eligibilityHolder 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… reads balanceOf on chain and returns {"eligible", "balance", "minimum"}. No signature; 20 checks per minute per IP.
  • Nonce. POST /gate/nonce with {"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/claim with {"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_… or x-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. Changing SERVER_SECRET changes every holder key; holders then claim again.
  • Revocation on sell. The balance is re-read at most every HOLDER_CHECK_TTL seconds per wallet, both on use and in the background. Below the minimum, the key answers 403 {"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_MIN requests per wallet over a sliding 60-second window, then 429 holder_rate_limit with retry-after.
  • Daily spend cap. HOLDER_DAILY_CAP_USD of estimated upstream spend per wallet per UTC day, then 429 holder_daily_cap with the reset time (00:00 UTC). The estimate is the larger of two numbers: the operator ledger's method (balance usage deltas split by response bytes), and the sum of usage.cost that 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)

NameDefaultMeaning
HOLDER_TOKENemptyToken contract address; empty keeps holder access off
HOLDER_RPChttps://robinhood-rpc.publicnode.comJSON-RPC endpoint for balanceOf
HOLDER_CHAIN_ID4663Checked against the RPC's eth_chainId; also part of the signed message
HOLDER_MIN_BALANCE0Minimum balance in whole tokens (0 = any non-zero balance)
HOLDER_DECIMALS18Token decimals
HOLDER_DAILY_CAP_USD0.5Per-wallet spend cap per UTC day (0 = no cap)
HOLDER_RATE_PER_MIN10Per-wallet requests per minute (0 = no limit)
HOLDER_CHECK_TTL300Seconds between on-chain balance re-checks per wallet
SERVER_SECRETgeneratedHMAC secret for holder keys; appended to .env (mode 600) on first boot with holder access on
OPS_WALLETemptyWallet 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, /health and /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 .env as GATE_TOKEN=…. In the browser, the dashboard stores it in localStorage and sends it only as an Authorization header 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_HOST defaults to 127.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 /api suffix 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 (limit dips, then recovers). Estimates use usage deltas, which only go up.
A key is stuck “cooling” after a top-up
POST /admin/keys/refresh (a balance rise clears a balance cooldown) or POST /admin/keys/reset?label=<label>.