Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
267 lines
9.1 KiB
Go
267 lines
9.1 KiB
Go
package mirror
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"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("PUT /v1/models/{model}/{version}/{file}", a.upload)
|
|
mux.HandleFunc("GET /v1/health", a.health)
|
|
mux.HandleFunc("PUT /v1/firewall", a.putFirewall)
|
|
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"`
|
|
// Removed are the versions removed here, model/version to when (ms),
|
|
// for a peer mirror to remove too.
|
|
Removed map[string]int64 `json:"removed,omitempty"`
|
|
}
|
|
|
|
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, Removed: a.m.Tombstones()})
|
|
}
|
|
|
|
// 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"`
|
|
// SHA256 is what the registry published for the file: a download that
|
|
// is not it is deleted and the fetch fails. Empty checks nothing.
|
|
SHA256 string `json:"sha256,omitempty"`
|
|
}
|
|
|
|
// 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.StartChecked(req.Source, e, strings.ToLower(req.SHA256))
|
|
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 err := errors.Join(ValidName(model), ValidName(version)); err != nil {
|
|
fail(w, http.StatusBadRequest, err)
|
|
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
|
|
}
|
|
|
|
// maxUpload bounds a file sent to the mirror rather than fetched by it.
|
|
const maxUpload = 8 << 30
|
|
|
|
// upload keeps a file the caller sends — an adapter a node trained, which
|
|
// is on no registry to fetch from — with the same discipline as an import:
|
|
// its checksum and source beside it, and an existing version never
|
|
// replaced. X-Llmgw-Source says where it came from.
|
|
func (a *API) upload(w http.ResponseWriter, r *http.Request) {
|
|
e := Entry{Model: r.PathValue("model"), Version: r.PathValue("version"), File: r.PathValue("file")}
|
|
for _, n := range []string{e.Model, e.Version, e.File} {
|
|
if err := ValidName(n); err != nil {
|
|
fail(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
}
|
|
if _, err := os.Stat(e.Path(a.m.root)); err == nil {
|
|
fail(w, http.StatusConflict, fmt.Errorf("%s/%s/%s: %w", e.Model, e.Version, e.File, ErrExists))
|
|
return
|
|
}
|
|
tmp, err := os.CreateTemp(a.m.root, ".upload-*")
|
|
if err != nil {
|
|
fail(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
defer os.Remove(tmp.Name())
|
|
n, err := io.Copy(tmp, http.MaxBytesReader(w, r.Body, maxUpload))
|
|
if cerr := tmp.Close(); err == nil {
|
|
err = cerr
|
|
}
|
|
if err != nil {
|
|
fail(w, http.StatusBadRequest, fmt.Errorf("receiving the file: %w", err))
|
|
return
|
|
}
|
|
if n == 0 {
|
|
fail(w, http.StatusBadRequest, errors.New("the file is empty"))
|
|
return
|
|
}
|
|
source := strings.TrimSpace(r.Header.Get("X-Llmgw-Source"))
|
|
if source == "" {
|
|
source = "uploaded"
|
|
}
|
|
added, err := Import(r.Context(), a.m.root, tmp.Name(), e, "", source, nil)
|
|
switch {
|
|
case errors.Is(err, ErrExists):
|
|
fail(w, http.StatusConflict, err)
|
|
return
|
|
case err != nil:
|
|
fail(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, FetchResponse{
|
|
Status: Status{Model: e.Model, Version: e.Version, File: e.File, State: Present, BytesDone: added.Entry.Bytes, BytesTotal: added.Entry.Bytes},
|
|
URL: e.URL(a.base),
|
|
})
|
|
}
|