Files
llmgw/internal/mirror/api.go
T
Ilker ManapandClaude Opus 5.5 85025dfb03 Trained adapters published, private to their owners like the knowledge bases they come from
An adapter now has an owner (org, team, project, user or key): only callers
there see it, and to anyone else it is a model that does not exist, absent
from /v1/models too. A training job's owner defaults to the one collection
its pairs come from, must be able to read every collection they come from,
and pairs from a knowledge base are never made public. ctl train publish
keeps the GGUF or PEFT files on the mirror, which accepts uploads now, and
sets the owned adapter after the usual architecture check.

The knowledge bases' cache_salt follows the collections searched rather
than the organization, so two teams of one organization no longer share
cached passages.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 21:30:50 +02:00

260 lines
8.7 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)
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 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),
})
}