Files
llmgw/internal/mirror/api.go
T
Ilker ManapandClaude Opus 5 16ec585411 Let the control plane ask the mirror for a model instead of pushing one at it
The mirror holds the weights, so the mirror does the downloading. A control
plane that had to pull the bytes itself and push them here would carry every
model twice over the network, through the one machine least able to spare the
bandwidth.

Two listeners, and the split is the point. Weights stay on plain HTTP where
every node can reach them: that path is eight nodes pulling forty gigabytes,
and putting a client certificate in front of it would mean re-certifying every
agent's fetcher in order to change how models are administered. Management gets
its own port, which can be given the fleet's CA — the same tlsconf the agent
stream uses, so this is a configuration rather than a new idea.

A fetch answers 202, not 200. The bytes are not here yet, and a caller that
read it as "done" would point a catalog at a file still arriving, which every
node would then copy. Progress is asked for rather than waited on, because no
client between here and the operator will hold a connection open for an hour.

A failed fetch is remembered with its reason. Forgetting it would leave the
control plane waiting on a model that is never coming, with nothing to say why.
Status falls back to reading the disk, so a restart does not report a model
that is plainly there as unknown.

Removal takes the whole version: a model split across files is useless with one
of them missing, and half a version on disk looks present and will not load. It
refuses while a download is running, because the fetch would carry on writing
and the file would reappear, looking like the delete had silently failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 09:34:48 +02:00

202 lines
6.8 KiB
Go

package mirror
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"strings"
)
// API is the mirror's management interface.
//
// Management only. The weights themselves are served by an ordinary file
// handler on a separate listener, and that split is deliberate: nodes fetching
// forty gigabytes should not have to authenticate against a management API, and
// putting a client certificate in front of the byte path would mean
// re-certifying the agent's fetcher to change how models are administered.
//
// So: bytes over plain HTTP where every node can reach them, control over an
// API that can be locked down without touching them.
type API struct {
m *Manager
// base is how a catalog should refer to files here, reported alongside each
// model so the caller does not have to reconstruct the URL and get it
// subtly wrong.
base string
}
// NewAPI wraps a manager in handlers.
func NewAPI(m *Manager, base string) *API { return &API{m: m, base: base} }
// Handler routes the management endpoints.
func (a *API) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/models", a.list)
mux.HandleFunc("POST /v1/models", a.fetch)
mux.HandleFunc("GET /v1/models/{model}/{version}", a.status)
mux.HandleFunc("DELETE /v1/models/{model}/{version}", a.remove)
mux.HandleFunc("GET /v1/health", a.health)
return mux
}
// ModelsResponse is what the mirror holds, and where to point a catalog.
type ModelsResponse struct {
// Base is the address nodes fetch from. Returned rather than assumed
// because the caller writing a catalog entry needs the same spelling the
// mirror would use, and a mismatch there is what makes a file look unused
// when it is not.
Base string `json:"base"`
Models []Status `json:"models"`
}
func (a *API) list(w http.ResponseWriter, _ *http.Request) {
all, err := a.m.All()
if err != nil {
fail(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, ModelsResponse{Base: a.base, Models: all})
}
// FetchRequest asks the mirror to download a model.
type FetchRequest struct {
Source string `json:"source"`
Model string `json:"model"`
Version string `json:"version"`
// File is optional; the last element of the source URL is used when it is
// empty, which is right almost always and wrong visibly rather than
// quietly when it is not.
File string `json:"file"`
}
// FetchResponse reports where a download stands, and where it will be.
type FetchResponse struct {
Status Status `json:"status"`
// URL is what a catalog entry should say once the download finishes. Given
// now rather than later so the operator can write the catalog while the
// bytes are still moving.
URL string `json:"url"`
}
func (a *API) fetch(w http.ResponseWriter, r *http.Request) {
var req FetchRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
fail(w, http.StatusBadRequest, fmt.Errorf("reading the request: %w", err))
return
}
if req.File == "" {
req.File = LastPathElement(req.Source)
}
if req.Model == "" || req.Version == "" || req.File == "" {
fail(w, http.StatusBadRequest,
errors.New("model, version and a filename are all required; the filename can come from the source URL"))
return
}
e := Entry{Model: req.Model, Version: req.Version, File: req.File}
st, err := a.m.Start(req.Source, e)
switch {
case errors.Is(err, ErrAlreadyFetching):
// Not an error the caller needs to handle: a control plane that asks
// twice for the same model wanted it once. Reporting the download
// already running says the same thing as starting it would have.
writeJSON(w, http.StatusAccepted, FetchResponse{Status: st, URL: e.URL(a.base)})
return
case err != nil:
fail(w, http.StatusBadRequest, err)
return
}
// 202, not 200: the bytes are not here yet. A caller that treats this as
// "done" would point a catalog at a file that is still arriving, and every
// node would then fetch a partial one.
writeJSON(w, http.StatusAccepted, FetchResponse{Status: st, URL: e.URL(a.base)})
}
func (a *API) status(w http.ResponseWriter, r *http.Request) {
model, version := r.PathValue("model"), r.PathValue("version")
st, ok := a.m.Status(model, version)
if !ok {
fail(w, http.StatusNotFound,
fmt.Errorf("the mirror holds nothing for %s/%s", model, version))
return
}
writeJSON(w, http.StatusOK, FetchResponse{
Status: st,
URL: Entry{Model: st.Model, Version: st.Version, File: st.File}.URL(a.base),
})
}
func (a *API) remove(w http.ResponseWriter, r *http.Request) {
model, version := r.PathValue("model"), r.PathValue("version")
if strings.ContainsAny(model+version, `/\`) {
fail(w, http.StatusBadRequest, errors.New("model and version are single names, not paths"))
return
}
// A download in flight is not deleted out from under itself: the fetch
// would carry on writing and the file would reappear, which looks like the
// delete silently failed.
if st, ok := a.m.Status(model, version); ok && st.State == Fetching {
fail(w, http.StatusConflict,
fmt.Errorf("%s/%s is still downloading; nothing was removed", model, version))
return
}
removed, err := a.m.RemoveVersion(model, version)
switch {
case errors.Is(err, os.ErrNotExist):
fail(w, http.StatusNotFound, fmt.Errorf("the mirror holds nothing for %s/%s", model, version))
return
case err != nil:
fail(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"removed": removed})
}
// health says the mirror is up and can read its own directory.
//
// Reading the directory rather than only answering: a mirror whose disk has
// gone away still accepts connections, and a check that only proves the process
// is running would call that healthy right up until a node tried to fetch.
func (a *API) health(w http.ResponseWriter, _ *http.Request) {
entries, err := List(a.m.root)
if err != nil {
fail(w, http.StatusServiceUnavailable, err)
return
}
var bytes int64
for _, e := range entries {
bytes += e.Bytes
}
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok", "files": len(entries), "bytes": bytes, "base": a.base,
})
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
// fail answers with the reason in the body.
//
// In the body because the caller is a program that will log it and a person who
// will read that log. A bare status code turns "the source URL 404s" into "the
// mirror said 400", and the next hour goes into finding out which.
func fail(w http.ResponseWriter, code int, err error) {
writeJSON(w, code, map[string]string{"error": err.Error()})
}
// LastPathElement is the filename a URL ends in.
func LastPathElement(u string) string {
u = strings.SplitN(u, "?", 2)[0]
if i := strings.LastIndex(u, "/"); i >= 0 && i < len(u)-1 {
return u[i+1:]
}
return u
}