/admin/mirror, behind the same admin session as the other admin pages. What is held, what is arriving with its byte count, what failed and why; start a fetch, retry a failed one, remove a version. Not mounted at all without a mirror configured. The right page for a deployment with no mirror is no page, rather than an empty one pointing at nothing. It reloads itself only while something is downloading. A reload at any other moment takes with it whatever is half-typed into the fetch form, and there is nothing to watch when nothing is moving. A failed row offers Retry, not Remove. A fetch that failed left nothing on disk, so the remove could only answer "the mirror holds nothing for this" — a button whose single outcome is a confusing error. Starting the same download again is what somebody looking at that row wants, and the mirror allows it: only a download still running is refused. An unreachable mirror is said outright instead of rendering as an empty table. The two look nearly identical and mean opposite things: one is a mirror waiting to be filled, the other is a machine to go and look at. Dashboard takes NavExtras rather than a second bool. Two adjacent booleans at a call site get swapped silently and are discovered by somebody clicking the wrong link. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
263 lines
9.8 KiB
Markdown
263 lines
9.8 KiB
Markdown
# A model mirror
|
|
|
|
One machine that holds the model files, so nodes fetch over your own network
|
|
instead of over the internet.
|
|
|
|
This is the highest-value piece of infrastructure around llmgw and the cheapest.
|
|
Eight nodes each pulling forty gigabytes from Hugging Face is a day's work, and
|
|
on plenty of corporate networks it is not possible at all. From a mirror on the
|
|
same switch it is minutes.
|
|
|
|
llmgw needs nothing special for this: a model's `source` is a URL, and the URL
|
|
can be yours.
|
|
|
|
## What it is
|
|
|
|
An HTTP server serving a directory. That is the whole design.
|
|
|
|
```
|
|
/srv/models/
|
|
qwen2.5-14b-instruct/
|
|
2026-03/
|
|
q4_k_m.gguf
|
|
bge-m3/
|
|
2026-01/
|
|
model.safetensors
|
|
```
|
|
|
|
The date in the path is the point of the layout, not decoration. A model
|
|
repository that changes upstream changes what you are running, and the first
|
|
sign is a prompt that worked last week answering differently this week with
|
|
nobody able to say why. Pin it here and that cannot happen.
|
|
|
|
## Running it
|
|
|
|
`llmgw` is the mirror. Same binary as the control plane and the agents, one
|
|
subcommand:
|
|
|
|
```
|
|
sudo mkdir -p /srv/models && sudo chown llmgw:llmgw /srv/models
|
|
llmgw mirror serve --dir /srv/models --base http://models.internal:8081
|
|
```
|
|
|
|
There is a unit in `deploy/systemd/llmgw-mirror.service`. Set `--base` to the
|
|
address nodes reach this machine on — not `localhost`, and not a name only this
|
|
host knows itself by. It is the string that ends up in catalog entries, and a
|
|
wrong one makes `mirror unused` report files as unreferenced when they are in
|
|
use. The next step after that list is deletion.
|
|
|
|
### Two listeners
|
|
|
|
`--listen :8081` serves the weights. Read-only, unauthenticated, Range requests
|
|
supported so an interrupted fetch on a node resumes rather than restarts. This
|
|
is the path eight nodes pull forty gigabytes from.
|
|
|
|
`--api-listen :8082` is the management API. Separate on purpose: putting a
|
|
client certificate in front of the byte path would mean re-certifying every
|
|
agent's fetcher in order to change how models are administered.
|
|
|
|
The API is unauthenticated unless you give it the fleet's CA, and it says so at
|
|
startup rather than leaving you to find out. Anyone who can reach that port can
|
|
start downloads and delete models, so either keep it on a trusted network or
|
|
hand it certificates:
|
|
|
|
```
|
|
llmgw mirror serve --dir /srv/models --base http://models.internal:8081 \
|
|
--tls-ca /etc/llmgw/ca.pem \
|
|
--tls-cert /etc/llmgw/mirror.pem \
|
|
--tls-key /etc/llmgw/mirror-key.pem
|
|
```
|
|
|
|
That is the same CA the agents already use, so the certificates come from the
|
|
authority you have:
|
|
|
|
```
|
|
llmgw ctl cert issue-server mirror --host models.internal --host 10.0.0.146
|
|
llmgw ctl cert issue-client control-plane
|
|
```
|
|
|
|
`--host` has to cover every address the API is called on, because that is what
|
|
a client verifies against. Both sides are checked — the mirror will not accept a
|
|
caller without a certificate from this CA, the same rule the agent stream
|
|
follows.
|
|
|
|
### nginx instead
|
|
|
|
If you already run nginx, it serves a directory perfectly well and you do not
|
|
need `mirror serve` for the bytes:
|
|
|
|
```nginx
|
|
server {
|
|
listen 80;
|
|
server_name models.internal;
|
|
|
|
root /srv/models;
|
|
|
|
# Read-only. This is deliberate: a mirror that can be written to over HTTP
|
|
# is a way to replace a model with another one, and the nodes would fetch
|
|
# it without complaint.
|
|
location / {
|
|
autoindex on;
|
|
# Large files, and the client is a machine with a resumable fetcher.
|
|
# Sendfile and no buffering keep memory flat regardless of size.
|
|
sendfile on;
|
|
tcp_nopush on;
|
|
}
|
|
}
|
|
```
|
|
|
|
`autoindex` is there so a person can browse what is available; llmgw does not
|
|
need it.
|
|
|
|
## Filling it
|
|
|
|
From the mirror itself:
|
|
|
|
```
|
|
llmgw mirror add \
|
|
--model qwen2.5-14b-instruct --version 2026-03 \
|
|
"https://huggingface.co/<repo>/resolve/<revision>/q4_k_m.gguf"
|
|
```
|
|
|
|
Use a revision rather than `main` in the source URL. `main` is what moves.
|
|
|
|
It downloads to a temporary name and renames when complete, so an interrupted
|
|
download never leaves something a catalog could be pointed at, and it prints the
|
|
sha256 when it lands. Compare that against what upstream publishes — Hugging
|
|
Face gives it as `lfs.oid` from
|
|
`https://huggingface.co/api/models/<repo>/tree/<revision>`. A mirror is the one
|
|
place in this system where a corrupt file is copied to every node instead of
|
|
being caught once.
|
|
|
|
It refuses to overwrite a version that is already there. Add a new one beside
|
|
it: that refusal is what makes the old weights still available when the new ones
|
|
turn out to be worse.
|
|
|
|
## Driving it from elsewhere
|
|
|
|
The same fetch over the API, so the control plane — or anything else — can ask
|
|
for a model without anyone logging in:
|
|
|
|
```
|
|
curl -X POST http://models.internal:8082/v1/models -d '{
|
|
"source": "https://huggingface.co/<repo>/resolve/<revision>/q4_k_m.gguf",
|
|
"model": "qwen2.5-14b-instruct",
|
|
"version": "2026-03"
|
|
}'
|
|
```
|
|
|
|
The mirror does the downloading. A control plane that pulled the bytes itself
|
|
and pushed them here would carry every model twice over the network, through the
|
|
one machine least able to spare the bandwidth.
|
|
|
|
It answers `202` with the URL a catalog entry should use, and the bytes are not
|
|
here yet. Poll for them:
|
|
|
|
| | |
|
|
|---|---|
|
|
| `GET /v1/models` | everything held or arriving, and the base to build URLs from |
|
|
| `POST /v1/models` | fetch this source into model/version |
|
|
| `GET /v1/models/{model}/{version}` | `fetching` with a byte count, `present`, or `failed` with the reason |
|
|
| `DELETE /v1/models/{model}/{version}` | remove a version; refuses while it is downloading |
|
|
| `GET /v1/health` | up *and* able to read its own directory |
|
|
|
|
A failed fetch stays `failed` with what went wrong, rather than disappearing:
|
|
otherwise the thing waiting on that model waits forever with nothing to say why.
|
|
Asking twice for the same download joins the one already running instead of
|
|
starting a rival writer on the same file.
|
|
|
|
`DELETE` takes the whole version, never one file of it. A model split across
|
|
several files is useless with one missing, and half a version on disk looks
|
|
present and will not load.
|
|
|
|
## Retiring a model
|
|
|
|
```
|
|
llmgw mirror unused # what no catalog entry refers to
|
|
llmgw mirror remove --model <m> --version <v> --file <f>
|
|
```
|
|
|
|
`unused` compares against every `source` in the running catalog, so the answer
|
|
is what can go rather than what looks old. It refuses to answer when it cannot
|
|
reach the control plane: an empty catalog and an unreachable one produce the
|
|
same list — everything — and the obvious next step after that list is deletion.
|
|
|
|
Retire the catalog entry first, let the fleet settle, then remove. A node that
|
|
has to refetch a file you deleted cannot, and the failure arrives later, on the
|
|
node, looking like something else.
|
|
|
|
## From the control plane's admin page
|
|
|
|
Configure the mirror on the control plane and the same operations appear at
|
|
`/admin/mirror`, behind the admin session the other admin pages use:
|
|
|
|
```yaml
|
|
mirror:
|
|
api: https://models.internal:8082
|
|
tls:
|
|
ca: /etc/llmgw/ca.crt
|
|
cert: /etc/llmgw/control-plane.crt
|
|
key: /etc/llmgw/control-plane.key
|
|
```
|
|
|
|
Leave the block out and the page is not mounted at all, which is the right page
|
|
for a deployment with no mirror — not an empty one pointing at nothing.
|
|
|
|
The page lists what is held, what is arriving with its byte count, and what
|
|
failed with the reason; it starts a fetch, retries a failed one, and removes a
|
|
version. It reloads itself only while something is downloading, because a reload
|
|
at any other moment takes with it whatever is half-typed in the form.
|
|
|
|
Two things it deliberately will not do. It cannot point the control plane at a
|
|
different mirror — that is configuration, and changing it needs access to the
|
|
host rather than a session. And it does not write the catalog for you: it shows
|
|
the URL an entry should use, and putting it there is still a deliberate act,
|
|
because that is the moment a model starts being served.
|
|
|
|
The address is a mistake worth naming: `api` is the **management** port, not the
|
|
one nodes fetch bytes from. They are two listeners so this one can be locked
|
|
down without re-certifying every agent's fetcher.
|
|
|
|
## Pointing llmgw at it
|
|
|
|
```yaml
|
|
- name: qwen2.5-14b-instruct
|
|
source: http://models.internal/qwen2.5-14b-instruct/2026-03/q4_k_m.gguf
|
|
scaling: { min: 2, max: 2 }
|
|
profiles:
|
|
- name: gpu
|
|
engine: vllm
|
|
gpu_class: h100-80g
|
|
tp: 1
|
|
```
|
|
|
|
Each node fetches once, in the background, before its engine starts — and
|
|
`llmgw ctl replicas` shows how far along it is while it does. A killed download
|
|
resumes rather than restarting, and a partial file is never visible under the
|
|
model's name, so an interrupted fetch cannot be handed to an engine.
|
|
|
|
## What this is not
|
|
|
|
It is not a cache. Nothing populates it on its own: a model is there because
|
|
somebody asked for it, by hand or over the API, and one nobody asked for is not
|
|
there. That is on purpose. A mirror that fetched on demand would reach the
|
|
internet at the least convenient moment, and in a network where that is blocked
|
|
it would fail in a way that looks like llmgw being broken.
|
|
|
|
It is also not a replacement for a shared filesystem. If you already run one
|
|
that every node mounts, put the models on it and use a path as the `source` —
|
|
nothing is fetched at all, and llmgw notices the filesystem is shared and will
|
|
not count the same free space once per node.
|
|
|
|
## When this stops being enough
|
|
|
|
Somewhere above ten or twenty nodes, one mirror serving a forty gigabyte file
|
|
to all of them at once becomes the bottleneck instead of the fix. At that point
|
|
the options are a filer, a second mirror per rack, or distribution between the
|
|
nodes themselves.
|
|
|
|
llmgw knows which nodes already hold which weights — it is in `ctl status` as
|
|
"N models on X/Y nodes" — so distributing between them is a feature it is
|
|
positioned to add. It has not been built, because a mirror is enough for
|
|
everyone who has needed it so far.
|