The form offered no placement and named every GGUF profile cpu. It now lists the fleet's GPU classes (the freest chosen) and the CPU, a GPU count and parallel requests, names the profile from the placement, shows what the model will run as, and refuses the CPU while GPUs are free unless that is ticked. Refs #2 (item 1). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
913 lines
31 KiB
Go
913 lines
31 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/gguf"
|
|
"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 {
|
|
// Kind is what the file says it is, read from its first bytes: "adapter"
|
|
// for a LoRA adapter, "model", or empty when it was not read. Arch is the
|
|
// architecture it names, which an adapter must share with its model.
|
|
Kind, Arch string
|
|
// 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
|
|
// Min and Max are its scaling as the catalog holds it.
|
|
Min, Max int
|
|
// Class is what it set, empty for its default; EffClass what that comes
|
|
// to. Owner is org:ID or empty for shared. MemoryGB and CPUCores are what
|
|
// a replica of its first profile reserves.
|
|
Class, EffClass, Owner string
|
|
MemoryGB, CPUCores int
|
|
// Adapters are the LoRA adapters it serves.
|
|
Adapters []catalog.Adapter
|
|
// Ready, Wanted and Nodes are how it runs now, for the row itself:
|
|
// replicas ready of those it should have, and the nodes they are on.
|
|
// Fleet is false where nothing could be asked, so the row says nothing
|
|
// rather than "0 of 0".
|
|
Fleet bool
|
|
Ready, Wanted int
|
|
Starting int
|
|
Nodes []nodeName
|
|
Parked bool
|
|
Unplaceable bool
|
|
// 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
|
|
// Detail is the card opened beneath the row: what runs it, on which
|
|
// nodes, and how many replicas it runs.
|
|
Detail *heldDetail
|
|
// Kind is what a single GGUF file is — "adapter" or "model" — and Arch
|
|
// its architecture; empty when not read.
|
|
Kind, Arch string
|
|
// Open is the row's ?open= value.
|
|
Open string
|
|
|
|
// 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
|
|
// Tab is the part of the page shown: held, explore or fetch. Tabs are the
|
|
// links to each.
|
|
Tab string
|
|
Tabs []mirrorTab
|
|
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/replicas", p.mirrorAction(m, p.setModel))
|
|
mux.HandleFunc("POST /admin/mirror/adapters", p.mirrorAction(m, p.setAdapters))
|
|
|
|
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 CPU while GPUs sit free is a choice to make on purpose:
|
|
// made by default, it was a model running at a tenth of the
|
|
// speed with nothing saying why.
|
|
if model.Profiles[0].CPUOnly() && r.FormValue("cpu_ok") == "" {
|
|
if n := freeGPUs(p.fleet); n > 0 {
|
|
return "", fmt.Errorf("this fleet has %s free and %s was set to run on the CPU; "+
|
|
"choose a GPU placement, or tick \"run on the CPU anyway\"",
|
|
plural(n, "GPU", "GPUs"), model.Name)
|
|
}
|
|
}
|
|
// 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: %s; it is placed on the next pass",
|
|
model.Name, plural(model.Scaling.Min, "replica", "replicas"), describeServe(model)), 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)
|
|
// Asked for from the page's own script — a fetch started from a
|
|
// repository opened in the list — the answer is the outcome alone,
|
|
// so the list, and the repository open in it, stay where they are.
|
|
wantJSON := strings.Contains(r.Header.Get("Accept"), "application/json")
|
|
if err != nil {
|
|
p.log.Warn("a mirror action was refused",
|
|
"admin", admin.Email, "mirror", m.Addr(), "err", err)
|
|
if wantJSON {
|
|
writeJSONStatus(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
p.renderMirror(w, r, m, "", err.Error())
|
|
return
|
|
}
|
|
p.log.Info("the mirror was changed",
|
|
"admin", admin.Email, "mirror", m.Addr(), "what", msg)
|
|
if wantJSON {
|
|
writeJSONStatus(w, http.StatusOK, map[string]string{"message": msg})
|
|
return
|
|
}
|
|
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
|
|
d.HasRegistry = p.registry != nil
|
|
d.Tab = mirrorTabFor(r, errMsg)
|
|
// Only the tab shown does its work: the registry is a server across the
|
|
// internet, and asking it for sixty repositories to show somebody who came
|
|
// to look at a download's progress made every visit wait on it.
|
|
if d.Tab == tabExplore {
|
|
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()
|
|
}
|
|
d.Tabs = mirrorTabs(d)
|
|
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)
|
|
}
|
|
var views map[string]adminapi.ModelView
|
|
prefix := ""
|
|
if p.fleet != nil && len(sources) > 0 {
|
|
views = map[string]adminapi.ModelView{}
|
|
for _, v := range p.fleet.Models() {
|
|
views[v.Name] = v
|
|
}
|
|
var ids []string
|
|
for _, n := range p.fleet.Nodes() {
|
|
ids = append(ids, n.ID)
|
|
}
|
|
prefix = sharedPrefix(ids)
|
|
}
|
|
for i := range d.Rows {
|
|
for _, m := range sources[d.Rows[i].URL] {
|
|
u := modelUse{
|
|
Name: m.Name, Mode: modeOf(m.Scaling), Replicas: max(m.Scaling.Max, 1),
|
|
Min: m.Scaling.Min, Max: m.Scaling.Max,
|
|
Class: m.Class, EffClass: m.EffectiveClass(), Owner: m.Owner,
|
|
Adapters: m.Adapters,
|
|
Declared: p.catalog.Declared(m.Name),
|
|
}
|
|
if len(m.Profiles) > 0 {
|
|
u.MemoryGB, u.CPUCores = m.Profiles[0].MinMemoryGB, m.Profiles[0].CPUCores
|
|
}
|
|
if v, ok := views[m.Name]; ok {
|
|
u.Fleet, u.Ready, u.Wanted = true, v.Ready, v.Desired
|
|
u.Parked, u.Unplaceable = v.Parked, v.Unplaceable
|
|
var nodes []string
|
|
nodes, u.Starting = runningOn(p.fleet.Replicas(m.Name))
|
|
for _, n := range nodes {
|
|
u.Nodes = append(u.Nodes, nodeName{Full: n, Short: strings.TrimPrefix(n, prefix)})
|
|
}
|
|
}
|
|
d.Rows[i].UsedBy = append(d.Rows[i].UsedBy, u)
|
|
}
|
|
}
|
|
|
|
p.heldKinds(r.Context(), d.Rows)
|
|
|
|
// The one row whose card is open. From the form too, so a change made in
|
|
// the card comes back with the card still open to show it.
|
|
open := r.FormValue("open")
|
|
for i := range d.Rows {
|
|
d.Rows[i].Open = openKey(d.Rows[i].Model, d.Rows[i].Version)
|
|
if open != "" && d.Rows[i].Open == open && d.Rows[i].State == mirror.Present {
|
|
d.Rows[i].Detail = p.heldDetailFor(r.Context(), d.Rows[i])
|
|
d.Rows[i].Detail.addAdapterChoices(d.Rows[i], d.Rows)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
|
|
// Only on the list: a reload anywhere else takes with it a search, an
|
|
// open repository or a URL half typed.
|
|
if d.Fetching > 0 && d.Tab == tabHeld && open == "" {
|
|
d.RefreshSec = 5
|
|
}
|
|
d.Tabs = mirrorTabs(d)
|
|
render(w, "admin_mirror", d)
|
|
}
|
|
|
|
const (
|
|
tabHeld = "held"
|
|
tabExplore = "explore"
|
|
tabFetch = "fetch"
|
|
)
|
|
|
|
// mirrorTab is one of the page's tabs.
|
|
type mirrorTab struct {
|
|
Label string
|
|
Count string // beside the label, empty for none
|
|
Href string
|
|
Active bool
|
|
}
|
|
|
|
// mirrorTabFor is the tab a request is for.
|
|
//
|
|
// Named in the address when it is chosen, and otherwise read from what the
|
|
// address carries, so links made before the page had tabs — a search, an
|
|
// open repository — still land where they meant. After a change the list is
|
|
// shown, where its effect is, unless it was refused: then the form it came
|
|
// from, to try again.
|
|
func mirrorTabFor(r *http.Request, errMsg string) string {
|
|
valid := func(t string) bool { return t == tabHeld || t == tabExplore || t == tabFetch }
|
|
if r.Method == http.MethodPost {
|
|
if t := r.PostFormValue("tab"); errMsg != "" && valid(t) {
|
|
return t
|
|
}
|
|
return tabHeld
|
|
}
|
|
q := r.URL.Query()
|
|
if t := q.Get("tab"); valid(t) {
|
|
return t
|
|
}
|
|
for _, k := range []string{"q", "repo", "view", "task", "size", "x"} {
|
|
if q.Has(k) {
|
|
return tabExplore
|
|
}
|
|
}
|
|
return tabHeld
|
|
}
|
|
|
|
func mirrorTabs(d adminMirrorData) []mirrorTab {
|
|
held := mirrorTab{Label: "Held", Href: "/admin/mirror?tab=held", Active: d.Tab == tabHeld}
|
|
switch {
|
|
case d.Unreachable:
|
|
held.Count = "unreachable"
|
|
case d.Fetching > 0:
|
|
held.Count = fmt.Sprintf("%d · %d downloading", d.Present, d.Fetching)
|
|
default:
|
|
held.Count = strconv.Itoa(d.Present)
|
|
}
|
|
if d.Failed > 0 {
|
|
held.Count += fmt.Sprintf(" · %d failed", d.Failed)
|
|
}
|
|
tabs := []mirrorTab{held}
|
|
if d.HasRegistry {
|
|
tabs = append(tabs, mirrorTab{Label: "Explore Hugging Face",
|
|
Href: "/admin/mirror?tab=explore", Active: d.Tab == tabExplore})
|
|
}
|
|
return append(tabs, mirrorTab{Label: "Fetch by URL",
|
|
Href: "/admin/mirror?tab=fetch", Active: d.Tab == tabFetch})
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
p.probeKinds(r.Context(), d.RepoSets)
|
|
}
|
|
|
|
// kindOf reads what a GGUF file is; a variable so tests need no registry.
|
|
var kindOf = func(ctx context.Context, url string) (gguf.Header, error) {
|
|
return gguf.KindFromURL(ctx, nil, url)
|
|
}
|
|
|
|
// probeKinds reads the start of each single-file GGUF set, to tell LoRA
|
|
// adapters from models: a repository tagged "lora" holds a model with the
|
|
// adapter merged in as often as an adapter, and only the file says which.
|
|
// A few at once and a few in all, since each is a request to the registry.
|
|
func (p *AccountPages) probeKinds(ctx context.Context, sets []repoSet) {
|
|
const most, together = 12, 6
|
|
sem := make(chan struct{}, together)
|
|
var wg sync.WaitGroup
|
|
n := 0
|
|
for i := range sets {
|
|
s := &sets[i]
|
|
if len(s.Sources) != 1 || !gguf.IsGGUF(s.Sources[0].Path) || n == most {
|
|
continue
|
|
}
|
|
n++
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
sem <- struct{}{}
|
|
defer func() { <-sem }()
|
|
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
if h, err := kindOf(cctx, s.Sources[0].URL); err == nil {
|
|
s.Arch = h.Architecture
|
|
s.Kind = "model"
|
|
if h.IsAdapter() {
|
|
s.Kind = "adapter"
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
// 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 { return hf.ModelName(repoID) }
|
|
|
|
// 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
|
|
}
|
|
|
|
func writeJSONStatus(w http.ResponseWriter, code int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// runningOn is the nodes a model's replicas are on, each once and in order,
|
|
// and how many of its replicas are not ready yet.
|
|
func runningOn(reps []adminapi.ReplicaView) (nodes []string, starting int) {
|
|
for _, r := range reps {
|
|
if r.State != "ready" {
|
|
starting++
|
|
}
|
|
for _, sh := range r.Shards {
|
|
if !slices.Contains(nodes, sh.Node) {
|
|
nodes = append(nodes, sh.Node)
|
|
}
|
|
}
|
|
}
|
|
slices.Sort(nodes)
|
|
return nodes, starting
|
|
}
|
|
|
|
// nodeName is a node as a row names it: short, with the full name beside.
|
|
type nodeName struct{ Short, Full string }
|
|
|
|
// sharedPrefix is what every node's name starts with, up to and including a
|
|
// separator — "llmgw-" of llmgw-node01 and llmgw-node02 — so a row can say
|
|
// node01 and fit on a line. Nothing when the names share no such part, or
|
|
// when there is one node and dropping it would leave only a number.
|
|
func sharedPrefix(ids []string) string {
|
|
if len(ids) < 2 {
|
|
return ""
|
|
}
|
|
p := ids[0]
|
|
for _, id := range ids[1:] {
|
|
for !strings.HasPrefix(id, p) {
|
|
p = p[:len(p)-1]
|
|
}
|
|
}
|
|
if i := strings.LastIndexAny(p, "-._"); i >= 0 {
|
|
return p[:i+1]
|
|
}
|
|
return ""
|
|
}
|