Files
llmgw/internal/web/templates/admin_mirror.html
T
Ilker ManapandClaude Opus 5 dfbb7557dd
CI / test (push) Successful in 1m42s
Serve a model made of several files
The mirror would happily hold a four-shard model that nothing could serve. A
catalog entry names one source, the agent fetched one URL, and so a version of
several files had no way to be described — which is every safetensors model,
and every GGUF too large to ship whole.

A source ending in a slash names a version rather than a file. The agent fetches
every file in it into one directory and hands the engine the directory: vLLM
reads the shards and the config out of the folder, and llama.cpp is given the
first part and finds the rest beside it.

The trailing slash is the whole signal. A flag beside the source would be a
second place for the two to disagree, and a URL already distinguishes a
directory from a file everywhere else.

The mirror answers a version with a manifest instead of a file listing. A plain
file server gives HTML there, and something that has to know what a version
consists of would be left parsing it. It is on the byte listener, not the
management API: fetching weights is the one thing every node does, and making
it depend on the administrative port would undo the split that keeps that port
closeable.

Built from the directory rather than a file written beside it. A manifest on
disk is a second record of what is there, and the two disagree the first time
somebody copies a file in by hand — which the mirror explicitly allows.

The whole set or nothing. A directory with three of four shards loads as a
broken model rather than failing to load, so a completion marker is written
last and an interrupted fetch starts the missing files again rather than being
found and served. Files already there at the right size are not fetched twice.

Progress is reported under the version's address, not each file's: the shards
waiting on it asked for the version, and progress under a file's own URL
reaches nobody.

A filename from the manifest is refused if it contains a separator. The mirror
is a place an operator copies files into by hand, so what it reports is not
automatically safe to join onto a path.

An HTML listing where a manifest was expected says so. "invalid character '<'"
sends somebody looking at the wrong thing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 21:07:26 +02:00

243 lines
12 KiB
HTML

{{define "admin_mirror"}}<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
{{/* Only while something is downloading. A reload at any other time would
take with it whatever the operator was typing into the form below. */}}
{{if .RefreshSec}}<meta http-equiv="refresh" content="{{.RefreshSec}}">{{end}}
<title>Mirror · llmgw</title><link rel="stylesheet" href="/_static/base.css">
</head><body><div class="wrap">
<header>
<h1>Model mirror</h1>
<p class="sub">
{{if .Unreachable}}{{.Addr}} did not answer{{else}}{{.Present}} held ·
{{.Fetching}} downloading · {{.Failed}} failed · {{.Addr}}{{end}}
</p>
<nav><a href="/admin">cluster</a> <a href="/admin/users">accounts</a>
<a href="/profile">your keys</a></nav>
</header>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
{{if .Message}}<p class="ok">{{.Message}}</p>{{end}}
{{if .Unreachable}}
{{/* Said outright rather than shown as an empty table. A mirror holding
nothing and a mirror that is not there look the same in a list of zero
rows, and they call for opposite responses. */}}
<p class="note">Nothing was read from the mirror, so this page is not a list of
what it holds — it is not currently reachable at <code>{{.Addr}}</code>. Check
that <code>llmgw-mirror</code> is running there and that its management port is
reachable from this machine.</p>
{{else}}
<h2>What the mirror holds</h2>
{{if .Rows}}
<table>
<tr><th>Model</th><th>Version</th><th>File</th><th>State</th><th>Size</th>
<th>Checksum</th><th></th></tr>
{{range .Rows}}<tr>
<td>{{.Model}}</td>
<td>{{.Version}}</td>
<td><code>{{.File}}</code></td>
<td class="state state-{{.State}}">{{.State}}</td>
<td class="num">
{{if eq (printf "%s" .State) "fetching"}}
{{if .HasPercent}}{{bytes .BytesDone}} / {{bytes .BytesTotal}} ({{.Percent}}%)
{{else}}{{/* No total came from upstream, so no percentage is invented —
a bar that guesses its own end and then stops moving is worse
than a number that only goes up. */}}
{{bytes .BytesDone}} so far{{end}}
{{else if eq (printf "%s" .State) "failed"}}—
{{else}}{{bytes .BytesDone}}{{end}}
</td>
<td>{{if .SHA256}}<code title="{{.SHA256}}">{{slice .SHA256 0 12}}</code>{{else}}—{{end}}</td>
<td>
{{if eq (printf "%s" .State) "fetching"}}
<span class="note">downloading</span>
{{else if eq (printf "%s" .State) "failed"}}
{{/* Retry, not remove. A fetch that failed left nothing on disk, so the
remove would come back "the mirror holds nothing for this" — a button
whose only outcome is a confusing error. Starting the same download
again is what somebody looking at a failed row wants, and the mirror
allows it: only a download still running is refused. */}}
<form method="post" action="/admin/mirror/fetch" class="inline">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="source" value="{{.Source}}">
<input type="hidden" name="model" value="{{.Model}}">
<input type="hidden" name="version" value="{{.Version}}">
<input type="hidden" name="file" value="{{.File}}">
<button type="submit" class="link">Retry</button>
</form>
{{else}}
{{if $.HasCatalog}}{{if .Serve}}<a href="/admin/mirror">close</a>
{{else}}<a href="/admin/mirror?serve={{.Model}}:{{.Version}}">serve</a>{{end}}
{{end}}
<form method="post" action="/admin/mirror/{{.Model}}/{{.Version}}/remove"
class="inline" onsubmit="return confirm('Remove {{.Model}}/{{.Version}} and every file in it?')">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button type="submit" class="link danger">Remove</button>
</form>
{{end}}
</td>
</tr>
{{if .Err}}<tr><td colspan="7" class="error-row">{{.Err}}</td></tr>{{end}}
{{if not .Err}}<tr><td colspan="7" class="url-row"><code>{{.URL}}</code></td></tr>{{end}}
{{with .Serve}}
<tr class="editing"><td colspan="7">
{{if .Refusal}}
{{/* Offered and then failing at placement is worse than not offered at all. */}}
<p class="error">Cannot be served as it stands: {{.Refusal}}.</p>
{{else}}
{{if .Warning}}<p class="note error-row">{{.Warning}}.</p>{{end}}
<form method="post" action="/admin/mirror/serve">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="source" value="{{.Source}}">
<input type="hidden" name="engine" value="{{.Engine}}">
<p class="sub">Serving <code>{{.Source}}</code> with <strong>{{.Engine}}</strong>.
{{if .Files}}The whole version, {{.Files}} files: the node fetches them into one
directory and the engine is given the directory.{{end}}</p>
<div class="field-row">
<label>Name <input type="text" name="name" value="{{.Model}}" required></label>
<label>Aliases <span class="opt">comma separated</span>
<input type="text" name="aliases" placeholder="coder-7b"></label>
<label>Replicas <input type="text" name="replicas" value="1" required></label>
</div>
{{/* Reaches clients through /v1/models, which for most of them is the only
view of what this gateway serves. Nine names and nothing to choose
between them is a choice made by guesswork. */}}
<label>What it is for <span class="opt">shown to clients in /v1/models</span>
<input type="text" name="description"
placeholder="general chat, strong Swedish; 14B, slower than the 8B"></label>
<div class="field-row">
<label>Context a request may use
<input type="text" name="context_length" value="4096" required></label>
<label>Output limit <span class="opt">optional</span>
<input type="text" name="max_output" value="1024"></label>
<label>Engine context pool <span class="opt">divided across slots</span>
<input type="text" name="ctx_size" value="16384"></label>
</div>
<div class="field-row">
<label>Profile name <input type="text" name="profile" value="{{.Profile}}"></label>
<label>Needs RAM (GB) <span class="opt">estimated from the weights</span>
<input type="text" name="min_memory_gb" value="{{.MinMemoryGB}}"></label>
<label>Only nodes labelled <span class="opt">key / value, optional</span>
<input type="text" name="selector_key" placeholder="role" size="8">
<input type="text" name="selector_value" placeholder="cpu-large" size="10"></label>
</div>
<button type="submit">Add to the catalog</button>
</form>
<p class="note">The context pool is what the engine is given; llama.cpp divides
it across its parallel slots, so the number one request may use is the smaller
one above.</p>
<p class="note">The memory figure is the weights plus half again for the KV pool
plus two for the engine — an estimate, and editable. It is filled in rather than
left blank because a CPU profile has no card to be short of: with no figure,
every ready node is a candidate and the first one picked is whichever the
scheduler reached, not whichever can hold the model. A node label does the same
job by name if you would rather pin it.</p>
{{end}}
</td></tr>
{{end}}
{{end}}
</table>
<p class="note">The address under each row is what a catalog entry should say.
It is rendered here rather than assembled by hand because the mirror and the
catalog have to spell the same file the same way — two spellings is what makes a
file that is in use look unreferenced.</p>
{{else}}<p class="empty">The mirror is reachable and holds nothing yet.</p>{{end}}
{{if .HasRegistry}}
<h2>Find a model</h2>
<form method="get" action="/admin/mirror">
<label>Search Hugging Face
<input type="text" name="q" value="{{.Query}}" placeholder="qwen2.5 coder gguf"></label>
<button type="submit">Search</button>
</form>
{{if .RegistryErr}}
{{/* Its own line, not the page's error: the registry is somebody else's server
and it being down says nothing about the mirror, which is what this page is
for and has to keep working when Hugging Face does not. */}}
<p class="note error-row">The registry: {{.RegistryErr}}</p>
{{end}}
{{if .Results}}
<table><tr><th>Repository</th><th>Downloads</th><th>Task</th><th></th></tr>
{{range .Results}}<tr>
<td><code>{{.ID}}</code>{{if .Gated.Is}} <span class="opt">gated</span>{{end}}</td>
<td class="num">{{compact .Downloads}}</td>
<td>{{or .PipelineTag "—"}}</td>
<td><a href="/admin/mirror?q={{$.Query}}&amp;repo={{.ID}}">files</a></td>
</tr>{{end}}
</table>
<p class="note">A gated repository needs its terms accepted with a Hugging Face
account, and this browses anonymously — the files will list, and fetching one
will fail at the mirror. Download it by hand and put it in the mirror
directory, or give the mirror a token.</p>
{{else if .Query}}{{if not .RegistryErr}}
<p class="empty">Nothing matched “{{.Query}}”.</p>
{{end}}{{end}}
{{if .Repo}}
<h3>{{.Repo.ID}} <span class="opt">at {{.Repo.ShortRevision}}</span></h3>
{{if .RepoSets}}
<table>
<tr><th>What</th><th>Size</th><th>Published checksum</th><th></th></tr>
{{range .RepoSets}}<tr>
<td><code>{{.Name}}</code>
{{if .Shards}}<span class="opt">{{.Shards}} shards, fetched together</span>{{end}}</td>
<td class="num">{{bytes .Bytes}}</td>
<td>{{with .SHA256}}<code title="{{.}}">{{slice . 0 12}}</code>{{else}}—{{end}}</td>
<td>
<form method="post" action="/admin/mirror/fetch" class="inline">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
{{range .Sources}}<input type="hidden" name="source" value="{{.URL}}">
<input type="hidden" name="file" value="{{.Path}}">
{{end}}
<input type="text" name="model" value="{{.Model}}" size="24">
<input type="text" name="version" value="{{.Version}}" size="10">
<button type="submit">Fetch</button>
</form>
</td>
</tr>{{end}}
</table>
<p class="note">Only the weights are listed; a repository is mostly a README and
a tokenizer. Shards of one file are one row and one button — a fifteen-shard
model is one decision, not fifteen. Two separate files are two rows because they
are usually alternatives: one quantisation or the other, not both.</p>
<p class="note">The source URLs are pinned to
<code>{{.Repo.ShortRevision}}</code> rather than to a branch, so what arrives is
what is listed here even if the branch moves in between — and a single file's
checksum above is what the registry publishes, to compare with what the mirror
computes once it has it.</p>
{{else}}
<p class="empty">No weight files in {{.Repo.ID}}.</p>
{{end}}
{{end}}
{{end}}
<h2>Fetch a model</h2>
<p class="note">By hand, for anything the search does not reach.</p>
<form method="post" action="/admin/mirror/fetch">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Source URL
<input type="text" name="source" required
placeholder="https://huggingface.co/…/resolve/main/model.gguf"></label>
<div class="field-row">
<label>Model <input type="text" name="model" required placeholder="qwen2.5-coder-7b"></label>
<label>Version <input type="text" name="version" required placeholder="2026-09"></label>
<label>File <span class="opt">optional</span>
<input type="text" name="file" placeholder="taken from the URL"></label>
</div>
<button type="submit">Start download</button>
</form>
<p class="note">The mirror answers as soon as the download is under way, not when
it finishes — a model is tens of gigabytes and nothing here holds a connection
open for that. The row above will show it arriving. A version is never
overwritten: fetching into one that exists is refused, so a new build is a new
version and the old weights stay where they are.</p>
{{end}}
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span></footer>
</div></body></html>{{end}}