The fleet runs the always-warm Qwen generate primary — plus two tiny
co-resident embedding and reranker gears — behind a single stdlib
OpenAI-compatible gateway, managed by model-gear as four Docker containers. It is
an alternative to the bare single-model deployment — scaffold it with
model init --fleet (the single-model model init is unchanged and remains the
default). The gateway routes by task family (generate / embed / score /
rerank); there is one generate backend by default and a warm generate
fallback is opt-in (see "Adding a fallback").
The single-model deployment serves one model on :8000 and model switch swaps
it (freeing the prior model). The fleet instead puts a stable OpenAI endpoint in
front of the primary, so:
- existing clients (the acp
vllm-localprovider,curl, …) point at:8000and keep working — an unknown/missingmodeldefaults to the primary; - the gateway can route additional models by name and fail over if a second backend is wired up;
- the same front fans
/v1/audio/*out to the audio overlay (--audio).
On the DGX Spark (GB10, 128 GB unified memory) the primary — a hybrid-Mamba
27B — runs solo at its load-tested headroom (util 0.6, full 256K context,
~75 GiB), owning the box. The prior co-resident dense 24B Mistral fallback was
removed (two ~30B NVFP4 models do not co-fit a shared GB10 — see "Live validation
findings"); Mistral stays a selectable catalog candidate (model overview --list)
and the opt-in fallback example.
client / acp ──:8000──▶ model-gear-gateway (python -m model_gear.gateway)
│ route by `model` / task family
├──▶ model-gear-vllm-primary :8000 generate (→ failover if a fallback is wired)
├──▶ model-gear-vllm-embed :8000 embed (/v1/embeddings)
└──▶ model-gear-vllm-rerank :8000 score/rerank (/v1/rerank, /v1/score)
Four containers by default, all restart: unless-stopped:
| Container | Role | Host port |
|---|---|---|
model-gear-gateway |
stdlib reverse proxy (the single OpenAI front) | ${VLLM_PORT:-8000} |
model-gear-vllm-primary |
generate primary (default: sakamakismile/Qwen3.6-27B-Text-NVFP4-MTP) |
internal only |
model-gear-vllm-embed |
embedding gear (Qwen/Qwen3-Embedding-0.6B, /v1/embeddings) |
internal only |
model-gear-vllm-rerank |
reranker gear (Qwen/Qwen3-Reranker-0.6B, /v1/rerank + /v1/score) |
internal only |
The backends are reachable only on the compose network (http://vllm-primary:8000,
vllm-embed:8000, vllm-rerank:8000); only the gateway is published to the host. The
gateway needs no Docker socket access — compose owns the lifecycle; the gateway
only routes.
Each vLLM gear runs through mg-logwrap so its output (and any crash trace)
persists to per-boot files under the host log dir and survives restart/recreate —
read them with model logs {primary,embed,rerank} even after a container is gone.
See docs/durable-logs.md (issue #50).
The gateway adds a second backend only when FALLBACK_URL or
FALLBACK_SERVED_NAME is set. To add a warm fallback: define a vllm-fallback
service in the fleet compose (mirror vllm-primary with the fallback's model /
quantization / tokenizer / tool-parser), add it to the gateway's depends_on,
set FALLBACK_URL + FALLBACK_SERVED_NAME on the gateway, and drop both
*_GPU_MEM_UTIL values so they sum well under 1.0. The archived dense Mistral
fallback config is in git history and
docs/mistral-small-3.2-24b-nvfp4.md.
A pure-stdlib (http.server + http.client, no third-party deps) reverse proxy:
- Name routing — a request's
modelroutes to the backend that serves it, plus anyGATEWAY_ALIASES. The forwarded body'smodelis rewritten to the backend's--served-model-nameso the backend accepts aliased/default routes. - Default model — a missing or unknown
modelroutes toGATEWAY_DEFAULT_MODEL(the primary). - Failover — when a fallback is wired up, a chosen backend that refuses the connection or returns a 5xx before any response body is retried against the other backend. (One generate backend by default → no generate peer to fail over to; the embed/rerank gears are separate task families, not failover targets.) A 4xx is a client error (returned verbatim, no failover). Once a 2xx body starts streaming there is no retry — the client already has bytes.
- Streaming —
"stream": true(SSE) is relayed chunk-by-chunk with per-chunk flushing; normal JSON is buffered withContent-Length. - Endpoints —
/v1/chat/completions,/v1/completions(generate primary),/v1/embeddings(the embedding gear),/v1/rerank+/v1/score(the reranker gear),/v1/models(OpenAI-standard, lists the loaded backend(s)),/v1/models/supported(the full supported-model catalog — every gear you can change to, each flaggedloaded/default),/health(gateway liveness), and/status(the live fleet aggregate — see below). See Supported catalog vs. warm backends for what/v1/modelsand/v1/models/supportedeach mean. GET /status— a model-gear-native (non-OpenAI) JSON aggregate the gateway fans out to each backend's/health+/metricsand returns as{object: "model-gear.fleet_status", default_model, busy: {running, waiting}, backends: [{name, task, served_name, health, metrics}], endpoints}. The backends are internal-only, so the gateway is the only thing that can see them — this is the source formodel overview --live.
The gateway image is built from the scaffolded Dockerfile.gateway
(pip install model-gear==${MODEL_GEAR_VERSION}, as a non-root user); model init --fleet pins MODEL_GEAR_VERSION to the running model-gear release. The version
is required (pinning keeps the image reproducible); from-source/dev boxes that run
ahead of a PyPI release point MODEL_GEAR_VERSION at a published TestPyPI .devN
build.
Two questions that look alike but aren't:
- What's loaded right now? — the model(s) actually in GPU memory. The live
source is
GET /v1/models(OpenAI-standard; one model in single-model mode; the generate primary plus the embedding + reranker gears in the fleet);model fleet statusqueries it. It changes when youmodel switchor bring the fleet up/down. (model status/model whoamiinstead report the model the deployment is configured to serve — from.env— plus container health, which is configuration, not a live/v1/modelsquery.) - What's supported (what can I warm up)? — the curated catalog of "gears"
model-gear knows how to serve, from
model overview --listorGET /v1/models/supported. Each entry is taggedload-tested(proven on this box) orconfigured(declared, not yet proven). It's static — defined inmodel_gear/catalog.py, shipped in the wheel, unchanged by what's running. On the gateway endpoint each entry also carries a runtime-computedloaded/defaultflag.
Mnemonic: the catalog is what's on the menu (and which dishes we've cooked);
/v1/models is what's hot now.
model init --fleet --apply # scaffold compose + .env + Dockerfile.gateway
model fleet up --apply # docker compose up -d --build, wait for gateway /health
model fleet status # each container's state + gateway /health + /v1/models
model overview --live # live dashboard: online / offered / busy + usage + endpoints
model fleet down --apply # docker compose downmodel fleet up / down are dry-run by default; pass --apply to commit.
--compose-dir overrides the deployment dir (default $MODEL_GEAR_DIR or
$HOME/.model-gear). model fleet status is read-only — it reports the warm
backend(s) (/v1/models); for the full set you can switch to, use
model overview --list / /v1/models/supported (see above).
model overview --live is the read-only live dashboard: it reads the gateway
/status (or, against a bare single-model server, that server's /metrics +
/health) and prints what is online (per-backend health), offered
(models, task families, endpoints), busy (in-flight / queued requests), and
cumulative usage (prompt/generation tokens, finished requests by reason). HTTP-only, so it
works against a local deployment or a model tunnel hostname alike; it degrades
gracefully when a backend or its metrics is unreachable.
model switch does not drive the fleet — it rewrites the single-model
VLLM_* keys. Change the fleet primary by editing the fleet .env
(PRIMARY_MODEL and its PRIMARY_SERVED_NAME / PRIMARY_GPU_MEM_UTIL
/ PRIMARY_TOOL_CALL_PARSER / PRIMARY_QUANTIZATION) and re-running model fleet up --apply. (A fallback, when wired up, uses the parallel FALLBACK_* keys.)
The fleet runs one generate backend by default: the primary owns the box at
PRIMARY_GPU_MEM_UTIL=0.6 (~75 GiB of the 128 GB), serving the full 256K context
— the load-tested solo footprint (see findings below). The co-resident embedding
and reranker gears are ~0.6B each at *_GPU_MEM_UTIL=0.06 (a couple GiB apiece),
so they tuck into the remaining headroom without crowding the primary; what does
not co-fit is a second ~30B generate model (below). That still leaves room
for the OS and other processes.
--gpu-memory-utilization is a fraction of total unified memory, computed
independently per vLLM process (they don't coordinate). So if you add a warm
fallback, PRIMARY_GPU_MEM_UTIL + FALLBACK_GPU_MEM_UTIL must sum well under
1.0 — two ~30B NVFP4 models do not co-fit a GB10 that is also running other
services (the prior 0.40 + 0.35 co-residence default OOM-looped; that's why
the fallback was removed). Validate live (watch spark memory / nvidia-smi
at model fleet up; OOM is the top operational risk).
Note the throughput trade-off if you do co-resident two backends: decode is memory-bandwidth bound and the bandwidth (~273 GB/s) is shared — two backends decoding simultaneously split it. The gateway routes one request to one backend, so a single client sees full speed.
First live model fleet up of the 27B-primary + 35B-A3B-fallback pair on
spark-f8a9 (a shared box: tritonserver/realtime-api, nova, reachy, mongo
also running, ~12–20 GiB baseline). Measured with dgx-spark-cli (spark):
| What | Result |
|---|---|
27B (primary) solo load → /health |
~423 s (~7 min): weight load 160 s (28.25 GiB), profiling/warmup 55 s, CUDA-graph capture + KV ~200 s |
| 27B decode (batch=1, 512 tok) | 8.0 tok/s; prefill 2,015 tok in 3.29 s |
| 27B footprint | ~75.5 GiB at util 0.6 (≈ 28 GiB weights + 42 GiB KV + 3.7 GiB CUDA graphs) |
| 35B-A3B (old fallback) load | Did not complete. Co-resident: CUDA error: out of memory on engine init → 14+ restart crash-loop. Even solo (65 GiB free): crashed/stalled at "Loading safetensors 0%", never /health in 8+ min. No benchmark obtained. |
| Co-residence (27B + 35B-A3B) | Not viable on this box. 27B alone (~75 GiB) + 35B-A3B (~24 GiB weights + KV) + baseline services exceed the 121.7 GiB unified pool → OOM + swap thrash (swap hit 68 %). |
Mistral-24B (new fallback) solo load → /health |
Loaded cleanly (port 8001, util 0.4): 15.05 GiB weights, 30.69 GiB KV, ~49.6 GiB total. Decode 14.9 tok/s; prefill 2,009 tok in 1.49 s; tool calling ✅. See docs/mistral-small-3.2-24b-nvfp4.md. |
Conclusion — the "two always-warm generate models" premise needs a dedicated
box, so the default is one generate backend. On a GB10 shared with other
services, two ~30B NVFP4 models do not co-fit with usable KV caches. The default
fleet therefore serves the Qwen generate primary at its load-tested solo
headroom (util 0.6, full 256K, ~75 GiB), with the tiny embedding + reranker gears
co-resident (util 0.06 each). If you genuinely need two warm models, run on a dedicated machine,
pair two small models, or wire the opt-in fallback (see "Adding a fallback") and
drop both utils. Single-model model switch (one warm at a time) remains the
other path.
Fallback history. The original 35B-A3B MoE fallback never loaded
(docs/qwen3.6-35b-a3b-nvfp4.md); it was replaced
(2026-05-30) by the dense RedHatAI/Mistral-Small-3.2-24B-Instruct-2506-NVFP4
(loads reliably, ~15 GiB weights —
docs/mistral-small-3.2-24b-nvfp4.md). Even the
dense 24B stayed tight on a shared box, so the warm fallback was removed from
the default fleet — Mistral remains a selectable catalog candidate and the
documented opt-in fallback. The 0.55/0.30 → 0.40/0.35 util history above is
the record of that co-residence struggle.
The fleet .env mirrors VLLM_MODEL / VLLM_SERVED_NAME / VLLM_TOOL_CALL_PARSER
(= the primary's) so the read-only single-model verbs (model status,
model whoami, model doctor's env_coherence check) stay sensible on a fleet
deployment. culture.yaml's model: vllm-local/sakamakismile/Qwen3.6-27B-Text-NVFP4-MTP
resolves through the gateway on :8000 as the default.