Grouped by name, the four qwens in the mirror were four groups of one and the table looked unchanged. A family is the letters a name starts with - qwen2.5-coder-7b and qwen3-8b are qwen, llama-3.1-8b is llama - and a family of two or more models gets a heading with how many and how much disk; a family of one stays a plain row. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
375 lines
19 KiB
HTML
375 lines
19 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="{{stylesheet}}">
|
|
</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>
|
|
{{template "nav" .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 class="compact held">
|
|
<tr><th>Model</th><th>Files</th><th class="num">Size</th><th>Runs</th><th></th></tr>
|
|
{{range .Groups}}{{$grouped := gt .Models 1}}
|
|
{{if $grouped}}
|
|
<tr class="model-group"><td colspan="5"><strong>{{.Family}}</strong>
|
|
<span class="opt">{{.Models}} models · {{bytes .Bytes}}</span>
|
|
{{if .Fetching}}<span class="tag new">downloading</span>{{end}}
|
|
{{if .Failed}}<span class="tag bad">failed</span>{{end}}
|
|
{{if .InUse}}<span class="tag">in use</span>{{end}}</td></tr>
|
|
{{end}}
|
|
{{range .Rows}}<tr{{if $grouped}} class="model-version"{{end}}>
|
|
<td><strong>{{.Model}}</strong> <span class="opt">{{.Version}}</span></td>
|
|
<td class="file" title="{{.File}}{{if .SHA256}} · sha256 {{.SHA256}}{{end}}">
|
|
{{if gt .Files 1}}{{.Files}} files{{else}}<code>{{.File}}</code>{{end}}</td>
|
|
<td class="num">
|
|
{{if eq (printf "%s" .State) "fetching"}}
|
|
{{if .HasPercent}}{{.Percent}}% of {{bytes .BytesTotal}}
|
|
{{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 eq (printf "%s" .State) "fetching"}}<span class="state state-starting">downloading</span>
|
|
{{else if eq (printf "%s" .State) "failed"}}<span class="state state-failed">failed</span>
|
|
{{else if .UsedBy}}{{$row := .}}{{range .UsedBy}}<div class="use">
|
|
{{/* Named only when the catalog calls it something other than the
|
|
mirror does; otherwise it is the model column again. */}}
|
|
{{if ne .Name $row.Model}}<span class="as" title="{{.Name}}">as {{.Name}}</span>{{end}}
|
|
{{if .Declared}}<span class="opt">{{if eq .Mode "always"}}always on{{else if eq .Mode "ondemand"}}on demand{{else}}stopped{{end}}<br>set in server.yaml</span>
|
|
{{else}}
|
|
{{/* Submitted on change: the choice is the action, and a second click
|
|
to confirm it would only be a way to forget to. */}}
|
|
<form method="post" action="/admin/mirror/mode" class="inline">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<input type="hidden" name="model" value="{{.Name}}">
|
|
<input type="hidden" name="replicas" value="{{.Replicas}}">
|
|
<select name="mode" class="mode" onchange="this.form.submit()" aria-label="How {{.Name}} runs">
|
|
<option value="always"{{if eq .Mode "always"}} selected{{end}}>always on</option>
|
|
<option value="ondemand"{{if eq .Mode "ondemand"}} selected{{end}}>on demand</option>
|
|
<option value="stopped"{{if eq .Mode "stopped"}} selected{{end}}>stopped</option>
|
|
</select>
|
|
<noscript><button type="submit" class="link">set</button></noscript>
|
|
</form>
|
|
{{end}}
|
|
</div>{{end}}
|
|
{{else}}<span class="opt">not served</span>{{end}}
|
|
</td>
|
|
<td class="actions">
|
|
{{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 eq (printf "%s" .State) "present"}}
|
|
<button type="button" class="link copy-url" data-url="{{.URL}}" title="{{.URL}}">copy address</button>
|
|
{{if and $.HasCatalog (not .UsedBy)}}{{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?{{if .UsedBy}} It is in use by the catalog.{{end}}')">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<button type="submit" class="link danger">remove</button>
|
|
</form>
|
|
{{end}}
|
|
</td>
|
|
</tr>
|
|
{{if .Err}}<tr><td colspan="5" class="error-row">{{.Err}}</td></tr>{{end}}
|
|
|
|
{{with .Serve}}
|
|
<tr class="editing"><td colspan="5">
|
|
{{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>
|
|
<label>Runs
|
|
<select name="mode">
|
|
<option value="always">always on</option>
|
|
<option value="ondemand">on demand: loads when asked, stops after 15 min unused</option>
|
|
</select></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}}
|
|
{{end}}
|
|
</table>
|
|
<p class="note">“copy address” gives what a catalog entry's <code>source</code>
|
|
should say — the file, or for a version of several files the directory, with its
|
|
trailing slash. It is rendered rather than assembled by hand because the mirror
|
|
and the catalog have to spell a file the same way, which is also how this page
|
|
knows which models use it.</p>
|
|
<script>
|
|
document.addEventListener('click', function (e) {
|
|
var b = e.target.closest('.copy-url');
|
|
if (!b) return;
|
|
var done = function (t) { var o = b.textContent; b.textContent = t; setTimeout(function () { b.textContent = o; }, 1500); };
|
|
if (navigator.clipboard) {
|
|
navigator.clipboard.writeText(b.dataset.url).then(function () { done('copied'); },
|
|
function () { window.prompt('Address', b.dataset.url); });
|
|
} else { window.prompt('Address', b.dataset.url); }
|
|
});
|
|
</script>
|
|
{{else}}<p class="empty">The mirror is reachable and holds nothing yet.</p>{{end}}
|
|
|
|
{{if .HasRegistry}}
|
|
<h2>Explore models</h2>
|
|
{{if not .Repo}}{{with .Explore}}
|
|
<nav class="pills" aria-label="Which models">{{range .Views}}<a href="{{.Href}}"{{if .Active}} class="on" aria-current="page"{{end}}>{{.Label}}</a>{{end}}</nav>
|
|
<div class="explore-filters">
|
|
<nav class="pills small" aria-label="Task">{{range .Tasks}}<a href="{{.Href}}"{{if .Active}} class="on" aria-current="true"{{end}}>{{.Label}}</a>{{end}}</nav>
|
|
<nav class="pills small" aria-label="Size">{{range .Sizes}}<a href="{{.Href}}"{{if .Active}} class="on" aria-current="true"{{end}}>{{.Label}}</a>{{end}}</nav>
|
|
</div>
|
|
{{end}}
|
|
<form method="get" action="/admin/mirror" class="explore-search">
|
|
{{with .Explore}}<input type="hidden" name="view" value="{{.View}}">
|
|
<input type="hidden" name="task" value="{{.Task}}">
|
|
<input type="hidden" name="size" value="{{.Size}}">
|
|
<input type="hidden" name="x" value="1">{{end}}
|
|
<input type="search" name="q" value="{{.Query}}" placeholder="Search Hugging Face — qwen coder, gemma, bge…" aria-label="Search Hugging Face">
|
|
<label class="check"><input type="checkbox" name="here" value="1" onchange="this.form.submit()"{{if .Explore}}{{if .Explore.Here}} checked{{end}}{{end}}> only what these nodes can run</label>
|
|
<button type="submit">Search</button>
|
|
</form>
|
|
{{end}}
|
|
|
|
{{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 not .Repo}}{{with .Explore}}
|
|
{{if .Rows}}
|
|
<table class="explore">
|
|
<tr><th>Repository</th><th class="num">Size</th><th>Architecture</th><th class="num">Context</th>
|
|
<th class="num">Downloads</th><th class="num">Likes</th><th class="num">Updated</th></tr>
|
|
{{range .Rows}}<tr class="explore-row">
|
|
<td><a href="{{.Href}}" class="open-repo" data-repo="{{.ID}}" aria-expanded="false"><code>{{.ID}}</code></a>
|
|
{{if .New}}<span class="tag new">new</span>{{end}}{{if .Gated.Is}}<span class="tag">gated</span>{{end}}
|
|
{{if or .BaseModel .License}}<div class="from">{{with .BaseModel}}from {{.}}{{end}}{{if and .BaseModel .License}} · {{end}}{{.License}}</div>{{end}}</td>
|
|
<td class="num">{{.ParamsLabel}}</td>
|
|
<td>{{if .Architecture}}<span{{if not .Verdict.Servable}} class="cannot"{{end}} title="{{.Verdict.Reason}}">{{.Architecture}}{{if .Verdict.Checked}}{{if .Verdict.Servable}} ✓{{else}} ✗{{end}}{{end}}</span>{{else}}—{{end}}</td>
|
|
<td class="num">{{.ContextLabel}}</td>
|
|
<td class="num">{{compact .Downloads}}</td>
|
|
<td class="num">{{compact .Likes}}</td>
|
|
<td class="num">{{.Age}}</td>
|
|
</tr>{{end}}
|
|
</table>
|
|
<script>
|
|
// A repository opens beneath its row rather than replacing the list: the
|
|
// list is what somebody is choosing from, and losing it to read one card
|
|
// means finding their place again for the next. The link still goes to the
|
|
// repository's own page when this does not run, or with a modifier key.
|
|
document.querySelector("table.explore").addEventListener("click", async function (e) {
|
|
const a = e.target.closest("a.open-repo");
|
|
if (!a || e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
|
e.preventDefault();
|
|
const row = a.closest("tr");
|
|
let detail = row.nextElementSibling;
|
|
if (detail && detail.classList.contains("repo-detail")) {
|
|
detail.hidden = !detail.hidden;
|
|
a.setAttribute("aria-expanded", String(!detail.hidden));
|
|
row.classList.toggle("open", !detail.hidden);
|
|
return;
|
|
}
|
|
detail = document.createElement("tr");
|
|
detail.className = "repo-detail";
|
|
const td = document.createElement("td");
|
|
td.colSpan = row.children.length;
|
|
td.textContent = "Loading…";
|
|
detail.appendChild(td);
|
|
row.after(detail);
|
|
row.classList.add("open");
|
|
a.setAttribute("aria-expanded", "true");
|
|
try {
|
|
const resp = await fetch("/admin/mirror/repo?repo=" + encodeURIComponent(a.dataset.repo));
|
|
const text = await resp.text();
|
|
if (!resp.ok) { td.textContent = text.slice(0, 300); td.classList.add("error-row"); return; }
|
|
// Our own server's rendering of the repository, the card already
|
|
// stripped of anything that runs: the same HTML the full page shows.
|
|
td.innerHTML = text;
|
|
} catch (err) {
|
|
td.textContent = "Could not load it: " + err;
|
|
}
|
|
});
|
|
</script>
|
|
{{if .Hidden}}<p class="note">{{.Hidden}} more {{if eq .Hidden 1}}is{{else}}are{{end}} hidden: their architecture is one the nodes' llama.cpp cannot load.
|
|
Untick “only what these nodes can run” to see them.</p>{{end}}
|
|
<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 not $.RegistryErr}}
|
|
<p class="empty">{{if $.Query}}Nothing matched “{{$.Query}}”{{else}}Nothing to list{{end}}{{if .Hidden}} that these nodes can run — {{.Hidden}} {{if eq .Hidden 1}}result was{{else}}results were{{end}} hidden for an architecture llama.cpp cannot load{{end}}.</p>
|
|
{{end}}
|
|
{{end}}{{end}}
|
|
|
|
{{if .Repo}}{{template "mirror_repo" .}}{{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>
|
|
<label class="inline-check"><input type="checkbox" name="force" value="1">
|
|
Fetch anyway <span class="opt">even if the nodes' llama.cpp cannot load it</span></label>
|
|
<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}}
|
|
|
|
{{/* One repository: its card and its files. The whole page when opened by
|
|
address, and the row beneath a listing entry when opened from the list. */}}
|
|
{{define "mirror_repo"}}
|
|
{{if not .Inline}}<p><a href="{{.Back}}">← back to the list</a></p>
|
|
<h3>{{.Repo.ID}} <span class="opt">at {{.Repo.ShortRevision}}</span></h3>{{end}}
|
|
<p class="repo-meta">
|
|
{{if .Inline}}<span>at <code>{{.Repo.ShortRevision}}</code></span>{{end}}
|
|
{{with .Repo.BaseModel}}<span>from {{range $i, $b := .}}{{if $i}}, {{end}}<code>{{$b}}</code>{{end}}</span>{{end}}
|
|
{{with .Repo.License}}<span>license {{.}}</span>{{end}}
|
|
{{with .Repo.Languages}}<span>{{join .}}</span>{{end}}
|
|
<span><a href="{{.RepoURL}}" rel="noopener noreferrer">on Hugging Face ↗</a></span>
|
|
</p>
|
|
{{if .Card}}
|
|
<details class="model-card" open>
|
|
<summary>Model card</summary>
|
|
<div class="card-body">{{.Card}}</div>
|
|
</details>
|
|
{{end}}
|
|
{{if .RepoSets}}
|
|
<table class="repo-files">
|
|
<tr><th>What</th><th class="num">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>
|
|
{{if not .Verdict.Servable}}
|
|
<span class="state state-failed" title="{{.Verdict.Reason}}">cannot be served</span>
|
|
<div class="note">{{.Verdict.Reason}}</div>
|
|
{{else}}
|
|
{{if .Verdict.Architecture}}<span class="opt" title="{{.Verdict.Reason}}">{{.Verdict.Architecture}}{{if .Verdict.Checked}} ✓{{end}}</span>{{end}}
|
|
<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>
|
|
{{end}}
|
|
</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}}
|