Endpoint catalog
LIVE — six routes. Two strings is the whole integration:
Base URL https://thering.lol/api/frodo/v1
API key frodo_sk_…No extension, no SDK of ours, nothing to install. If a tool speaks the OpenAI protocol, it speaks this.
#Routes
| Route | Method | Shape | Auth | OK | Fails with |
|---|---|---|---|---|---|
/api/frodo/v1/chat/completions |
POST |
OpenAI | Authorization: Bearer · x-api-key |
200 streamed |
400 401 403 405 413 429 502 503 |
/api/frodo/v1/messages |
POST |
Anthropic | x-api-key · Authorization: Bearer |
200 streamed |
same |
/api/frodo/v1/models |
GET |
OpenAI | optional — validated when sent | 200 |
401 403 405 |
/api/frodo/key/create |
POST |
— | wallet signature | 201 |
401 403 429 503 |
/api/frodo/key/rotate |
POST |
— | key + wallet signature | 201 |
401 403 429 503 |
/api/frodo/key/revoke |
POST |
— | key + wallet signature | 200 |
401 403 405 |
Every route answers OPTIONS with 204 and CORS headers allowing authorization, content-type,
x-api-key and anthropic-version. Preflight used to hit the method check and get 405, which
broke every browser-based client.
#Inference
curl https://thering.lol/api/frodo/v1/chat/completions \
-H "Authorization: Bearer frodo_sk_…" \
-H "content-type: application/json" \
-d '{"model":"<id from /models>","messages":[{"role":"user","content":"say ok"}]}'bashcurl https://thering.lol/api/frodo/v1/messages \
-H "x-api-key: frodo_sk_…" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{"model":"<id>","max_tokens":64,"messages":[{"role":"user","content":"say ok"}]}'bash#Streaming
Set "stream": true and the response is Server-Sent Events, in whichever protocol's shape you
asked for. Token-by-token stays token-by-token: the upstream body is piped through with
backpressure respected (proxy.js:172-190), not buffered and
re-emitted, so the first token reaches you as soon as it exists.
curl -N https://thering.lol/api/frodo/v1/chat/completions \
-H "Authorization: Bearer frodo_sk_…" \
-H "content-type: application/json" \
-d '{"model":"frodo-frontier","stream":true,"messages":[{"role":"user","content":"count to 3"}]}'bashdata: {"choices":[{"delta":{"content":"1"}}]}
data: {"choices":[{"delta":{"content":", 2"}}]}
data: [DONE]Hanging up stops the spend. Close the connection and the gateway aborts upstream rather than
letting an abandoned generation bill to the end — the same AbortController the 120s deadline uses.
That is the one thing worth knowing that most proxies get wrong: a cancelled read on your side has
to become a cancelled request on ours, or every timeout you set costs full price anyway.
Errors during a stream arrive as an SSE frame, not as a status code — the status was already sent
with the headers. Parse for an error key on each frame if you need to distinguish a truncated
answer from a finished one.
#Discovery
curl https://thering.lol/api/frodo/v1/models -H "Authorization: Bearer frodo_sk_…"bash{
"object": "list",
"data": [{ "id": "frodo-frontier", "object": "model", "created": 1704067200, "owned_by": "frodo" }],
// outside the OpenAI shape — clients ignore what they don't recognise
"enabled": true,
"store": "durable", // "memory" means the per-instance fallback: not durable
"max_output_tokens": 4096
}jsoncThe ids here are modes, never upstream model ids — see the model stack.
Discovery stays open so a client can read the allowlist before it holds a key. But when a key
is presented it is verified — several editors validate a pasted credential by calling this route,
and answering 200 to a bad key tells the user it works and then fails on the first message.
#Key management
create mints once the unlock is confirmed on chain, and refuses otherwise — there is no
HTTP override and there should never be one. See
the key lifecycle.
revoke requires the key and a signature from the wallet it was minted to: a leaked key alone
cannot lock its owner out, and a wallet alone cannot revoke a key it does not hold.
rotate swaps a key for a fresh one and retires the old one in the same request, so a rotation
never leaves two live keys behind. It deliberately does not re-check the unlock — the holder
already earned the key, and a market cap that later fell should not strand them
(rotate.js:13-19).
#The error envelope
Errors use OpenAI's shape, because that is what every client here parses:
{ "error": { "message": "That model is not available through FRODO.", "type": "permission_error", "code": null } }json| Status | type |
Means | Retry? |
|---|---|---|---|
400 |
invalid_request_error |
Body is not valid JSON | no — fix the body |
401 |
authentication_error |
Missing or unknown key | no — fix the key |
403 |
permission_error |
Key revoked · mode not served · wallet does not own the key | no — costs no quota either way |
405 |
invalid_request_error |
Wrong method | no |
413 |
invalid_request_error |
Body over 1 MB — refused, not truncated | no — send less |
429 |
rate_limit_error |
Quota | yes — wait out Retry-After: 60 |
502 |
api_error |
Upstream unavailable. Deliberately opaque: a detailed message could leak the proxy URL | yes — exponential backoff |
503 |
api_error |
Gateway disabled, unconfigured, or at global capacity | maybe — capacity clears, a disabled gateway does not |
Only the 4xx that mention quota are worth retrying. Everything else in the 4xx column is a
statement about your request, and sending it again unchanged produces the same answer while burning
whatever budget the retry loop has. Back off on 429, 502 and 503; fix and resend the rest.
A refusal costs you nothing. The allowlist is checked before the quota
(proxy.js:118-127), so a 403 for a mode we do not serve does
not spend a request from your day. Neither does a 401, a 400 or a 413 — nothing is charged
until a request is actually forwarded.
error used to be a bare string. Every client below reads error.message, so all of them rendered
undefined and swallowed the reason. Map: proxy.js:14-22.
#What passes through
The gateway forwards your request body wholesale and clamps exactly four things
(clampCost). Everything else the chosen model supports reaches it
unchanged — so this is a compatibility list, not a feature list we had to build.
| Streaming (SSE) | LIVE | both wire formats |
| Tool / function calling | LIVE | forwarded as sent; support is the model's |
| Structured outputs · JSON mode | LIVE | same |
| System messages | LIVE | untouched — and on frodo-raw, nothing is added to them |
| Vision · image input | LIVE | same |
| Multi-turn, stop sequences, temperature, seed | LIVE | same |
n · best_of |
clamped to 1 | they bill per completion |
max_tokens |
clamped to 4096 | clamped, not rejected |
| Embeddings · images · audio · video | not routed | this gateway is chat completions |
The rule is simple: if the model can do it, your key can do it. The only things that do not arrive as you sent them are the four cost multipliers above, and each is clamped rather than refused, so a request never fails for being ambitious.
#Coming to this surface DESIGNED
Documented here because clients are built against a surface, not against a changelog. Each of these extends something the gateway already computes — see the build for the hook.
x-ratelimit-limit · -remaining · -reset |
DESIGNED | on every response; checkQuota already derives all three |
GET /api/frodo/v1/limits |
DESIGNED | read your ceilings without spending a request |
usage on every response |
DESIGNED | tokens in, tokens out, and what it cost your day |
Mode variants — :fast · :code |
DESIGNED | modes |
GET /api/frodo/v1/attestation |
PLANNED | nonce-bound enclave proof — L3 |
Until a row here says LIVE, the route returns 404 and the header is absent. Nothing on this page
is stubbed to look implemented.
#Limits
| Default | |
|---|---|
| Requests / minute / key | 20 |
| Requests / day / key | 500 |
| Max output tokens | 4096 — requests above are clamped, not rejected |
n · best_of |
clamped to 1 — they bill per completion |
| Request body | 1 MB → 413 |
| Upstream deadline | 120 s |
| Keys per wallet | 1 live — re-minting revokes the previous |
| Mints / wallet / day · / network / day · global | 3 · 5 · 500 |
Every value is an env var (env.example). They are low because all FRODO keys
spend from one funded workspace — see the trust boundary.
#Where the key works
| Client | How | Notes | |
|---|---|---|---|
| Cursor | Settings → Models → OpenAI API Key + Override Base URL | Tab autocomplete and Ctrl+K stay on Cursor's own backend regardless — that is not your key failing | |
| VS Code (Copilot BYOK) | Chat: Manage Language Models → OpenAI Compatible | BYOK covers chat, not inline ghost-text completions | |
| OpenCode | opencode.json → @ai-sdk/openai-compatible provider |
Keep name set, or baseURL/apiKey may not be forwarded |
|
| Any OpenAI SDK | base_url= + api_key= |
Python, JS, Go — the protocol is the integration | |
| Antigravity | ❌ not possible | No BYOK setting; it talks only to Gemini. The MITM workarounds want the Gemini wire format and carry account-ban warnings |
Full setup per editor, with sources and the failure modes worth knowing before you judge it: CLIENTS.md.
#The FRODO model stack
One key, three modes. A caller names a mode. Which model answers it is decided inside the gateway and is never returned, logged, or published.
| Mode | What it is | |
|---|---|---|
frodo-frontier |
FRONTIER | Maximum intelligence — the major labs' top models, routed. |
frodo-raw |
RAW | No hidden system prompt. No forced personality. |
frodo-private |
PRIVATE | No prompt logs. Zero-retention routing. |
These are the only model ids the product publishes. They are not weights of ours and nothing here claims they are: a mode is a routing profile over capacity the key already reaches. The candidate models behind RAW and PRIVATE are listed on the marketplace as a shortlist to benchmark, not as a menu.
curl https://thering.lol/api/frodo/v1/chat/completions \
-H "Authorization: Bearer frodo_sk_…" \
-H "content-type: application/json" \
-d '{"model":"frodo-frontier","messages":[{"role":"user","content":"say ok"}]}'bashIn OpenCode that is one line:
"models": { "frodo-frontier": { "name": "FRODO Frontier" } }jsonc#How the two layers fit
A mode is served only when it is mapped to a model and that model is on the allowlist. A
half-configured mode is invisible rather than a 502 waiting for the first caller to find it.
| Set by | Public? | |
|---|---|---|
FRODO_MODE_FRONTIER · _RAW · _PRIVATE |
the model each mode resolves to | no — the value never leaves the function |
FRODO_ALLOWED_MODELS |
exact-string allowlist of upstream ids | no — /v1/models publishes modes, not this |
frodo-frontier · frodo-raw · frodo-private |
derived from the two above | yes — this is the whole public surface |
Leaving FRODO_MODE_RAW and FRODO_MODE_PRIVATE unset is exactly how RAW and PRIVATE stay shut
until P1 and P2. There is no second switch to forget, and no way to call a mode that is not
mapped — it is a 403, and it costs the caller no quota.
The allowlist is still an exact-string gate, not a filter. An id we map that upstream does not
have is a 502; an id upstream has that we do not allow is a 403 from us — which is the whole
point. Verified on 2026-08-30: openai/gpt-5.5-pro is in the upstream catalog and the gateway
still refused it, so this is a real gate rather than a pass-through wearing one.
Resolution: resolveModel · serveableModes ·
publication: models.js ·
tests: node scripts/test-gateway.mjs asserts that /v1/models publishes no upstream id.