On-demand gateway¶
On-demand deployments (see the user guide) are started by the first request
and stopped when idle. Two parts of the dgxmm process do this:
- The gateway (
dgxmm/ondemand/gateway.py): HTTP routes under/dgxmm/ondemand/{id}/v1/…that LiteLLM calls instead of the engine. It checks who is calling, asks the controller for a slot, then streams the request to the engine container with httpx. - The controller (
dgxmm/ondemand/controller.py,ctx.ondemand): per-deployment in-flight counters, the wait queue, make-room decisions and the idle reaper. It drives the existing deployer (restart,wake,stop), so every start goes through the fit check and the start-up gate.
Always-on deployments do not use either: LiteLLM calls their engine directly, as before.
A request for a stopped model¶
sequenceDiagram
autonumber
actor C as Client
participant P as Reverse proxy
participant L as LiteLLM
participant G as DGXMM gateway
participant O as Controller
participant D as Deployer
participant E as Engine container
C->>P: POST /v1/chat/completions (model = model name)
P->>L: same request
L->>G: POST http://dgxmm:8090/dgxmm/ondemand/{id}/v1/chat/completions<br/>Authorization: Bearer <on-demand key>
Note over G: peer on DGXMM's Docker network, no forwarding headers,<br/>clean path, then the key (constant-time compare)
G->>O: acquire(id)
alt deployment ready
O-->>G: slot
else stopped, fits
O->>D: restart (fit check, start-up gate)
D->>E: create and start the container
E-->>D: ready
O-->>G: slot
else stopped, does not fit
O->>D: stop idle on-demand deployments (least recently used first)
O->>D: restart
Note over O: no idle model to stop: queued (FIFO);<br/>a streaming request gets the queued chunk now
O-->>G: slot once memory is free, or a timeout after 15 min (503)
end
G->>E: the request (key and cookies removed)
E-->>G: answer (SSE chunks)
G-->>L: the same bytes, unbuffered
L-->>P: answer
P-->>C: answer
Note over G,O: slot released after the last byte;<br/>the idle timer restarts
Gateway¶
Routes (under the base path, not under /api, and not in the OpenAPI schema):
| Route | Behaviour |
|---|---|
POST /ondemand/{id}/v1/ + chat/completions, completions, embeddings, responses, messages, chat/completions/batch, audio/transcriptions, audio/translations, rerank, score (TRAFFIC) |
Traffic: takes a slot (may start, make room or queue), then forwards. These are the inference endpoints the pinned vLLM serves under /v1 (llama.cpp's are a subset). LiteLLM sends an Anthropic-format request (/v1/messages) for an openai/ entry to {api_base}/responses. With x-dgxmm-health-check: 1 (LiteLLM health checks, below) no slot is taken and nothing is started. |
GET /ondemand/{id}/v1/models |
Answered from the deployment record ({"object": "list", "data": [{"id": <served name>, …, "owned_by": "dgxmm"}]}); never wakes. |
GET / HEAD /ondemand/{id}/v1/models/{model} |
Forwarded only while the deployment is ready, without a slot; otherwise 503 ondemand_not_ready. |
anything else under /ondemand |
404 not_found; the engine is not called. Its admin and development endpoints (/sleep, /wake_up, /collective_rpc, /reset_prefix_cache, /metrics, /health, …), the …/render and tokenize helpers, messages/count_tokens, LoRA loading and stored responses (GET /v1/responses/{id}, …/cancel) are therefore unreachable through the gateway. |
Checks, in order (all errors are OpenAI-style {"error": {"message", "type", "code"}}):
- Peer guard (
PeerGuard, ASGI middleware, before routing). The raw TCP peer, as Uvicorn received it and beforeX-Forwarded-Foris applied, must be allowed by the peer policy (below), must not be on the decider's network, and the request must carry noX-Forwarded-For,ForwardedorX-Real-IPheader. Otherwise the answer is 404not_found, as if the route did not exist. Requests that come through nginx or another proxy are therefore refused even when the proxy sits on the same network. - Path: a
.,..or empty segment,\,?,#, a control character or an encoded/,\,#,?or NUL is 400invalid_path. - Key configured: without a readable
ondemand_key_file, every request is 503ondemand_not_configured(logged once). The file is checked at most once a second and re-read when its inode, mtime or size changed: a rotated key replaces the old one without a restart (and starts a LiteLLM sync), a file that went missing or empty fails closed again. - Rate limit: a peer with 20 failed key checks in a minute gets 429
rate_limitedwithRetry-After: 60for the rest of that minute. - Key:
Authorization: Bearer <key>, compared in constant time; else 401invalid_api_key. - Body: at most 16 MiB (multipart audio uploads included), else 413
request_too_large. - Deployment: unknown id 404
not_found; the decision model 503not_on_demand. An always-on deployment (switched while LiteLLM still pointed here) is forwarded without a slot while ready, else 503not_on_demand; the gateway never starts it.
Peer policy (peer_policy, refreshed at startup and every 30 s with the decider network's
subnets). With ondemand_allowed_cidrs: auto, the allowed peers are the subnets of the Docker networks
DGXMM's own container is attached to, except the decider's network, and minus each network's gateway
addresses and the first host address of each subnet: connections to a published host port arrive from
there (docker-proxy, hairpin NAT), so the host and the LAN never pass. When DGXMM runs outside Docker,
auto allows nobody. An explicit list is allowed minus the same exclusions: the attached networks are read
whatever the setting says, so their gateway and first host addresses and the decider's network are refused
even when the list covers them (one warning per distinct policy). Until the first refresh nobody is
allowed.
Forwarding. The upstream URL is the engine's base URL on the Docker network plus the checked path,
rebuilt from its segments. Authorization, Cookie, Host, forwarding headers and hop-by-hop headers
(including any named in Connection) are removed, and Accept-Encoding: identity is sent so the bytes
passed back are the bytes the engine wrote. Status, headers (except hop-by-hop, Server and Date) and
body are passed back chunk by chunk with no buffering; engine 4xx/5xx answers pass through unchanged.
One httpx client per deployment (256 connections, connect timeout 10 s, pool timeout 60 s, no read or
write timeout, because generation can be long). A client replaced after a restart on a new address is
closed only when its last stream ends.
Queued notice. A streaming chat/completions or completions request that has to queue for memory gets 200 with SSE headers and one
chat.completion.chunk (text_completion for /completions) with id: "ondemand-queued", the served
model name and the notice as the assistant's content, as soon as the controller reports the queueing.
The engine's SSE bytes follow unchanged. A later error (timeout, start failure, engine unreachable)
becomes data: {"error": …} and data: [DONE]. A streaming request that only waits for a start sends
nothing until the engine answers, so its response is exactly the engine's. Responses API and Anthropic
messages streams never get a notice: their typed events must begin with the engine's response.created /
message_start (LiteLLM's Responses-to-Anthropic adapter and the OpenAI SDK rely on that), so they wait
like that, and a timeout is a plain 503. Non-streaming answers of a
request that queued get the notice prepended to choices[0].message.content (chat, when it is a string;
tool-call answers with null content are left alone), choices[0].text, or the first output_text part
of the first message item of a Responses answer (none: unchanged), and Content-Length is
recomputed. Other endpoints are never modified. A request that asks for structured output gets no notice
at all, streaming or not, so its client can parse the answer: any of response_format, tools,
tool_choice, functions, function_call, structured_outputs or a guided_* field set (not null),
or a Responses text.format other than text. It waits like a request that only waits for a start.
LiteLLM health checks. LiteLLM's /health and background checks send a real completion through the
entry. The entry's model_info.health_check_params (merged by LiteLLM into the probe only) adds
x-dgxmm-health-check: 1. A traffic request with that header is answered by the gateway itself and is
never forwarded, in any state or mode: the header is not a secret (model_info is readable through
LiteLLM's /model/info, and any LiteLLM client can send it with extra_headers), so forwarding it would
let a client run a generation without a slot, invisible to the reaper and make-room. It takes no slot,
starts nothing and changes no counter. chat/completions, completions, responses, messages and
embeddings get a minimal empty answer of their shape (200, header x-dgxmm-ondemand-state: <state>);
a failed deployment is 503 ondemand_not_ready, other endpoints 503 ondemand_health_unsupported.
The answer therefore says the gateway and the deployment record are there, not that the engine is
serving: LiteLLM reports on-demand models as healthy while they sleep. The header needs the key like any
other request, and is removed from forwarded requests.
Hang-ups. Each request watches for the client's disconnect. It cancels a pending acquire (the
controller removes the waiter or its queue entry) or the upstream request, which closes the engine
connection. The slot is released in all cases, also when it was granted at the moment of the hang-up.
Other errors: the engine unreachable before it answered is 502 ondemand_upstream_error; a
deployment removed while the request waited is 404 not_found (503 ondemand_stopped when it was
starting for the request); more than ondemand_max_queued waiting requests is 503 ondemand_queue_full
with Retry-After: 60.
Key rotation. When the key changes, the failed-attempt counts are cleared, and for 300 s a request with the previous key gets 401 without counting as a failed attempt: LiteLLM keeps sending it until it reloads the entries the sync re-registered (up to about a minute), and its single address must not be locked out.
Controller¶
acquire(id) returns a slot at once when the deployment is ready. Otherwise, when
ondemand_max_queued requests already wait (queued or for a start, all deployments together), it fails
at once with ondemand_queue_full; else, until the deadline (ondemand_queue_timeout_s from the
request's arrival, so the cold start counts against it):
- A start is already running for this deployment: wait for it (not in the queue).
- Stopped or failed:
deployer.restart; asleep (vLLM sleep mode):deployer.wake. Any other state (starting by hand, stopping, switched to always on): wait without starting it. InsufficientMemoryfrom the deployer's fit check:- if the model needs more than the capacity (usable memory minus what always-on and system deployments
hold), fail at once with
does_not_fit, except within about 20 s of an on-demand stop (freed memory may still read as used) or after this attempt stopped a victim; - else pick victims: on-demand deployments that are ready or asleep, have no slot in flight, have been idle for their full timeout and whose engine metrics (a sample less than 60 s old) show no running or waiting requests, least recently used first, until the shortfall is covered. If all of them are not enough, none is stopped and the request is queued;
- stop the victims (Put to sleep to make room for X), wait up to about 20 s for the budget to show the freed memory, and run the fit check again.
All start and make-room decisions run under one controller lock, and each attempt is shared by every waiter of that deployment, so a burst of first requests starts the model once. Attempts are shielded: a waiter that hangs up never cancels a deployer call half-way, and an attempt starts nothing when no one waits any more.
Never stopping a busy model. Every stop (reaper or make-room) goes through deployer.stop with a
precheck that runs under the deployer's lock, without an await: it re-reads the deployment, re-checks
that it is still idle by the rules above and marks it as stopping, which acquire treats as not ready.
Handing out a slot and approving a stop therefore cannot interleave. Only stops the precheck approved are
recorded as on-demand events and audited.
Queue. One entry per deployment, in order of first arrival. The head attempts with make-room; while
the head is blocked, later entries may start only if they fit in the memory free right now minus what was
freed for the head (it is reserved for the head until it starts or gives up). Waiters wake on every state
change, removal, stop and queue change, and otherwise after at most 15 s (sooner when an idle timeout is
about to run out). on_queued (the queued notice) fires once per request, the first time it has to wait
for memory.
Reaper. run() calls reap_once() every 15 s: every on-demand deployment that is idle by the rules
above is stopped (Put to sleep after N min idle). The idle timer restarts on every slot granted and
released, when the deployment becomes ready, when DGXMM first sees it after a restart, and when it is
switched to on demand.
Stopped or removed while starting. When a deployment that is starting for requests goes to stopping
or stopped (an administrator's Stop, a schedule, the watchdog) or is removed, its waiters get 503
ondemand_stopped. That is not a start failure: no backoff, and the next request starts it again.
Start failures. When a start ends in failed, or a background wake fails, all waiters get
503 start_failed. New starts of that deployment are refused with the same error for 60 s, doubling to at
most 900 s; a successful start, set_mode or a changed model or configuration resets it. A background
wake that found no memory queues the requests instead.
Switch to always on. set_mode(id, "always_on") of a ready deployment just stores the mode. A
stopped, failed or asleep one is started (restart) or woken (wake) first and stays on_demand with
pending_mode = "always_on" until then; while set_mode starts it, acquire waits instead of starting it
itself, and the reaper and make-room never pick it. The deployer applies the pending mode in the same
transaction as the state change to ready (or failed / stopped). Until then LiteLLM keeps the gateway
entry, so the name never disappears during the 1 to 3 minute start; the next sync adds the direct entry
and then deletes the gateway one. A refused start (InsufficientMemory, a docker error) clears the pending
mode and leaves the deployment as it was.
Live counters. A change of a deployment's in-flight or queued counts publishes a deployment event
(the view with its ondemand field), at most once a second per deployment: the first change at once,
later ones folded into one event a second after the previous one, with the counts as they are then.
Nothing is persisted: counters, timers and the queue live in memory. After a DGXMM restart every ready on-demand deployment waits a full idle timeout before it is stopped; queued connections are lost anyway.
status(id) feeds the ondemand field of deployment views: {"inflight", "queued", "last_request_at",
"phase"} with phase one of queued, starting_for_request, serving, failed (failed or removed: LiteLLM no
longer lists it), sleeping (stopped, asleep or stopping) and idle, in that order of precedence. The
UI's badges come from it; a failed deployment keeps the normal Failed badge.
LiteLLM registration¶
LiteLLMSync (dgxmm/litellm.py, see LiteLLM integration) registers on-demand deployments
in every state except failed, so the model name stays callable while the model is stopped. The entry
differs from an always-on one only in its litellm_params:
| Parameter | Value |
|---|---|
api_base |
<dgxmm_internal_url>/ondemand/{id}/v1, by default http://dgxmm:8090/dgxmm/ondemand/{id}/v1 |
api_key |
the on-demand key (ondemand_key_file) |
timeout, stream_timeout |
max(1200, ondemand_queue_timeout_s + 600) s (1500 by default): a request may wait the queue limit for memory plus the start |
num_retries |
0: LiteLLM must not retry or fail over a request that is being held |
and in model_info, health_check_params: {"extra_headers": {"x-dgxmm-health-check": "1"}}, so LiteLLM's
health checks never wake the model (see the gateway above).
model_info carries dgxmm_id as for every DGXMM entry and dgxmm_key_fp, the first 12 hex digits of the
key's SHA-256 (LiteLLM's /model/info does not return keys). An entry whose fingerprint is missing or
differs from the current key is replaced on the next sync, so a new key is registered without any manual
step. Likewise dgxmm_params_fp fingerprints the extra litellm_params and model_info: when
ondemand_queue_timeout_s changes, or after an upgrade that changed them (entries from before the
health-check marker), the entries are replaced once; an unchanged setting writes nothing. Always-on
entries are unchanged and carry neither fingerprint.
A mode switch changes the entry's api_base. Each sync runs its adds before its deletes, so the new entry
exists before the old one is removed and the name is never missing. Without a readable key, on-demand
deployments are not registered, existing entries are left as they are (not deleted), and their LiteLLM
status is error with ondemand_not_configured. A sync runs when a deployment enters or leaves ready,
failed or planned, when one is removed, on a mode change, and every 60 s.
Data model¶
deployments.mode(on_demand/always_on, nullable:NULLreads asalways_on),deployments.idle_timeout_s(60–86400 s,NULL=ondemand_idle_timeout_s) anddeployments.pending_mode(always_onwhile a switch waits for the start, elseNULL; added byadd_missing_columns).- Migration 40 (deployments: on-demand mode) adds both columns when they are missing and sets
mode = 'always_on'on every existing row, so existing deployments keep their behaviour. New deployments default toalways_onin the API (the deploy wizard sendson_demandwhen LiteLLM is enabled and its last sync succeeded,ondemand.litellm_readyinGET /api/host); the decision model is alwaysalways_on. - Events written by the controller have the actor
ondemand; its audit rows (ondemand_start,ondemand_sleep,ondemand_queue_timeout) have no user and record the actor, deployment id and event text.PATCH /api/deployments/{id}/modeis audited asondemand_modewith the old and new values.