Files
llmgw/internal/web/admin_mirror.go
T
Ilker ManapandClaude Opus 5.5 22bb098247 Group the mirror's table by model family, not by exact name
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>
2026-09-28 23:44:55 +02:00

652 lines
22 KiB
Go

package web
import (
"context"
"errors"
"fmt"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/gguf"
"html/template"
"net/http"
"sort"
"strconv"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/hf"
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
"gitea.mynodes.xyz/ilker/llmgw/internal/sourcecheck"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// MirrorAdmin is the mirror's management API as this page needs it.
//
// An interface rather than the client itself so the page can be tested without
// a mirror, and so a deployment without one leaves it nil and the page
// unmounted — the same shape the cluster dashboard uses.
type MirrorAdmin interface {
List(ctx context.Context) (mirror.ModelsResponse, error)
Fetch(ctx context.Context, req mirror.FetchRequest) (mirror.FetchResponse, error)
Remove(ctx context.Context, model, version string) ([]string, error)
// Addr is the mirror this points at, shown on the page because an operator
// looking at an empty table needs to know which machine is empty.
Addr() string
}
// Registry is the model registry the explorer browses. Nil leaves the explorer
// off, which is right for a deployment with no way out to the internet: a
// search box that can only time out is worse than no search box.
type Registry interface {
Explore(ctx context.Context, q hf.Query) ([]hf.Model, error)
Repo(ctx context.Context, id string) (hf.Repo, error)
}
// repoSet is one thing worth fetching: a file, or every shard of one.
type repoSet struct {
// Verdict is whether the nodes' llama.cpp can load it, from the
// architecture the registry reports; a set that cannot be served is
// shown with the reason and no fetch button.
Verdict gguf.Verdict
hf.FileSet
// Sources are every file in the set, in the order the registry listed them.
// A shard set goes as one action — a fifteen-shard model is not fifteen
// decisions, it is one.
Sources []repoSource
// Model and Version are what the fetch form starts with. Editable, because
// the name a catalog entry uses is the operator's to choose and the
// registry's idea of it is only a good guess.
Model string
Version string
}
type repoSource struct{ URL, Path string }
// modelUse is a catalog model that runs a mirrored version, and how.
type modelUse struct {
Name string
Mode string // always, ondemand or stopped
Replicas int
// Declared models are read from server.yaml on every start, so a mode
// changed here would revert; the page says where to change it instead.
Declared bool
}
// mirrorRow is one model version as the page shows it.
type mirrorRow struct {
mirror.Status
// URL is what a catalog entry should say. The one string on this page
// somebody will copy, so it is rendered rather than left to be assembled.
URL string
Percent int
// Serve is the offer to put this version into service, open on the row the
// operator clicked. Nil on a row that is still arriving: a catalog entry
// pointing at a partial file is the thing versioned paths exist to prevent.
Serve *serveForm
// HasPercent is false when the upstream never sent a length. The bar is
// then not drawn at all: a progress bar that has to invent its own total is
// worse than a byte count that only goes up.
HasPercent bool
// Files is how many files a held version has. A version of several is one
// row — a fifteen-shard model is one thing to serve or remove, and fifteen
// rows of it pushed everything else off the page.
Files int
// UsedBy is the catalog models whose source is this row's address, and how
// each runs, so a file in use does not look like one waiting to be served
// and how it runs can be changed where it is seen.
UsedBy []modelUse
}
type adminMirrorData struct {
Nav Nav
Addr string
Base string
Rows []mirrorRow
// Groups are the same rows under their model.
Groups []mirrorGroup
Fetching int
Present int
Failed int
// Unreachable separates "the mirror holds nothing" from "the mirror did not
// answer". They render almost identically and mean opposite things: one is
// a mirror waiting to be filled, the other is a machine to go and look at.
Unreachable bool
// HasCatalog is false where the catalogue cannot be changed at runtime, and
// the serve action is then not offered rather than offered and refused.
HasCatalog bool
// The explorer.
HasRegistry bool
Query string
Explore *explorer
// Back is the listing an open repository was reached from.
Back template.URL
// Inline is a repository rendered beneath its row in the listing, without
// the page around it.
Inline bool
// Card is the open repository's model card, rendered.
Card template.HTML
// RepoURL is the open repository on the registry's own site.
RepoURL string
Repo *hf.Repo
RepoSets []repoSet
RegistryErr string
Message string
Error string
CSRF string
Version string
Now string
// RefreshSec is 0 when nothing is downloading. The page reloads only while
// there is something to watch, because a reload while an operator is typing
// a source URL into the form takes the URL with it.
RefreshSec int
}
// RegisterAdminMirror mounts the model mirror under /admin/mirror.
//
// Fetching and removing, and nothing else. What the mirror holds is operator
// state rather than account state, but the reasoning is the same as
// /admin/users: these two are the work somebody does repeatedly and should not
// need a shell for, while anything that would let a stolen session reach
// further — pointing the control plane at a different mirror, say — stays in
// configuration where changing it means access to the host.
// MirrorDeps is what the mirror page can reach.
//
// A struct rather than five parameters: three of them are optional interfaces
// and two are the same shape, which at a call site is a swap nobody notices
// until a page shows the wrong thing.
type MirrorDeps struct {
Mirror MirrorAdmin
// Registry browses a model registry. Nil turns the explorer off.
Registry Registry
// Catalog puts a mirrored model into service. Nil leaves the serve action
// off, which is right where the catalogue cannot be changed at runtime.
Catalog CatalogAdmin
// Fleet is the cluster, for saying whether anything could run a model
// before somebody spends an hour downloading it.
Fleet Source
}
func (p *AccountPages) RegisterAdminMirror(mux *http.ServeMux, d MirrorDeps) {
m := d.Mirror
p.hasMirror = true
p.registry = d.Registry
p.catalog = d.Catalog
p.fleet = d.Fleet
mux.HandleFunc("GET /admin/mirror", p.adminOnly(
func(w http.ResponseWriter, r *http.Request, _ store.User) {
p.renderMirror(w, r, m, "", "")
}))
// One repository as a fragment, for the row beneath it in the listing.
mux.HandleFunc("GET /admin/mirror/repo", p.adminOnly(
func(w http.ResponseWriter, r *http.Request, _ store.User) {
repo := strings.TrimSpace(r.URL.Query().Get("repo"))
if p.registry == nil || repo == "" {
http.NotFound(w, r)
return
}
d := adminMirrorData{CSRF: csrfFor(r), Inline: true, HasRegistry: true}
p.openRepo(r, &d, repo)
if d.RegistryErr != "" {
http.Error(w, "The registry: "+d.RegistryErr, http.StatusBadGateway)
return
}
render(w, "mirror_repo", d)
}))
mux.HandleFunc("POST /admin/mirror/fetch", p.mirrorAction(m,
func(ctx context.Context, admin store.User, r *http.Request) (string, error) {
// One form posts one source or fifteen. A sharded model is one
// decision, not one per shard, and the mirror fetches each file
// independently anyway.
sources, files := r.Form["source"], r.Form["file"]
if len(sources) == 0 || strings.TrimSpace(sources[0]) == "" {
return "", errors.New("a source URL is required")
}
model := strings.TrimSpace(r.FormValue("model"))
version := strings.TrimSpace(r.FormValue("version"))
// Read before anything is downloaded: the header says whether the
// nodes can load it, and a file that cannot be served is an hour
// of download and a replica failing on every node.
if r.FormValue("force") == "" {
if v := p.checkSource(ctx, strings.TrimSpace(sources[0])); !v.Servable {
return "", fmt.Errorf("not fetched: %s. Tick \"fetch anyway\" to take it regardless", v.Reason)
}
}
var started int
for i, src := range sources {
src = strings.TrimSpace(src)
if src == "" {
continue
}
req := mirror.FetchRequest{Source: src, Model: model, Version: version}
if i < len(files) {
req.File = strings.TrimSpace(files[i])
}
if _, err := m.Fetch(ctx, req); err != nil {
// Reported with what already went, so a set that fails on
// its fourth shard does not read as though nothing started.
return "", fmt.Errorf("%d of %d started, then %s: %w",
started, len(sources), mirror.LastPathElement(src), err)
}
started++
}
// Named as started, not as done. The mirror answered 202 and the
// bytes are still moving; a message saying "added" would invite
// somebody to point the catalog at a partial file.
return fmt.Sprintf("started downloading %s/%s (%s)",
model, version, plural(started, "file", "files")), nil
}))
if d.Catalog != nil {
// How a model runs, changed where the file it runs is seen.
mux.HandleFunc("POST /admin/mirror/mode", p.mirrorAction(m,
func(ctx context.Context, admin store.User, r *http.Request) (string, error) {
name, mode := r.FormValue("model"), r.FormValue("mode")
if name == "" || (mode != modeAlways && mode != modeOnDemand && mode != modeStopped) {
return "", errors.New("choose a model and how it should run")
}
replicas, _ := strconv.Atoi(r.FormValue("replicas"))
sc := scalingFor(mode, replicas, 0)
if _, err := d.Catalog.Scale(ctx, name, sc, admin.Email); err != nil {
return "", err
}
switch mode {
case modeOnDemand:
return name + " now loads when asked for and stops after 15 minutes unused", nil
case modeStopped:
return name + " is stopped; its replicas are being taken down", nil
}
return name + " now runs all the time", nil
}))
mux.HandleFunc("POST /admin/mirror/serve", p.mirrorAction(m,
func(ctx context.Context, admin store.User, r *http.Request) (string, error) {
model, err := buildModel(r.FormValue)
if err != nil {
return "", err
}
// The same check `llmgw ctl model add` makes. This page builds
// the source out of a row the mirror reported, so it is right
// far more often than something typed — but a file removed
// between the listing and the click, or a mirror that has gone
// down since, both end as a replica failing on a node minutes
// later with an error naming the node. Asking costs one HEAD.
if _, err := sourcecheck.Check(ctx, model.Source); err != nil {
return "", err
}
if err := d.Catalog.Add(ctx, model, admin.Email); err != nil {
return "", err
}
// Placed on the next pass, not now: saying it is serving would
// be wrong for however long the weights take to reach a node.
return fmt.Sprintf(
"%s added to the catalog, %s; it is placed on the next pass",
model.Name, plural(model.Scaling.Min, "replica", "replicas")), nil
}))
}
mux.HandleFunc("POST /admin/mirror/{model}/{version}/remove", p.mirrorAction(m,
func(ctx context.Context, _ store.User, r *http.Request) (string, error) {
model, ver := r.PathValue("model"), r.PathValue("version")
removed, err := m.Remove(ctx, model, ver)
if err != nil {
return "", err
}
return "removed " + model + "/" + ver +
" (" + plural(len(removed), "file", "files") + ")", nil
}))
}
// mirrorAction wraps a change: admin session, CSRF, audit line, re-render.
//
// The audit line is not optional. Removing a version deletes weights every node
// may be about to fetch, and the only record that it happened on purpose is
// this log entry naming who clicked it.
func (p *AccountPages) mirrorAction(m MirrorAdmin,
do func(ctx context.Context, admin store.User, r *http.Request) (string, error),
) http.HandlerFunc {
return p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
sess, _, _ := p.session(r)
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
msg, err := do(r.Context(), admin, r)
if err != nil {
p.log.Warn("a mirror action was refused",
"admin", admin.Email, "mirror", m.Addr(), "err", err)
p.renderMirror(w, r, m, "", err.Error())
return
}
p.log.Info("the mirror was changed",
"admin", admin.Email, "mirror", m.Addr(), "what", msg)
p.renderMirror(w, r, m, msg, "")
})
}
func (p *AccountPages) renderMirror(w http.ResponseWriter, r *http.Request,
m MirrorAdmin, msg, errMsg string,
) {
d := adminMirrorData{
Nav: navFrom(r, "mirror"),
Addr: m.Addr(),
Message: msg,
Error: errMsg,
CSRF: csrfFor(r),
Version: p.version,
Now: time.Now().UTC().Format(time.RFC3339),
}
d.HasCatalog = p.catalog != nil
p.explore(r, &d)
got, err := m.List(r.Context())
if err != nil {
p.log.Error("could not read the mirror", "mirror", m.Addr(), "err", err)
d.Unreachable = true
if d.Error == "" {
d.Error = err.Error()
}
render(w, "admin_mirror", d)
return
}
d.Base = got.Base
var sources map[string][]catalog.Model
if p.catalog != nil {
if sources, err = p.catalog.Sources(r.Context()); err != nil {
p.log.Warn("could not read which models use the mirror", "err", err)
}
}
held := map[string]int{} // model/version -> index of its row
for _, st := range got.Models {
switch st.State {
case mirror.Fetching:
d.Fetching++
case mirror.Failed:
d.Failed++
default:
key := st.Model + "/" + st.Version
if i, ok := held[key]; ok {
// Another file of a version already shown: counted into it.
d.Rows[i].Files++
d.Rows[i].BytesDone += st.BytesDone
d.Rows[i].URL = strings.TrimSuffix(
mirror.Entry{Model: st.Model, Version: st.Version}.URL(got.Base), "/") + "/"
continue
}
held[key] = len(d.Rows)
d.Present++ // versions, not files: what the rows below count
}
row := mirrorRow{
Status: st,
Files: 1,
URL: mirror.Entry{Model: st.Model, Version: st.Version, File: st.File}.
URL(got.Base),
}
if pc, ok := st.Percent(); ok {
row.Percent, row.HasPercent = pc, true
}
d.Rows = append(d.Rows, row)
}
for i := range d.Rows {
for _, m := range sources[d.Rows[i].URL] {
d.Rows[i].UsedBy = append(d.Rows[i].UsedBy, modelUse{
Name: m.Name, Mode: modeOf(m.Scaling), Replicas: max(m.Scaling.Max, 1),
Declared: p.catalog.Declared(m.Name),
})
}
}
// The one row whose serve form is open, and only when the catalog can be
// changed at all.
if p.catalog != nil {
if want := r.URL.Query().Get("serve"); want != "" {
byVersion := map[string][]mirror.Status{}
for _, st := range got.Models {
byVersion[st.Model+"/"+st.Version] = append(byVersion[st.Model+"/"+st.Version], st)
}
for i := range d.Rows {
key := d.Rows[i].Model + ":" + d.Rows[i].Version
if key != want || d.Rows[i].State != mirror.Present {
continue
}
form := planServe(d.Rows[i].Status,
byVersion[d.Rows[i].Model+"/"+d.Rows[i].Version], got.Base, p.fleet, p.catalog, func(src string) gguf.Verdict { return p.checkSource(r.Context(), src) })
d.Rows[i].Serve = &form
break
}
}
}
// Downloads first, then failures, then what is simply here. The two states
// somebody opened this page for are at the top; the rest is a list they
// scroll. Within a state the order is fixed so rows do not shuffle between
// reloads while a byte count ticks.
sort.SliceStable(d.Rows, func(i, j int) bool {
a, b := d.Rows[i], d.Rows[j]
if ra, rb := mirrorRank(a.State), mirrorRank(b.State); ra != rb {
return ra < rb
}
if a.Model != b.Model {
return a.Model < b.Model
}
if a.Version != b.Version {
return a.Version < b.Version
}
return a.File < b.File
})
d.Groups = groupByModel(d.Rows)
if d.Fetching > 0 {
d.RefreshSec = 5
}
render(w, "admin_mirror", d)
}
// mirrorGroup is a family of models the mirror holds — every qwen, every
// llama — and their versions.
type mirrorGroup struct {
Family string
Rows []mirrorRow
// Models is how many different models are in it; a family of one is
// shown without a heading, which would only repeat its row.
Models int
// Bytes is what the versions take on disk, downloads so far included.
Bytes int64
Fetching, Failed int
InUse bool
}
// modelFamily is the family a model's name puts it in: the letters its name
// starts with. "qwen2.5-coder-7b-instruct" and "qwen3-8b" are qwen,
// "llama-3.1-8b-instruct" is llama, "deepseek-r1-distill-qwen-14b" is
// deepseek — which is how people talk about them, and all a name says.
func modelFamily(model string) string {
name := strings.ToLower(model)
end := strings.IndexFunc(name, func(r rune) bool { return r < 'a' || r > 'z' })
if end <= 0 {
// Starts with a digit or a symbol: the first part whole.
first, _, _ := strings.Cut(name, "-")
return first
}
return name[:end]
}
// groupByModel gathers rows by family, in the order the rows are already in:
// a family with a download or a failure comes first because its first row
// does, and its models and versions stay in that order beneath it.
//
// Grouped because a family is what somebody looks for. Sorted by state and
// name alone, "which qwens do we have" meant reading the whole table.
func groupByModel(rows []mirrorRow) []mirrorGroup {
var out []mirrorGroup
at := map[string]int{}
models := map[string]map[string]bool{}
for _, r := range rows {
fam := modelFamily(r.Model)
i, ok := at[fam]
if !ok {
i = len(out)
at[fam] = i
out = append(out, mirrorGroup{Family: fam})
models[fam] = map[string]bool{}
}
g := &out[i]
g.Rows = append(g.Rows, r)
g.Bytes += r.BytesDone
models[fam][r.Model] = true
switch r.State {
case mirror.Fetching:
g.Fetching++
case mirror.Failed:
g.Failed++
}
if len(r.UsedBy) > 0 {
g.InUse = true
}
}
for i := range out {
out[i].Models = len(models[out[i].Family])
}
return out
}
func mirrorRank(s mirror.State) int {
switch s {
case mirror.Fetching:
return 0
case mirror.Failed:
return 1
default:
return 2
}
}
func plural(n int, one, many string) string {
if n == 1 {
return "1 " + one
}
return strconv.Itoa(n) + " " + many
}
// explore fills in the registry half of the page: a listing, or one repository.
//
// A failure here is reported in its own line rather than as the page's error.
// The registry is somebody else's server on the other side of the internet, and
// it being down says nothing about the mirror — which is the thing this page is
// actually for, and which must keep working when Hugging Face does not.
func (p *AccountPages) explore(r *http.Request, d *adminMirrorData) {
if p.registry == nil {
return
}
d.HasRegistry = true
d.Query = strings.TrimSpace(r.URL.Query().Get("q"))
if repo := strings.TrimSpace(r.URL.Query().Get("repo")); repo != "" {
d.Back = exploreState(r.URL.Query()).href(d.Query, "", "")
p.openRepo(r, d, repo)
return
}
p.exploreList(r, d)
}
// openRepo reads one repository: its card, and its weights grouped into what
// can be fetched.
func (p *AccountPages) openRepo(r *http.Request, d *adminMirrorData, repo string) {
got, err := p.registry.Repo(r.Context(), repo)
if err != nil {
d.RegistryErr = err.Error()
return
}
d.Repo = &got
d.RepoURL = strings.TrimSuffix(got.DownloadURL("", ""), "/resolve/"+revOrMain(got.Revision)+"/")
d.Card = renderCard(got.Card, d.RepoURL+"/blob/"+revOrMain(got.Revision)+"/")
var weights []hf.File
for _, f := range got.Files {
if !f.IsWeights() {
// Thirty files of which four are worth gigabytes. The rest are
// a README and a tokenizer, and listing them buries the ones
// somebody came for.
continue
}
weights = append(weights, f)
}
for _, set := range hf.Group(weights) {
rs := repoSet{
FileSet: set,
Model: defaultModelName(got.ID),
// The revision, not the date: two fetches of the same commit
// land in the same place, and a repository re-uploaded under
// the same branch lands somewhere else.
Version: got.ShortRevision(),
}
rs.Verdict = p.verdictFor(got.Architecture, set.Name)
for _, f := range set.Files {
rs.Sources = append(rs.Sources,
repoSource{URL: got.DownloadURL("", f.Path), Path: f.Path})
}
d.RepoSets = append(d.RepoSets, rs)
}
}
// defaultModelName is the catalog name a repository suggests.
//
// The last element, lowercased, with the quantisation marker dropped: the owner
// is not part of what anybody calls the model, and "-GGUF" describes the file
// format rather than the model, which is already clear from the file being
// fetched.
func defaultModelName(repoID string) string {
name := mirror.LastPathElement(repoID)
name = strings.TrimSuffix(name, "-GGUF")
name = strings.TrimSuffix(name, "-gguf")
return strings.ToLower(name)
}
// verdictFor judges a registry file set by the repository's architecture. A
// vision projector is its own architecture whatever the repository says.
func (p *AccountPages) verdictFor(arch, name string) gguf.Verdict {
if strings.Contains(strings.ToLower(name), "mmproj") {
return gguf.LlamaCpp(p.llamaVersion, "clip")
}
// Published beside the quantisations they were made with, and a GGUF
// file like them, so the repository's architecture would pass it.
if strings.Contains(strings.ToLower(name), "imatrix") {
return gguf.Verdict{Checked: true,
Reason: "it is an importance matrix for quantising, not a model"}
}
if arch == "" || !gguf.IsGGUF(name) {
return gguf.Verdict{Servable: true}
}
return gguf.LlamaCpp(p.llamaVersion, arch)
}
// checkSource reads a GGUF source's header and judges it; anything else, or a
// header that cannot be read, passes.
func (p *AccountPages) checkSource(ctx context.Context, source string) gguf.Verdict {
url, ok := sourcecheck.HTTPSource(source)
if !ok || !gguf.IsGGUF(url) || p.llamaVersion == "" {
return gguf.Verdict{Servable: true}
}
return gguf.Check(ctx, url, p.llamaVersion)
}
func revOrMain(rev string) string {
if rev == "" {
return "main"
}
return rev
}