Configuration
models.toml in the repo root is the only file you edit. Everything else — the
gateway's routing table, each vllm serve argv, the generated client configs,
doctor — is derived from it. servedeck.toml no longer exists: no module
reads it, and a file of that name in the repo is ignored rather than merged.
state/desired.json is written by servedeck, not by you; the client configs are
written by servedeck wire --apply (see CLIENTS.md). Every value
below was checked against models.py, settings.py, limits.py and
desired.py on 2026-09-18; a key not listed here is one the loader ignores.
models.toml
Loaded and validated by models.py:load(). One problem is one RegistryError
saying what and where, never a bare KeyError; if the registry fails to load,
doctor skips every other check. The loader ignores keys it does not know —
there is no strict-schema pass, so utl = 0.96 or hostram_gib = 95 loads
cleanly and does nothing. Check a new key against the tables here, and confirm
it took effect with servedeck models or servedeck doctor.
[gpu] — required
| Key | Type | Required | Default | For |
|---|---|---|---|---|
total_mib |
int | yes | — | The card's size, the denominator of every utilisation. 97887 here. |
margin_mib |
int | yes | — | Subtracted from free VRAM before a main model's utilisation is computed, never handed to any model. 1024 here. |
power_limit_w |
int | no | unset | The watt cap doctor expects. |
margin_mib is also the launching worker's CUDA-context cushion: the process
filling --gpu-memory-utilization's fraction first creates a CUDA context,
loads cuBLAS/cuDNN kernels and allocates NCCL buffers, a few hundred MiB outside
that fraction and taken while the allocation runs, so control.compute_util
guarantees ceil(total * util) <= free - 700. power_limit_w is deliberately
unset: the cap is the owner's decision, and a number here that nobody decided
would read as one somebody had. Unset, doctor's power-cap row reports the live
watts and passes; set, it fails when the live cap differs by more than 1 W. See
HOST.md.
[defaults.env] — merged into every model
A table of string → string, optional, merged at load time into each model's own
env, where the model's own key wins. Quote the values: they are interpolated
into --setenv=K=V, so a TOML integer or boolean reaches the model as 4 or
True with no complaint.
VLLM_USE_FLASHINFER_SAMPLER = "0" — no system CUDA toolkit here, so
FlashInfer's JIT top-k/top-p sampler cannot compile and the engine core dies
during the profiling run. Every legacy launcher exported it; the harmless
deep_gemm import warning at boot has the same cause.
HF_HUB_OFFLINE = "1" (added 2026-09-18) — every model launches from the local
hub cache, so a launch cannot depend on the network, cannot resolve a revision
other than the one on disk, and needs no HF_TOKEN. The cost: a snapshot
genuinely missing a file fails fast with a local-files-only error instead of
fetching it, which is why doctor grew a weights (<key>) row per model. The 27B
and GLM have not been launched under it yet, so watch the first one.
[builds.<name>] — the venv a model launches from
| Key | Type | Required | For |
|---|---|---|---|
venv |
string | yes | vllm is looked for at <venv>/bin/vllm, unless the last path segment is already bin, in which case it is used as written. ~ is expanded before it becomes argv[0]'s directory, because execve does not expand it. |
cuda_home |
string | yes | Becomes CUDA_HOME, and <cuda_home>/bin goes on the model's PATH. |
cuda_home exists because the box has no system CUDA toolkit: nvcc/ptxas
come from the venv's nvidia/cu13 wheel and FlashInfer's JIT needs them at
request time, not only at process start. It is carried explicitly rather than
string-glued from venv plus a python version, because that version is a fact
about how the venv was built. Both keys are required, and the short forms the
older version of this document showed are rejected: stock = "~/path"
raises builds.stock must be a table with 'venv' and 'cuda_home', a venv-only
table raises builds.stock: missing 'cuda_home' (models.py:370-382). The
three builds are in BUILDS.md.
[models.<key>]
The table key is the registry key: the CLI argument, the /api/* path segment
and the unit-name suffix (model-<key>). It must match [a-z0-9-]+ or the load
fails, rather than systemd refusing three modules later.
| Key | Type | Required | Default | For |
|---|---|---|---|---|
id |
string | yes | — | The canonical public name; first entry in --served-model-name. |
repo |
string | yes | — | Hugging Face repo id; vllm serve's positional argument. |
slot |
"main" | "resident" |
yes | — | main is exclusive, one at a time; resident is co-resident and budgeted. |
port |
int | yes | — | The loopback port this model's own vLLM listens on. Clients never see it. |
build |
string | yes | — | A key in [builds]. |
ctx |
int | "native" |
yes | — | "native" resolves to the checkpoint's own max_position_embeddings from the local hub cache (never fetched). An int pins a validated ceiling below it — GLM is 327680 because its context here is bounded by VRAM headroom, not by the checkpoint's 1,048,576. |
aliases |
list of strings | no | [] |
Every name a client has ever used. Appended to --served-model-name and advertised in GET /v1/models. |
flags |
list of strings | no | [] |
Appended verbatim to the argv after the computed flags. |
env |
table of strings | no | {} |
The model's own environment, merged over [defaults.env]. |
presets |
table of tables | no | {} |
See below. |
vram_mib |
int | residents only | — | The resident's VRAM budget, from which its utilisation is derived. |
util |
float in (0, 1] | no | unset | Added 2026-09-18. The utilisation this main model is proven to serve at; refused at load time if it is not a number in (0, 1]. |
host_ram_gib |
int | no | unset | Added 2026-09-18. MemAvailable GiB required before launch; start() refuses below it. No swap on this box and pinned pages cannot be reclaimed, so overshooting is an OOM kill of the desktop, not a slowdown — one happened on 2026-08-28. |
kv_cache_bytes_per_token |
int | no | unset | Added 2026-09-18. When set, --kv-cache-memory-bytes is emitted as ctx_tokens * this. The registry used to pin the product instead, so moving the context control left the KV cap sized for the old length and vLLM refused after a six-minute load. |
min_output_tokens |
int | no | unset | Floor the gateway raises an explicitly small output budget to, clamped by resolved ctx. GLM sets 8192: a small max_tokens truncates mid-<think> and both content and reasoning_content come back empty. |
max_output_tokens |
int | no | unset | Advertised to clients by servedeck wire (VS Code's maxOutputTokens). |
vision |
bool | no | false |
Advertised by servedeck wire. Coerced with bool(), so vision = "false" is true — write a real TOML boolean. |
needs_tty |
bool | no | false |
Retired 2026-09-18. Still parsed so an older models.toml loads, read by nothing. Nothing ever enforced it; /etc/sysctl.d/90-servedeck.conf pins kernel.yama.ptrace_scope to 0 at boot, which made "cannot boot unattended" false for the one model that set it, and doctor's ptrace_scope (host) row is now unconditional. Delete it from your entries. |
servedeck never takes --served-model-name, --host, --max-model-len,
--gpu-memory-utilization or --port from flags: render_argv computes all
five, and the live Flash-Next argv on 2026-09-18 is byte-for-byte the rendered
registry entry. Restart=always, RestartSec=10 and TimeoutStopSec=120 are
unit properties in units.py, not registry keys; a model's own
--shutdown-timeout 30 belongs in flags, because it is vLLM's setting.
Pinned values today: util 0.96 flashnext / 0.96 fp4local / 0.40 qwen27b / 0.95 glm53;
host_ram_gib 95 flashnext / 85 fp4local / 155 glm53; kv_cache_bytes_per_token 17200 glm53.
[models.<key>.reasoning] takes parser (required if the table exists) →
--reasoning-parser, and mirror_content (bool, default false) → the gateway
copies reasoning into reasoning_content for chat-completions clients, with
servedeck wire reporting the model as thinking to VS Code.
[models.<key>.tools] takes parser (required if present) →
--enable-auto-tool-choice --tool-call-parser.
[models.<key>.presets]
Each <name> = { … } is a served name of its own: it appears in
--served-model-name and in GET /v1/models, routes to the same weights, and
carries its table as a chat_template_kwargs overlay on the request, merged
with setdefault so it only fills a gap — a preset is a default a client could
not otherwise express, not an override of one it could.
glm53-flash-low = { reasoning_effort = "low" } arrives upstream as
chat_template_kwargs.reasoning_effort = "low".
Do not add main or local as an alias or preset. The gateway synthesises both
as aliases of whichever model holds the main slot (routes.py:127), resolved
before the registry is consulted; a registry entry of that name would load and
then be shadowed.
GLM client-compatibility switches
Three booleans on [models.<key>], read by glm_policies.py. They must be real
TOML booleans: capture = "false" is a truthy string, and a switch that turns
on when its config says "false" is the worst available failure for a flag
whose job is keeping request bodies off the disk.
| Key | Default | Second consent | Why |
|---|---|---|---|
sanitize_tool_tags |
false; true for glm53 |
no | GLM's streaming tool-call parser can miss a closing tag that straddles a token boundary, and the tag leaks into the parsed argument — captured live as list_dir({"path</arg_key>": …}), which the client rejects, costing the agent its step. Stripping markup that is never valid argument text is a repair, not a guess. It also switches on the answerless-turn count for that route. |
restore_reasoning |
false everywhere |
no, but evidence | Puts reasoning back on the in-flight assistant turns of a request whose client echoed the tool_call ids and dropped the thinking; the output-side mirror cannot, because it can make thinking available to a client but not make the client send it back. Off because the proxy it is ported from records "three Xid 31 GPU faults followed within 40 minutes of it first firing, after 14 hours clean" — correlation, not proof, but this box has an active unrelated GPU-fault problem, so nothing that plausibly perturbs the GPU defaults to on. Turn it on for one session, watch telemetry, decide. |
capture |
false |
yes — SERVEDECK_GLM_CAPTURE=1, or python -m servedeck --glm-capture |
Consent, not an enable. With both set, the gateway keeps a 6-turn ring of that route's request/response bodies under state/captures/<model>/ (directory 0700, files 0600, credential headers redacted). Bodies contain everything the user typed, which is why one switch is in the registry and the other deliberately is not: the predecessor defaulted this on, writing full bodies to /tmp with no retention. |
Known gap, 2026-09-18: routes.py:255 assembles RoutePolicies from
mirror_content, the preset overlay, min_output_tokens and ctx only, and
does not copy these three fields, so all three are inert in the live gateway
however models.toml sets them. Only tests construct a RoutePolicies
carrying them; the fix belongs in routes.py:route_for.
What _validate enforces
[gpu] present with both required keys and at least one [models.*] table;
every required field present, with slot one of the two words, ctx an int or
"native", aliases/flags lists of strings, presets and each overlay
tables, vram_mib on every resident and util a number in (0, 1]; registry
keys matching [a-z0-9-]+; every id, alias and preset name unique across the
whole file, with a preset forbidden from shadowing any id or alias including its
own model's; ports unique and never 8000 (ats-optimizer) or 8010 (the gateway);
every build a key in [builds]; and sum(resident vram_mib) + margin_mib <
total_mib, so a main model always has room.
Utilisation: pinned in the registry, checked against the card
A main model's utilisation is its registry util when it has one, and
floor2((free_mib - margin_mib) / total_mib) when it does not. A resident's is
always floor2(vram_mib / total_mib) plus a CUDA-context cushion check, so a
resident can never make a big model refuse to boot — its memory is not free.
With util pinned, compute_util compares it against what fits now and
refuses if less fits, naming the free and total MiB. It neither hands the
model more nor silently launches it lower, because a lower utilisation changes
the KV budget, the context that fits and the agent count with nothing said. That
is why the key was added: free-VRAM arithmetic yields 0.98 on an idle card (a
launch was rendered at 0.98 on 2026-09-17), which boots and then dies of CUDA
OOM once agents arrive (measured 2026-09-03), while Flash-Next has only served
at 0.96 and the 27B at 0.95. A per-launch override from the page or the API is
still allowed and the VRAM refusal still applies; a per-launch
--max-model-len above a model's pinned integer ctx is refused with a 409
rather than clamped.
Environment variables
SERVEDECK_PORT raises SettingsError on a non-integer; the three limits.py
overrides silently fall back to their default instead. Nothing here is required.
| Variable | Default | Read in | For |
|---|---|---|---|
SERVEDECK_MODELS |
<repo>/models.toml |
settings.py:129 |
The registry path. SERVEDECK_MODELS_TOML is a synonym. |
SERVEDECK_STATE_DIR |
<repo>/state |
settings.py:132 |
desired.json, wire's dated backups, captures, the measurement store. |
SERVEDECK_UNIT_PREFIX |
model- |
settings.py:135 |
The systemd namespace servedeck owns. Only model- (production) or sd-test- (the suite); anything else fails loudly at startup rather than producing a discovery glob that matches nothing and reports a serving box as empty. |
SERVEDECK_HOST |
127.0.0.1 |
settings.py:144 |
Listen address; no auth exists, so do not expose it. python -m servedeck --host sets it in the environment so the app and uvicorn agree. |
SERVEDECK_PORT |
8010 |
settings.py:143 |
Listen port. --port sets it the same way. |
SERVEDECK_LOG_LEVEL |
INFO |
__main__.py:27 |
Level for the servedeck logger tree, whose one stderr handler is what puts reconcile's decisions in the journal. An unrecognised name falls back to INFO; uvicorn's access log is unaffected and stays on. |
SERVEDECK_GPU_TOTAL_MIB |
nvidia-smi, else 0 |
limits.py:130 |
Total VRAM for the capacity arithmetic. 0 means "cannot compute" and capacity blocks rather than sizing a KV cache against a number nobody measured. |
SERVEDECK_OVERHEAD_GIB |
4.7 |
limits.py:133 |
VRAM that is neither weights nor KV (activations, CUDA graphs). Measured 4.3-4.5 GiB on two very different models; 4.7 errs high on purpose. |
SERVEDECK_FRAG_MARGIN_MIB |
4096 |
limits.py:134 |
Fragmentation margin, reported as "thin" and never as "impossible": adding it to the requirement makes any utilisation above ~0.958 unsatisfiable, a bug this project shipped once. |
SERVEDECK_TRAINING_MARKERS |
~/Projects/local_llm/run/training_in_progress:~/.cache/algotrading/training_in_progress |
limits.py:98 |
Colon-separated lock files; if one exists, something else wants the GPU and doctor fails. Read at call time. The empty string leaves no markers at all. |
SERVEDECK_HF_HUB_DIR |
~/.cache/huggingface/hub |
discovery.py:101 |
Where the hub cache is read, which is what makes ctx = "native" and doctor's weights rows resolvable. Its docstring marks it "tests only"; treat it as a test seam, not an operator knob. |
SERVEDECK_GLM_CAPTURE |
unset | glm_policies.py:542 |
The master capture switch (1, true, yes, on), needed alongside a route's own capture = true. |
limits.get() caches on first call, so changing the three capacity overrides
means restarting the unit.
state/desired.json — schema 3
What an operator last explicitly asked for, and the one thing servedeck
remembers across a restart. Read it; do not hand-edit it — control rewrites it
atomically (temp file in the same directory, fsync, os.replace), so an edit
made while servedeck runs is overwritten by the next action.
{
"version": 3,
"main": "flashnext",
"residents": [],
"launch": {"flashnext": {"util": 0.96,
"argv": {"--max-model-len": "262144",
"--max-num-seqs": "16"}}}
}
main is a registry key or null, residents a list of keys. launch (new in
schema 3, 2026-09-18) holds the settings of each model's last boot that actually
reached READY, and reconcile replays them; an argv value of null means
"remove this flag". Before it existed the allocator was write-only: a tuned
utilisation, context, agent count and KV-offload size lived in the argv of a
running process and nowhere else, so the next restart or reboot relaunched at
the registry defaults with utilisation recomputed from free VRAM, and nothing
said so. Recording only ready boots means a configuration that fails to boot is
never the one a reboot repeats.
Written only by control.start / stop / switch / adopt, never derived
from a probe: a crashed model must stay desired so reconcile brings it back, and
a model an operator stopped must stay stopped even though adopting a stray unit
would otherwise re-desire it. A v2 file migrates to v3 silently and in place
(the change is purely additive); a v1 file migrates with a warning and leaves
desired.json.v1 beside it, and its backend was a launcher name rather than a
registry key, so confirm it names a model. Any other version is treated as
empty rather than guessed at, as is a missing, empty or unparseable file — this
file must never be the reason servedeck will not start. Live content on
2026-09-18: main = "flashnext", no residents, launch empty, so a reconcile
would relaunch Flash-Next at the pinned util = 0.96 with its registry flags.
Why one file
models.toml's predecessor was local_llm/.config, a shell file whose parser
silently skipped any line that was not KEY="value" and any value containing a
double quote. It parsed to an empty set of settings at least once,
--limit-mm-per-prompt could never be expressed in it, and three incidents came
out of it (docs/imported-2026-09/local_llm/docs/SCRIPTS.md and the 2026-09 notes; not re-verified here). TOML
parses or fails, and load() rejects what it cannot use — except an unknown
key, as above.