API.
The two client dialects the pool speaks on its public listener — OpenAI's /v1 and Ollama's /api — plus the control-plane /status document the CLI reads over the unix socket.
Public routes (RELAY_LISTEN)
The public mux composes three families of routes plus a health check: the OpenAI-compatible /v1 surface, the Ollama-compatible /api surface, and the legacy plaintext node routes. Every inference route on both client dialects sits behind the same bearer-key gate.
| Route | Key required | Notes |
|---|---|---|
GET /v1/models | Yes | List registered models, OpenAI shape plus context_length and the pool's own state/fits fields. |
POST /v1/chat/completions | Yes | Streaming (SSE) and non-streaming, proxied byte-for-byte to llama-server. |
POST /v1/completions | Yes | Same proxy path as chat/completions. |
POST /v1/embeddings | Yes | Same proxy path; useful when a registered model is an embedding model. |
GET /api/tags | Yes | Ollama's model list. Only models that currently fit the pool are listed. |
POST /api/chat | Yes | Ollama's chat shape: NDJSON out, and the options map (num_predict, temperature, stop) is honoured. |
POST /api/generate | Yes | Ollama's prompt-completion shape, same translation path. |
GET /healthz | No | Daemon liveness, {"status":"ok"}. |
POST /api/nodes/enroll | No | Redeem a join token for a node credential. Unauthenticated by construction, so rate-limited instead. |
GET /api/nodes/tunnel | No | Node tunnel upgrade; authenticated by the node credential or client certificate inside the handshake. |
RELAY_NODE_LISTEN, a separate mutual-TLS port bound on every relay serve. Setting RELAY_REQUIRE_MTLS unmounts the two plaintext node routes above — they then answer 410 mtls_required naming the TLS listener rather than 404ing — and leaves the inference surface untouched. See Security and Configuration. Transport
RELAY_V1_TLS defaults to auto, which resolves to TLS unless RELAY_LISTEN is loopback-only. An empty host — :8383, the default — means every interface, which is not loopback, so a stock hub serves /v1 over TLS with its own CA. Plain HTTP happens only on an explicitly loopback bind or with RELAY_V1_TLS=off. A client configured for http:// against a default deployment does not connect.
relay serve prints the hub CA's SHA-256 fingerprint once at startup, and relay status re-prints it; that fingerprint is what the relay CLI and a joining node pin, and it is not secret. For an arbitrary HTTP client there is no pinning flag in the code — trust the hub CA instead, which is on the hub at <state dir>/tls/ca.pem. Setting RELAY_TLS_DOMAIN swaps the private CA for a publicly-trusted ACME certificate, and then no client needs either.
Auth
Every route on both client dialects is gated by requireAPIKey, and an unset RELAY_API_KEY does not mean the endpoint is open. relay serve populates the key before it builds the daemon: an env-provided key is used as-is and never persisted, and if none is set it generates one — 32 bytes of crypto/rand, hex-encoded — persists it in the state dir at mode 0600, and prints it exactly once, on the run that created it. Re-show or rotate it later with relay token --api. Clients send Authorization: Bearer <key>, compared in constant time.
Beyond that single default key, relay key add NAME mints named keys that carry their own quotas — --tokens-per-day, --max-concurrency and --requests-per-minute, each defaulting to 0. Only a SHA-256 hash of a named key is persisted; the secret is shown once at creation and never again. A presented token is matched against the default key first and then against every stored hash. Neither matching is 401 invalid_api_key — never 404, because there is no resource identity to hide here.
Request bodies are capped at 8 MiB. Prompts are text; anything past a few megabytes is a mistake or an attack, not a conversation.
rate_limited is the per-key request rate limit — RELAY_V1_RATE_LIMIT, default 300 requests per minute, keyed on the key id rather than daemon-wide, with a named key's --requests-per-minute overriding it for that key alone. It carries a Retry-After header. quota_exceeded is a named key exhausting its daily token cap or its concurrency cap; it has no Retry-After, and the default key can never produce it because it carries no quota. Concurrency is reserved up front and released on the way out; token cost is charged after the response, since it is not knowable in advance. Both are only ever evaluated after a key has matched, so an unauthenticated caller gets 401 and can never probe limiter state. <state dir>/audit.jsonl, created at mode 0600, with a name that is fixed across every install so a log shipper can rely on it. The fields are timestamp, key_id, model, placement (host, shard, or node:<name>), prompt_tokens, completion_tokens, latency_ms and status. There is deliberately no field for prompt text, completion text, the bearer key itself, or any request or response body — key_id is a key id, never the key. A request rejected by the rate limiter before it reached its handler is logged too. Read it with relay audit tail. GET /v1/models
{
"object": "list",
"data": [
{
"id": "qwen3-8b",
"object": "model",
"created": 1735689600,
"owned_by": "relay",
"context_length": 16384,
"state": "warm",
"fits": true,
"fit_reason": "",
"placement": "host"
}
]
}Everything past owned_by is additive — an SDK that does not know these fields ignores them and still gets a working OpenAI-shaped list. state, fits, fit_reason and placement come from the pool's own view of the model: a client's model picker is meant to let a user select a warm model, while cold, loading and unavailable ones are listed with fit_reason explaining why an unavailable one cannot be picked.
RELAY_CONTEXT_SIZE (16384 when that is 0 or unset): the window the pool actually serves every model at, which is usually smaller than the model's native maximum. Size client-side context off this field, not the GGUF's own metadata — a client that sizes its conversation window off the model card will overrun the window the pool is actually serving. POST /v1/chat/completions
# /v1 is TLS by default; copy the hub's <state dir>/tls/ca.pem to the client.
curl https://hub.internal:8383/v1/chat/completions \
--cacert ./ca.pem \
-H "Authorization: Bearer $RELAY_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"model":"qwen3-8b","messages":[{"role":"user","content":"hello"}],"stream":true}'When model is omitted, the pool uses the sole registered model if there is exactly one; with zero or several registered models it returns 400 invalid_request naming the ambiguity instead of guessing.
A cold model load streams weights across the pool before it can answer, so the handler gives it RELAY_LOAD_TIMEOUT plus one minute of headroom, long enough for the supervisor's own timeout (which produces the useful error) to fire first.
Streaming responses are proxied with FlushInterval: -1, so tokens arrive as tokens rather than one buffered blob, on both the host/shard path (loopback dial) and the node-replica path (one tunnel stream per request, no keep-alive across requests).
The Ollama surface (/api)
A large share of the local-AI ecosystem speaks Ollama's wire dialect rather than OpenAI's, so /api/tags, /api/chat and /api/generate are mounted alongside /v1 on the same listener. This is a translation layer, not a byte-for-byte proxy. llama-server already speaks OpenAI's shape, so /v1 forwards bytes unchanged; Ollama's format is different — newline-delimited JSON objects rather than server-sent events, with its own field names — so each request here is translated onto the same call handleInference would make and the response translated back into Ollama's NDJSON shape. Placement, error mapping, the bearer gate, the quotas, the rate limit and the audit log are all shared with /v1 verbatim; only the wire shape differs.
Two differences worth knowing. Real Ollama has no auth of its own, but these routes are gated anyway — an operator who has locked down /v1 must not have that undone by a second, unauthenticated door onto the same pool — so send the same key in the same Authorization: Bearer header. And /api/tags lists only models that currently fit the pool, where /v1/models lists every registered model and reports fit as a field.
Error codes
| Code | HTTP | When |
|---|---|---|
invalid_request | 400 | Bad JSON, oversized body (>8 MiB), or ambiguous model selection. |
invalid_api_key | 401 | No bearer token, or one matching neither the default key nor any named key. 401, never 404. |
model_not_found | 404 | The named model was never registered with relay model add|pull. |
rate_limited | 429 | Too many requests this minute for this key. Carries a Retry-After header. |
quota_exceeded | 429 | A named key exhausted its daily token cap or its concurrency cap. No Retry-After. |
model_unavailable | 503 | The pool cannot currently place this model; the body names the exact shortfall. |
upstream_error | 502 | llama-server (host or node) failed mid-proxy. |
internal_error | 500 | Infra/database/exec fault. Never exposes the wrapped cause. |
A placement failure produces one of three distinct 503 bodies, all in the same OpenAI envelope. The first is captured from a real run; the other two are the format strings the scheduler builds them from.
{
"error": {
"message": "\"qwen3-14b\" needs about 12800 MB VRAM: the host has 6144 MB free, the best node has 0 MB free, and the pool aggregate (6144 MB) is not enough to shard it either — free VRAM, add a node, or pick a smaller/more-quantized model",
"type": "model_unavailable",
"code": "model_unavailable"
}
}{
"error": {
"message": "\"qwen3-14b\" is host-only (a privacy setting — it never leaves this hub, not even as a shard): needs about 12800 MB VRAM, the host has 6144 MB free — free VRAM on the host or pick a smaller/more-quantized model",
"type": "model_unavailable",
"code": "model_unavailable"
}
}{
"error": {
"message": "\"qwen3-70b\" needs about 48000 MB and the whole pool tops out at 30720 MB",
"type": "model_unavailable",
"code": "model_unavailable"
}
}; N MB are held by busy or pinned residents (…) — naming what eviction could not reclaim. 500 internal_error to the client and is logged with its full chain server-side. The raw text can contain local paths or OS error strings, so it must not reach the client. Control plane: GET /status
The control API is served on the unix socket (RELAY_SOCKET, created 0600 and owned by the daemon's user), not on RELAY_LISTEN. It has no authentication of its own; the filesystem permission is the whole access-control story. relay status --json prints the /status document verbatim. The other routes back the relay node, relay model and relay key subcommands: GET/PATCH/DELETE /nodes, POST /nodes/token, POST /nodes/{id}/wipe, GET/POST/PATCH/DELETE /models, POST/DELETE /keys and POST /keys/default/rotate.
The control plane is versionless. It carries no version field, makes no compatibility promise, and moves with the binary — the CLI is the only intended client, so fields are added and reshaped whenever the CLI needs them. Read it for operator tooling if you like, but pin the relay version you read it with.
| Field | Type | Meaning |
|---|---|---|
listen | string | RELAY_LISTEN as bound. |
state_dir | string | RELAY_STATE_DIR. |
host_gpu | string | Detected GPU name on the hub. |
host_vram_total_mb / host_vram_free_mb | int | Hub VRAM as last probed. |
vram_reserve_mb | int | RELAY_VRAM_RESERVE_MB. |
pool_vram_mb | int | Host lendable VRAM (total − reserve, floored at 0) plus every connected node's lendable VRAM. |
kv_cache_type | string | RELAY_KV_CACHE_TYPE in effect (f16, q8_0, q4_0). |
context_size | int | What this hub actually serves a replica at — the same figure GET /v1/models reports as context_length. Read from the hub's own config, not the CLI's environment, because relay model catalog can run on a different machine. |
parallel_slots | int | llama.cpp's continuous-batching width this hub runs at. context_size × parallel_slots is the KV-cache reservation every replica placement is actually sized against. |
nodes_total / nodes_online | int | Enrolled vs. currently connected nodes. |
nodes[] | NodeReport | id, name, status, connected, gpu_name, vram_total_mb, vram_free_mb, lendable_mb, latency_us, last_seen, trust, cache_used_mb, cache_max_mb. |
models[] | ModelReport | name, path, required_vram_mb, total_layers, pinned, host_only. |
loaded_model | string | Kept for wire compatibility: the first of host_models. The host runs one llama-server per resident model, so this can only ever describe one of them. |
host_models[] | string | Every model the host is serving from its own GPU. Omitted when empty. |
placement[] | PlacementEntry | device, node, model, node_id, layers, layer_from, layer_to, on_cpu — the layer split of the resident shard, if any. |
replicas[] | ReplicaReport | model, where (host or node name), addr, status, in_flight. The only place replica placement is visible. |
v1_scheme | string | https or http, resolved through the same V1UsesTLS() decision the listener uses, so daemon and CLI can never disagree. |
ca_fingerprint | string | The hub CA's SHA-256 fingerprint — the same one a join token embeds. Always populated; it is not secret. |
tls_domain | string | RELAY_TLS_DOMAIN: set when /v1 presents a publicly-trusted ACME certificate instead of the hub CA. Omitted when empty. |
recent_placements[] | PlacementOutcome | at, model, outcome (refused, sharded or evicted), detail. A 20-entry ring, newest first. A successful replica placement is never in it — that is the expected case, and would push the interesting entries out within seconds of ordinary traffic. Omitted when empty. |
models_used_mb | int | Current usage of the hub's own models directory. |
models_max_mb | int | RELAY_MODELS_MAX_GB in MB. Omitted when no cap is set. Visibility only — it never triggers deletion. |
NodeReport.trust is replicas or shard-only: a shard-only machine never runs a whole-model replica and so never sees a plaintext prompt or completion, only the activations for the layers it holds — while still lending its full VRAM to the pool. ModelReport.host_only is the per-model counterpart: that model never leaves the hub, not even as a shard. None of the control plane is part of the client contract. It is the CLI's own channel over the unix socket; a client of the pool — an OpenAI-compatible SDK, an Ollama-shaped tool, an editor plugin, an agentic coding tool — consumes only the public inference surface, and needs nothing from relay but a base URL and a key to do it. That narrowness is deliberate, but /v1 is not the whole public API: the pool speaks two client dialects, OpenAI's on /v1 and Ollama's on /api, so nothing has to be written against relay specifically in either ecosystem.