CI / test (push) Failing after 6m37s
Each agent measures its node's memory read rate when it starts: every CPU sums a share of a buffer past any cache, a tenth of free memory at most, the best of four passes, in a fraction of a second; reported in Host (mem_bandwidth_gbs) and NodeView. A token on a CPU reads every weight it uses once, so one reply's speed is about that rate over those bytes: modelplan.CPUTokensPerSecond, 0.68 of the measured rate plus 5 ms a token, calibrated on the PoC against llama.cpp's predicted_per_second (node01 24.9 GB/s, qwen2.5-1.5b: 14.5 measured, 14.7 estimated; node05 41.9 GB/s, qwen2.5-14b: 3.3 and 3.2). A model of experts named …-A3B reads its active part (ActiveShare). The explorer shows it as CPU tok/s, at Q4_K_M on the fastest node that holds the model; a repository's page gives it for each GGUF by its size. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
338 lines
9.6 KiB
Go
338 lines
9.6 KiB
Go
package web
|
|
|
|
import (
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/gguf"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/hf"
|
|
)
|
|
|
|
// The explorer's views: what the registry is excited about this week, what
|
|
// arrived this month, and what everybody settled on.
|
|
const (
|
|
viewTrending = "trending"
|
|
viewNew = "new"
|
|
viewDownloads = "downloads"
|
|
// viewAdapters lists LoRA adapters, all of them or those made for one
|
|
// model.
|
|
viewAdapters = "adapters"
|
|
)
|
|
|
|
// newWithin is how recent a repository has to be to count as new.
|
|
const newWithin = 30 * 24 * time.Hour
|
|
|
|
// minParams is the smallest thing worth calling a model.
|
|
const minParams = 50e6
|
|
|
|
// exploreShown is how many rows the explorer lists.
|
|
const exploreShown = 25
|
|
|
|
// The labels are catalogue keys: explore.view.<key>, explore.task.<key>.
|
|
var exploreViews = []string{viewTrending, viewNew, viewDownloads, viewAdapters}
|
|
|
|
var exploreTasks = []struct{ key, tag string }{
|
|
{"chat", "text-generation"}, {"embed", "feature-extraction"}, {"any", ""},
|
|
}
|
|
|
|
func init() {
|
|
for _, v := range exploreViews {
|
|
registerKeys("explore.view." + v)
|
|
}
|
|
for _, t := range exploreTasks {
|
|
registerKeys("explore.task." + t.key)
|
|
}
|
|
registerKeys("explore.size.any")
|
|
}
|
|
|
|
var exploreSizes = []struct {
|
|
key, label string
|
|
max int64
|
|
}{
|
|
// "any" is named in words, explore.size.any; the others are figures.
|
|
{"3", "≤ 3B", 3e9}, {"9", "≤ 9B", 9e9}, {"35", "≤ 35B", 35e9}, {"any", "", 0},
|
|
}
|
|
|
|
// explorer is the registry half of the mirror page when no repository is
|
|
// open: a listing, its filters, and links that keep them.
|
|
type explorer struct {
|
|
View, Task, Size string
|
|
// Of is the model whose adapters are listed, in the adapters view; empty
|
|
// lists adapters for any model.
|
|
Of string
|
|
// Here keeps to what the nodes' llama.cpp can load. On by default: a
|
|
// trending list is mostly things this cluster cannot run, and finding
|
|
// that out one repository at a time is what the explorer is for avoiding.
|
|
Here bool
|
|
Rows []exploreRow
|
|
// Hidden is how many rows Here took out, so an empty page says why.
|
|
Hidden int
|
|
// Fit says what hardware Here judged by; empty when there was none.
|
|
Fit string
|
|
// gpus is whether the cluster has cards, and so runs safetensors too.
|
|
gpus bool
|
|
|
|
Views, Tasks, Sizes []exploreLink
|
|
}
|
|
|
|
type exploreLink struct {
|
|
Label string
|
|
Href template.URL
|
|
Active bool
|
|
}
|
|
|
|
type exploreRow struct {
|
|
hf.Model
|
|
Verdict gguf.Verdict
|
|
Href template.URL
|
|
New bool
|
|
// Updated is how long ago the repository changed, in the page's language.
|
|
Updated string
|
|
// CPUSpeed is about how many tokens a second one reply would get on the
|
|
// CPU of CPUNode, the fastest node that holds it; empty when unknown.
|
|
CPUSpeed, CPUNode string
|
|
}
|
|
|
|
// ParamsLabel is the parameter count as people say it: 7.6B, 494M.
|
|
func (r exploreRow) ParamsLabel() string {
|
|
n := r.Params()
|
|
switch {
|
|
case n <= 0:
|
|
return "—"
|
|
case n >= 1e9:
|
|
return trimZero(fmt.Sprintf("%.1f", float64(n)/1e9)) + "B"
|
|
default:
|
|
return fmt.Sprintf("%dM", n/1e6)
|
|
}
|
|
}
|
|
|
|
// ContextLabel is the context length in thousands of tokens.
|
|
func (r exploreRow) ContextLabel() string {
|
|
if r.GGUF == nil || r.GGUF.ContextLength <= 0 {
|
|
return "—"
|
|
}
|
|
return fmt.Sprintf("%dk", r.GGUF.ContextLength/1024)
|
|
}
|
|
|
|
// age is how long ago the repository last changed, coarsely.
|
|
func (r exploreRow) age(l *Lang) string {
|
|
t := r.LastModified
|
|
if t.IsZero() {
|
|
t = r.CreatedAt
|
|
}
|
|
if t.IsZero() {
|
|
return "—"
|
|
}
|
|
d := time.Since(t)
|
|
switch {
|
|
case d < time.Hour:
|
|
return l.T("explore.age.now")
|
|
case d < 48*time.Hour:
|
|
return l.T("explore.age.h", int(d.Hours()))
|
|
case d < 14*24*time.Hour:
|
|
return l.T("explore.age.d", int(d.Hours()/24))
|
|
case d < 60*24*time.Hour:
|
|
return l.T("explore.age.w", int(d.Hours()/24/7))
|
|
default:
|
|
return l.T("explore.age.mo", int(d.Hours()/24/30))
|
|
}
|
|
}
|
|
|
|
func trimZero(s string) string { return strings.TrimSuffix(s, ".0") }
|
|
|
|
// exploreState reads the explorer's settings from the address. With none set
|
|
// — the page opened from the menu — it is trending chat models this cluster
|
|
// can run.
|
|
func exploreState(q url.Values) explorer {
|
|
e := explorer{View: q.Get("view"), Task: q.Get("task"), Size: q.Get("size"),
|
|
Of: strings.Trim(strings.TrimSpace(q.Get("of")), "/")}
|
|
// "x" marks a submitted form, so an unticked box reads as off rather than
|
|
// as not yet chosen.
|
|
e.Here = q.Get("x") != "1" || q.Get("here") == "1"
|
|
if !oneOf(e.View, viewTrending, viewNew, viewDownloads, viewAdapters) {
|
|
e.View = viewTrending
|
|
// A search with no view asks for what a name usually means, which is
|
|
// the one everybody downloads.
|
|
if strings.TrimSpace(q.Get("q")) != "" {
|
|
e.View = viewDownloads
|
|
}
|
|
}
|
|
if !oneOf(e.Task, "chat", "embed", "any") {
|
|
e.Task = "chat"
|
|
}
|
|
if !oneOf(e.Size, "3", "9", "35", "any") {
|
|
e.Size = "any"
|
|
}
|
|
return e
|
|
}
|
|
|
|
func oneOf(s string, options ...string) bool {
|
|
for _, o := range options {
|
|
if s == o {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// href is the page with these settings and one of them changed.
|
|
func (e explorer) href(query, key, value string) template.URL {
|
|
v := url.Values{"tab": {tabExplore}, "view": {e.View}, "task": {e.Task}, "size": {e.Size}, "x": {"1"}}
|
|
if e.Here {
|
|
v.Set("here", "1")
|
|
}
|
|
if query != "" {
|
|
v.Set("q", query)
|
|
}
|
|
if key != "" {
|
|
v.Set(key, value)
|
|
}
|
|
// Whose adapters, kept while it is adapters being looked at.
|
|
if e.Of != "" && v.Get("view") == viewAdapters {
|
|
v.Set("of", e.Of)
|
|
}
|
|
return template.URL("/admin/mirror?" + v.Encode())
|
|
}
|
|
|
|
// hfQuery is what to ask the registry for.
|
|
func (e explorer) hfQuery(search string) hf.Query {
|
|
q := hf.Query{Search: search, Sort: hf.SortTrending, Limit: 60,
|
|
// Without cards what runs here is GGUF only. With them safetensors
|
|
// too, and a page asks for more, since many are then left out for
|
|
// their size.
|
|
GGUF: e.Here && !e.gpus}
|
|
if e.Here && e.gpus {
|
|
q.Limit = 100
|
|
}
|
|
switch e.View {
|
|
case viewDownloads:
|
|
q.Sort = hf.SortDownloads
|
|
case viewNew:
|
|
// Trending, then kept to the recent: sorted by creation date alone,
|
|
// the newest repositories are mostly empty uploads nobody has used.
|
|
q.Limit = 100
|
|
case viewAdapters:
|
|
// Adapters rarely carry a task or a parameter count, so neither is
|
|
// asked for; what makes one useful is the model it was made for.
|
|
q.Sort = hf.SortDownloads
|
|
if e.Of != "" {
|
|
q.Filters = []string{"base_model:adapter:" + e.Of}
|
|
} else {
|
|
q.Filters = []string{"lora"}
|
|
}
|
|
return q
|
|
}
|
|
for _, t := range exploreTasks {
|
|
if t.key == e.Task {
|
|
q.Task = t.tag
|
|
}
|
|
}
|
|
for _, s := range exploreSizes {
|
|
if s.key == e.Size {
|
|
q.MaxParams = s.max
|
|
}
|
|
}
|
|
return q
|
|
}
|
|
|
|
// exploreList fills in the listing.
|
|
func (p *AccountPages) exploreList(r *http.Request, d *adminMirrorData) {
|
|
e := exploreState(r.URL.Query())
|
|
// From a held model's card: its repository, whose adapters are those of
|
|
// the model it was made from — a GGUF repository is a quantisation, and
|
|
// adapters are made for the original.
|
|
if from := strings.TrimSpace(r.URL.Query().Get("from")); e.View == viewAdapters && e.Of == "" && from != "" {
|
|
e.Of = from
|
|
if repo, err := p.registry.Repo(r.Context(), from); err == nil && len(repo.BaseModel) > 0 {
|
|
e.Of = repo.BaseModel[0]
|
|
}
|
|
}
|
|
l := langOf(r)
|
|
for _, v := range exploreViews {
|
|
e.Views = append(e.Views, exploreLink{l.T("explore.view." + v), e.href(d.Query, "view", v), v == e.View})
|
|
}
|
|
for _, t := range exploreTasks {
|
|
e.Tasks = append(e.Tasks, exploreLink{l.T("explore.task." + t.key), e.href(d.Query, "task", t.key), t.key == e.Task})
|
|
}
|
|
for _, s := range exploreSizes {
|
|
label := s.label
|
|
if label == "" {
|
|
label = l.T("explore.size." + s.key)
|
|
}
|
|
e.Sizes = append(e.Sizes, exploreLink{label, e.href(d.Query, "size", s.key), s.key == e.Size})
|
|
}
|
|
fit := p.fitOf()
|
|
if e.Here && fit != nil {
|
|
e.gpus = fit.gpus()
|
|
e.Fit = fit.note(l)
|
|
}
|
|
d.Explore = &e
|
|
|
|
got, err := p.registry.Explore(r.Context(), e.hfQuery(d.Query))
|
|
if err != nil {
|
|
d.RegistryErr = l.Err(err)
|
|
return
|
|
}
|
|
back := e.href(d.Query, "", "")
|
|
for _, m := range got {
|
|
if e.View == viewNew && time.Since(m.CreatedAt) > newWithin {
|
|
continue
|
|
}
|
|
row := exploreRow{Model: m, Verdict: gguf.Verdict{Servable: true},
|
|
Href: back + template.URL("&repo="+url.QueryEscape(m.ID)),
|
|
New: !m.CreatedAt.IsZero() && time.Since(m.CreatedAt) < newWithin}
|
|
row.Updated = row.age(l)
|
|
if a := m.Architecture(); a != "" && p.llamaVersion != "" {
|
|
row.Verdict = gguf.LlamaCpp(p.llamaVersion, a)
|
|
}
|
|
// Adapters are small and say little about their architecture in a
|
|
// listing; whether one fits is read from its file when it is opened.
|
|
if e.View == viewAdapters {
|
|
if len(e.Rows) < exploreShown {
|
|
e.Rows = append(e.Rows, row)
|
|
}
|
|
continue
|
|
}
|
|
// An adapter or a fragment, not a model: tens of megabytes of LoRA
|
|
// weights listed among the models they need a model to run on.
|
|
if e.Here && m.Params() > 0 && m.Params() < minParams {
|
|
continue
|
|
}
|
|
// Nothing in it a node loads: no GGUF, no safetensors — pickles,
|
|
// which nodes refuse, or a format no engine here reads.
|
|
if e.Here && !loadable(m) {
|
|
e.Hidden++
|
|
continue
|
|
}
|
|
// Too big for every node here, or in a form these cards lack.
|
|
if e.Here && fit != nil && !fit.runs(m) {
|
|
e.Hidden++
|
|
continue
|
|
}
|
|
if fit != nil {
|
|
row.CPUSpeed, row.CPUNode = fit.rowSpeed(m)
|
|
}
|
|
if e.Here && row.Verdict.Checked && !row.Verdict.Servable {
|
|
e.Hidden++
|
|
continue
|
|
}
|
|
if len(e.Rows) < exploreShown {
|
|
e.Rows = append(e.Rows, row)
|
|
}
|
|
}
|
|
}
|
|
|
|
// loadable reports whether a listed repository has weights a node loads:
|
|
// GGUF or safetensors, by the registry's summary of them or its tags.
|
|
func loadable(m hf.Model) bool {
|
|
if m.GGUF != nil || m.Safetensors != nil {
|
|
return true
|
|
}
|
|
return slices.Contains(m.Tags, "gguf") || slices.Contains(m.Tags, "safetensors")
|
|
}
|