Files
Ilker ManapandClaude Opus 5.5 63198f2753 A profile may load weights of its own
The catalog had one source a model, so an embedding model served by vLLM
from its repository on a card and by llama.cpp from a GGUF on CPUs had its
entry swapped by hand halfway through the metallurgy import. A profile's
source is what its engine loads and placement looks for; the model's is
the default, and what updates and canaries move. ctl model add checks
every source, the GGUF check reads each llama.cpp profile's, and ctl
weights collects none a profile loads. An empty source leaves a profile's
fingerprint as it was, so running replicas do not read as stale.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 17:15:38 +02:00

117 lines
3.6 KiB
Go

package adminapi
import (
"encoding/json"
"net/http"
"sort"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/weights"
)
// OrphanView is cached weights on one node that no catalog model accounts for.
type OrphanView struct {
Node string `json:"node"`
Paths []string `json:"paths"`
// Bytes is what deleting them would free, as far as the node reported. A
// file whose size was not reported counts as zero rather than being left
// out, so the list and the number always describe the same set.
Bytes int64 `json:"bytes"`
}
// Orphans finds cached weights nothing in the catalog points at.
//
// Computed here from what nodes already report, so listing costs no round trip
// and a node that is offline simply contributes nothing — its files are not
// deletable while it is gone anyway.
//
// Only files llmgw fetched are ever considered. Everything else in a model
// directory was put there by the operator and is none of this system's
// business, however unreferenced it looks.
func (a *API) Orphans() []OrphanView {
var wanted []string
for _, m := range a.ctl.Catalog().Models() {
wanted = append(wanted, m.WeightSources()...)
// A model's adapters are its weights too; collecting them would
// leave replicas that cannot restart.
for _, ad := range m.Adapters {
wanted = append(wanted, ad.Source)
}
}
out := make([]OrphanView, 0)
for _, n := range a.ctl.Inventory().Nodes() {
size := map[string]int64{}
have := make([]string, 0, len(n.Weights))
for _, w := range n.Weights {
have = append(have, w.Path)
size[w.Path] = w.SizeBytes
}
paths := weights.Orphans(have, wanted)
if len(paths) == 0 {
continue
}
v := OrphanView{Node: n.ID, Paths: paths}
for _, p := range paths {
v.Bytes += size[p]
}
out = append(out, v)
}
sort.Slice(out, func(i, j int) bool { return out[i].Node < out[j].Node })
return out
}
func (a *API) listOrphans(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, a.Orphans())
}
// CollectRequest limits a sweep to one node. Empty means every node, which
// is the common case after removing a model from the catalog.
type CollectRequest struct {
Node string `json:"node,omitempty"`
}
// CollectResult is what was deleted on one node, or why nothing was.
type CollectResult struct {
Node string `json:"node"`
Files int `json:"files"`
Bytes int64 `json:"bytes"`
Error string `json:"error,omitempty"`
}
// collectWeights deletes them.
//
// A proposal, not an instruction: each node checks every path again before
// unlinking it, and refuses anything it cannot prove it fetched. The two
// checks are deliberate duplication — the control plane knows the catalog and
// the node knows its disk, and only the node can be sure.
func (a *API) collectWeights(w http.ResponseWriter, r *http.Request) {
rm := a.ctl.WeightRemover()
if rm == nil {
writeError(w, http.StatusNotImplemented,
"this control plane cannot ask nodes to delete files")
return
}
var body CollectRequest
// An empty body is the "all nodes" case and must not be an error.
_ = json.NewDecoder(r.Body).Decode(&body)
out := make([]CollectResult, 0)
for _, o := range a.Orphans() {
if body.Node != "" && o.Node != body.Node {
continue
}
res := CollectResult{Node: o.Node, Files: len(o.Paths), Bytes: o.Bytes}
// A node that has gone since the list was built is reported rather than
// failing the whole sweep: the other nodes' space is still worth
// freeing, and this one will be offered again next time.
if err := rm.RemoveWeights(r.Context(), o.Node, o.Paths); err != nil {
res.Error = err.Error()
res.Files, res.Bytes = 0, 0
}
out = append(out, res)
}
writeJSON(w, http.StatusOK, out)
}