The endpoints clients call

What the gateway serves on :4000, what it deliberately does not, and the headers and retry behaviour that come with each.

EndpointPurpose
POST /v1/chat/completionsProxied byte-for-byte. Also /completions, /responses, /embeddings, /rerank, /score, /audio/transcriptions, /audio/translations, /audio/speech, /images/generations, /images/edits, /moderations
GET /v1/modelsAggregated across every pool, filtered to what the calling key may invoke. A virtual model is listed when the caller can invoke any model it routes to. Clients build model pickers from this, and offering names that 403 on selection is a defect the authorisation being correct does not excuse
GET /healthPer-backend health, in-flight, request and error counts, plus snapshot_version and the key count for the configuration this process is serving. No auth required. Exposes backend addresses — keep it off the public interface
GET /metricsPrometheus text, including fastllm_snapshot_version. No auth required
/admin/*--role all/control only. Gated by a session cookie (POST /login), not --proxy-token — see the table below and "Admin authentication" underneath it
POST /login / POST /logout--role all/control only. Argon2id password check; sets/clears the fastllm_session cookie every other /admin/* route requires
/, /ui/* (management UI)--role all/control only. The embedded SPA — see "Management UI" below
GET /snapshot--role all/control only. What --role proxy polls in Http mode; gated by --proxy-token
POST /usage--role all/control only. Batched usage reporting from --role proxy (see "TLS and the reverse channel" below); gated by the same --proxy-token as /snapshot
POST /limits/reconcile--role all/control only. Rate-limit count reporting from --role proxy (see "Rate limits" below); gated by the same --proxy-token

Endpoints, and what is not one

Twelve POST endpoints are proxied. All of them take the same path: read model from the body, authorise it, route it, forward the bytes. Nothing on that list is parsed on the way back, so adding one costs a line — which is why /responses, /audio/speech, /images/* and /moderations are there.

A native (anthropic/gemini) backend answers 501 for everything except /chat/completions, because only chat has a translation. That gate is what makes adding a passthrough endpoint safe: a native backend refuses it clearly instead of being handed a body it cannot read.

What is deliberately absent, and why it is not a line of config. The stateful job APIs — /batches, /files, /fine_tuning — are not endpoints so much as small databases. Creating a job is a POST with a model in it, which would work; retrieving one is a GET /v1/batches/{id} with no model and no body, so there is nothing to route on. Serving them means remembering which backend owns which job id, which is durable state on the request path — the one thing this proxy is built not to have. They need a design, not a suffix.

Response cache

Off unless a model asks for it:

-d '{"name":"embeddings","cache_ttl_seconds":300}'

An identical request to that model — same resolved model, same body — is answered from memory without touching the provider. Responses carry x-fastllm-cache: hit or miss, because a caller measuring latency deserves to know why one request took a microsecond and the next took a second.

Opt-in per model rather than global, because caching changes semantics: two identical requests at temperature > 0 are supposed to be able to differ. A deployment that sets nothing pays nothing, not even the hash — that is only computed once a model is known to have caching on.

Non-streaming 2xx responses only. Caching a stream would mean buffering the whole response before any of it reached the client, turning the one path this proxy exists to keep incremental into a batch operation. Errors are never cached: a 429 is a statement about now, and serving it from cache would keep a provider's bad minute alive long after it ended. The natural fit is embeddings and short completions, which are the requests that repeat.

The cache is per process, bounded by --cache-max-entries and --cache-max-bytes (both matter: a thousand embedding responses is nothing and a thousand completions is hundreds of megabytes). A shared cache would mean a network call, and the request path performs no I/O — a lower hit rate across replicas is the honest cost of that invariant.

A cache hit still counts against the caller's rate limit and budget. A cache is a latency and cost optimisation, not a way around a quota. And the whole cache is dropped whenever a snapshot changes, since a reconfiguration can repoint a model at a different provider and there is no way to tell from a key which entries are affected — a cold cache is a latency cost where a stale one is a correctness bug.

Rate limit headers

Every response from a principal with limits configured carries the de-facto x-ratelimit-* shape, so a client that already paces itself against OpenAI needs no new code:

x-ratelimit-limit-requests / x-ratelimit-remaining-requests
x-ratelimit-limit-tokens   / x-ratelimit-remaining-tokens
x-ratelimit-reset

Remaining is floored, not rounded — 0.6 of a request is not one a client can spend. x-ratelimit-reset is seconds until the allowance is fully back; a token bucket has no discrete window to reset, so that is the honest reading, and a full bucket reports 0. A principal with no limits gets no headers at all, because publishing remaining: 0 to an unlimited caller would make a well-behaved client back off against a limit that does not exist.

Retries

A retry waits 25ms, then 50ms, then 100ms, plus up to 50% jitter, and doubles that for a 429 — a provider that just said "too many requests" means it. Bounded deliberately: the delay is paid by a client still waiting for its answer, so this is a retry budget measured against one request's patience, not a background job's. Jitter is keyed on the request rather than an RNG, since the data plane has no random source in a --no-default-features build and all that matters is that simultaneous retries decorrelate.