mentatd-serve
Name
mentatd-serve: OpenAI-compatible router and merged MCP endpoint for a mentat cluster.
Synopsis
mentatd-serve
mentatd-serve --version
mentatd serve
Description
mentatd-serve puts one OpenAI-compatible endpoint and one MCP endpoint in
front of every model the cluster runs. A client specifies a model and the router
forwards the request to the group serving it. Adding a model means starting
another deployment. The router needs no change.
It is a separate binary and container from mentatd. The daemon never
touches inference traffic, so the router can restart, move or stop while
models keep serving the clients already talking to them.
Configuration is by environment. There are no options other than
--version. A containerised router needs network_mode: host, because
announced endpoints sit on host addresses that bridge networking cannot
reach.
Discovery
The router builds a watch set of daemon HTTP addresses from three sources:
UDP announcements on MENTAT_ANNOUNCE_PORT, the MENTAT_DAEMONS seed list,
and the mesh membership each watched daemon reports. Each watched daemon is
polled on /status every POLL_INTERVAL_S with its /events WebSocket
held open, so a cluster event re-reads at once. A burst of events coalesces
into one re-read.
An announcement is a hint. It adds one address to watch. Every claim in it
is re-read over TCP and probed before it affects routing. The datagram's
source address and every address it advertises must match
ALLOWED_SOURCES. With MENTAT_SECRET set, unsigned announcements are
refused.
The group table merges every daemon's view into one entry per group name. A view older than three poll intervals is stale. When two daemons disagree about a group, the one reporting more running actors wins.
Admission
A group is routable on /v1 when it has a running actor and its announced
OpenAI endpoint answers a /models probe. The probe is also where model
names come from: whatever the engine lists under /v1/models is what routes
to it. Nothing announces model names.
An engine is admitted as soon as its API answers, which on some models is during its self-test.
/status.json says why a model is missing. Each group carries healthy
and, when false, why_not specifying the failed gate: no endpoint, no running
actors, unprobed, probe failed, or probe stale. A probe failure quotes every
candidate address it tried and appends the agent's own bind finding when
there is one.
Candidate addresses
A port-form announcement (see "Announcing endpoints") resolves to one
candidate URL per address of the announcing node. Candidates on a subnet
the router is attached to sort first. Within each half, the node's own
ranking from MENTAT_ANNOUNCE_IFACES orders them. Every candidate is
checked against ALLOWED_SOURCES. A URL-form announcement is its own single
candidate. The router uses it as written and skips the allowlist check.
The prober walks the list and keeps the first address that answers. Live
traffic stays on it until it stops answering, then the router falls through
to the next candidate. Every PROBE_PROMOTE_S the router re-tries the
addresses ranked above the one in use, so a repaired link is taken back
without operator action. /status.json shows openai (in use) beside
openai_candidates (all of them, best first). A group serving from its
second candidate is how a dropped link looks from the router.
A probe that fails on a reused connection is retried once on a fresh one before the group is marked unhealthy. Servers close idle keep-alive connections, and a probe landing on one gets an error indistinguishable from a dead endpoint. Only the probe and the status poll retry. A proxied request is sent once, since a retry would re-send work the engine may already be doing.
Request handling
Any POST whose body carries model is forwarded to the group serving that
model, so /v1/chat/completions, /tokenize, /detokenize and any other
endpoint the engine exposes all work. A body with no model is refused
with 400.
The announced base ends in /v1. A root-level path such as /tokenize is
resolved against the base with the /v1 removed.
A known model routes and streams through, frame by frame with backpressure.
A model whose group exists but is not admitted returns 503 with the reason.
A name nothing serves returns 404. Bodies over 128 MiB are refused. One
upstream request may run for SERVING_TIMEOUT_S.
Announcing endpoints
Model containers announce endpoints through agent registration. The
entrypoint exports these before ray start, and the agent reads them once:
# the rank serving the API
# every rank
# what serves the API
All three are optional. An agent without them registers as before.
MENTAT_OPENAI_API belongs on the rank running the API server, since only
that rank answers inference. Nothing enforces this. The agent announces
whatever is set, and the router takes the lexically first if several ranks
announce. MENTAT_MCP_API belongs on every rank, because every rank runs a
status server. MENTAT_MODEL_PROVIDER specifies the engine behind
MENTAT_OPENAI_API and belongs on the same rank. /status.json reports it
per group, empty when the container did not say. "Counting tokens" needs it.
Port form and URL form
An endpoint takes one of two forms:
| Value | Meaning |
|---|---|
8000/v1, or http://0.0.0.0:8000/v1 |
Every address this node announces |
http://10.0.0.1:8000/v1 |
That address only |
Prefer the port form. An endpoint announced on one address is reachable only from that link, so a router off it can never route to the model. The port form leaves the host to the router, which resolves it against every address the node announces. The same image then serves a router on the LAN and one on the fabric, and a group stays routable when a fabric cable drops.
The port form assumes the API server binds the wildcard address, which
--host 0.0.0.0 does and vLLM does by default. The agent watches its own
/proc/net/tcp for the announced port. If the server bound a single address, the
agent logs service_bind_narrow and attaches the finding to the
announcement, so /status.json says bound to 10.0.0.1 only beside the
failed probe. The finding is advisory. The probe alone admits an endpoint.
The URL form is for a server the port form cannot describe: a different
host, a reverse proxy, a port published out of a bridge network. A URL is
used exactly as written. ALLOWED_SOURCES does not apply to it, since that
list covers addresses the router derived for itself.
HTTP interface
| Method and path | Returns |
|---|---|
GET /v1/models, GET /v1 |
The models routable now, each entry as its engine listed it |
POST /v1/* |
Forwarded to the group serving the request's model, streaming passed through |
POST /v1/responses/input_tokens |
A prompt token count. See "Counting tokens" |
POST /mcp |
The merged MCP endpoint. See "The MCP merge" |
any other POST |
Forwarded by the request's model, for root-level engine endpoints such as /tokenize |
GET /, /healthz, /status.json |
Route table, per-group health and endpoints, uptime_s |
GET /stats.json |
Per-model engine and router counters, for the status page |
GET / from a browser (an Accept header specifying HTML) returns the status
page instead of the document.
|
The status document carries uptime_s. Several of the router's guards are
per-process, for example the once-only log of a rejected source, so a log
line that seems to repeat may be one line per process. A line stamped
earlier than now minus uptime_s came from an earlier process.
The status page
http://<node>:6381/ in a browser is a live table of what the router is
carrying and what each engine is doing with it. The page polls
/stats.json.
The engine publishes queue depth, KV usage, token totals and latency
histograms on /metrics, so running, waiting, kv, the token counts
and the mean TTFT, queue and inter-token columns come from the engine
serving that model. The router adds proxied, the number of requests it is
carrying for that model right now.
Clicking a model lists those requests one per row: body size, time waiting with no first byte, time to first byte once it arrives, and bytes returned. A long wait with no first byte while the engine reports nothing running is an engine that took the request and stopped.
A group that fails its probe keeps its row, dimmed, with the reason in place of the numbers.
Counting tokens
POST /v1/responses/input_tokens answers how many prompt tokens an input
would cost, in OpenAI's shape:
# {"object":"response.input_tokens","input_tokens":14}
The router owns this route. vLLM has no such endpoint, and the path lands on
its /v1/responses/{response_id} pattern for a 405.
The serving engine counts the text. The router sends it to that group's
/tokenize as a chat request, so the chat template is included.
instructions becomes a leading system message and tools are passed
through, because the template renders both and the engine then prices
them. Text-only counts match the engine.
Media is estimated at flat rates: 4000 tokens per image and 40000 per video, whatever the resolution or length. The true cost depends on tiling and the model's patch size, which the router cannot know without fetching the media and running the engine's preprocessor. An attachment that is neither, such as a PDF, contributes only the text that accompanies it.
The route needs MENTAT_MODEL_PROVIDER=vllm on the container. A group that
announced no provider, or one the router does not know, gets a 400 specifying
the group.
The MCP merge
/mcp merges every group's management MCP into one endpoint. Tool names
are prefixed <group>__, so identical names across containers cannot
collide. tools/list answers are cached per group for TOOLS_TTL_S.
The merge skips the admission gate. A status server matters most while its engine is loading or wedged, which is when the gate would exclude it.
One native tool, serve_status, reports the watched daemons, each group's
health and endpoints, and the model table.
Environment
An unset or empty variable takes its default. A *_S value must be a
positive number. Anything else takes the default.
-
SERVE_PORT(default 6381)HTTP port.
-
MENTAT_DAEMONS(default127.0.0.1:6380)Comma-separated daemon HTTP addresses to seed the watch set. Unset seeds the local daemon. Set and empty seeds nothing, leaving UDP as the only path in. Compose cannot express empty, since
${VAR:-default}reads it as unset. -
MENTAT_ANNOUNCE_PORT(default 6382)UDP port to listen for daemon announcements on.
0turns the listener off. -
ALLOWED_SOURCES(default10.100.0.,192.168.1.,127.0.0.1,::1,172.)Comma-separated address prefixes. An announcement's source address and every address it advertises must match one before the router acts on it. The address a node calls its own is not checked, since nothing acts on it.
172.covers bridge-networked clients, which keep a172.xsource. A rejected source logsannounce_source_not_allowedonce, specifying the prefixes in force.A router that shares no wire with a fabric should leave that fabric's prefix out. Otherwise the router ranks the fabric address first, waits
PROBE_TIMEOUT_Son it every round, and falls through. -
DISCOVER_PEERS(default1)1adds the mesh peers of every watched daemon to the watch set. Any other value disables it. -
POLL_INTERVAL_S(default 10)Interval between
/statuspolls of each watched daemon. A daemon view older than three intervals is stale. -
PROBE_INTERVAL_S(default 5)Interval between endpoint probes.
-
PROBE_TIMEOUT_S(default 3)Deadline for one probe.
-
PROBE_FRESH_S(default: three probe intervals plus one timeout)How long a probe result stays valid. Past it the group reads
probe stale. The default clears one round that walks every candidate address, since each dead one costs a wholePROBE_TIMEOUT_S. Setting it alone makes groups flap in and out of the route table. -
PROBE_PROMOTE_S(default: six probe intervals)How often a group serving from a lower-ranked address re-tries the addresses ranked above it.
-
SERVING_TIMEOUT_S(default 1800)Deadline for one upstream request. A non-streaming answer arrives when generation ends, so this is sized for generation. Lower it and long generations are cut before any hung request is.
-
MCP_TIMEOUT_S(default 180)Deadline for one forwarded MCP call and for the tokenize call behind
/v1/responses/input_tokens. Some management tools block for their whole sampling window, so it is longer thanPROBE_TIMEOUT_S. -
TOOLS_TTL_S(default 60)How long a group's
tools/listanswer is cached. -
MENTAT_SECRET(default: unset)HMAC key for announcements. Must match the daemons'. A keyed router takes signed announcements only, so a half-applied rollout stops discovery until the seed list finds the daemons instead.
-
MENTAT_SECRET_FILE(default: unset)Read the key from this file instead of
MENTAT_SECRET. A file that cannot be read, or reads empty, stops the process at boot with the reason. -
MENTAT_UNIVERSE(defaultdefault)Cluster name. An announcement from another universe is dropped before its signature is checked, without a log line.
Limits
- The router must reach every daemon's HTTP port and at least one candidate address of every announced endpoint. With the port form that is any link it shares with the model's node. With the URL form it is the one address the announcement specifies.
- Admission tracks the probe. A model that answers
/modelswhile still warming up is routable. - Health is per group. A group with one wedged rank reads healthy while its API answers.
- The control port has no authentication. Signing covers announcements only, and every claim in one is re-read over TCP before it affects routing.