adminapi.Routes lists every endpoint with its query parameters and Go values
of the bodies it reads and answers with; internal/docgen/openapi turns that
into docs/admin-api.openapi.json by reflection, the way encoding/json encodes
the types, with the fields' comments as descriptions. A test fails when an
endpoint is registered and not in the table, another when the committed
document is stale. The binary embeds it and serves it with the token.
Handlers that wrote map literals and decoded anonymous structs now use named
types, so the document can see them. GET /api/v1/models/{name} answers with a
model's whole definition as it is in force, which a tool managing it needs.
docs/admin-api.md: reaching the API, the token, what a request must carry.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
117 lines
3.6 KiB
Go
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.Source)
|
|
// 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)
|
|
}
|