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>
560 lines
18 KiB
Go
560 lines
18 KiB
Go
package adminapi
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bufio"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/train"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
// Training LoRA adapters (BACKLOG F4), administered: a job names the model
|
|
// the adapter is for, the weights to train on, the node and how much of a
|
|
// card it needs, and the pairs; the node trains when it can, and what it
|
|
// made is kept here to download or publish.
|
|
|
|
// TrainStore is where jobs are kept.
|
|
type TrainStore interface {
|
|
CreateTrainJob(ctx context.Context, j store.TrainJob) error
|
|
TrainJobs(ctx context.Context, limit int) ([]store.TrainJob, error)
|
|
TrainJob(ctx context.Context, id string) (store.TrainJob, error)
|
|
TrainArtifact(ctx context.Context, job, kind string) ([]byte, error)
|
|
TrainArtifactKinds(ctx context.Context, job string) ([]string, error)
|
|
DeleteTrainJob(ctx context.Context, id string) error
|
|
}
|
|
|
|
// TrainAdmin is what the training endpoints need.
|
|
type TrainAdmin struct {
|
|
Store TrainStore
|
|
Service *train.Service
|
|
// Mirror keeps what a job made where the nodes fetch weights; nil
|
|
// leaves publishing off.
|
|
Mirror TrainMirror
|
|
}
|
|
|
|
// TrainMirror is the model mirror, as publishing needs it.
|
|
type TrainMirror interface {
|
|
Upload(ctx context.Context, model, version, file string, body []byte, source string) (mirror.FetchResponse, error)
|
|
}
|
|
|
|
// TrainPublishView is an adapter published: where the nodes fetch it from,
|
|
// and what to do for replicas to load it.
|
|
type TrainPublishView struct {
|
|
Adapter string `json:"adapter"`
|
|
Model string `json:"model"`
|
|
Source string `json:"source"`
|
|
// Owner is who sees it; empty is every caller of the model.
|
|
Owner string `json:"owner,omitempty"`
|
|
Note string `json:"note,omitempty"`
|
|
}
|
|
|
|
// SetTrain enables the training endpoints.
|
|
func (a *API) SetTrain(t *TrainAdmin) { a.train = t }
|
|
|
|
// SetTrainMirror gives publishing the mirror, which is set up after the
|
|
// training endpoints are.
|
|
func (a *API) SetTrainMirror(m TrainMirror) {
|
|
if a.train != nil {
|
|
a.train.Mirror = m
|
|
}
|
|
}
|
|
|
|
// TrainRequest queues a job.
|
|
type TrainRequest struct {
|
|
// Name is the adapter's: what requests will ask for it by.
|
|
Name string `json:"name"`
|
|
// Model is the catalog model the adapter is for.
|
|
Model string `json:"model"`
|
|
// BaseRepo and BaseRevision are the weights trained on: the model's own
|
|
// on its registry, in safetensors (Hugging Face), not a GGUF.
|
|
BaseRepo string `json:"base_repo"`
|
|
BaseRevision string `json:"base_revision,omitempty"`
|
|
// Node trains it; GPUMemoryMiB is what it needs of a card, which can be
|
|
// what an engine there leaves free.
|
|
Node string `json:"node"`
|
|
GPUMemoryMiB int `json:"gpu_memory_mib"`
|
|
// Owner is who the adapter will belong to: org:, team:, project: (by
|
|
// path or ID), user: or key:. Only callers there see it. When the
|
|
// pairs come from one knowledge base, it defaults to that collection's
|
|
// owner; it must be able to read every collection they come from.
|
|
Owner string `json:"owner,omitempty"`
|
|
// Public makes the adapter every caller's its model allows. Refused for
|
|
// pairs from a knowledge base.
|
|
Public bool `json:"public,omitempty"`
|
|
// Data is the pairs, NDJSON of {"q", "a"} (llmgw ctl kb dataset).
|
|
Data string `json:"data"`
|
|
// Params tune the trainer: rank, alpha, dropout, epochs, lr, batch,
|
|
// max_len, seed.
|
|
Params map[string]any `json:"params,omitempty"`
|
|
// GGUF also makes the adapter in llama.cpp's format; when absent, it
|
|
// does when the model is served by llama.cpp.
|
|
GGUF *bool `json:"gguf,omitempty"`
|
|
}
|
|
|
|
// TrainJobView is a job as listed.
|
|
type TrainJobView struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Model string `json:"model"`
|
|
BaseRepo string `json:"base_repo"`
|
|
BaseRevision string `json:"base_revision,omitempty"`
|
|
Node string `json:"node"`
|
|
Owner string `json:"owner,omitempty"`
|
|
GPUMemoryMiB int `json:"gpu_memory_mib"`
|
|
Params string `json:"params,omitempty"`
|
|
GGUF bool `json:"gguf"`
|
|
Pairs int `json:"pairs"`
|
|
State string `json:"state"`
|
|
Step int `json:"step"`
|
|
Steps int `json:"steps"`
|
|
Loss float64 `json:"loss"`
|
|
Message string `json:"message,omitempty"`
|
|
PeakMiB int `json:"peak_mib"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
StartedAt time.Time `json:"started_at,omitzero"`
|
|
FinishedAt time.Time `json:"finished_at,omitzero"`
|
|
CreatedBy string `json:"created_by,omitempty"`
|
|
// Artifacts are what it made: peft (a gzipped tar), gguf.
|
|
Artifacts []string `json:"artifacts,omitempty"`
|
|
}
|
|
|
|
func trainView(j store.TrainJob) TrainJobView {
|
|
return TrainJobView{ID: j.ID, Name: j.Name, Model: j.Model, BaseRepo: j.BaseRepo, BaseRevision: j.BaseRevision,
|
|
Node: j.Node, Owner: j.Owner, GPUMemoryMiB: j.GPUMemoryMiB, Params: j.Params, GGUF: j.GGUF, Pairs: j.Pairs, State: j.State,
|
|
Step: j.Step, Steps: j.Steps, Loss: j.Loss, Message: j.Message, PeakMiB: j.PeakMiB, CreatedAt: j.CreatedAt,
|
|
StartedAt: j.StartedAt, FinishedAt: j.FinishedAt, CreatedBy: j.CreatedBy}
|
|
}
|
|
|
|
func (a *API) registerTrain(mux Mux) {
|
|
mux.HandleFunc("POST /api/v1/train/jobs", a.trainCreate)
|
|
mux.HandleFunc("GET /api/v1/train/jobs", a.trainList)
|
|
mux.HandleFunc("GET /api/v1/train/jobs/{id}", a.trainGet)
|
|
mux.HandleFunc("POST /api/v1/train/jobs/{id}/cancel", a.trainCancel)
|
|
mux.HandleFunc("DELETE /api/v1/train/jobs/{id}", a.trainDelete)
|
|
mux.HandleFunc("GET /api/v1/train/jobs/{id}/artifacts/{kind}", a.trainArtifact)
|
|
mux.HandleFunc("POST /api/v1/train/jobs/{id}/publish", a.trainPublish)
|
|
}
|
|
|
|
func (a *API) trainReady(w http.ResponseWriter) bool {
|
|
if a.train == nil {
|
|
writeError(w, http.StatusNotImplemented, "training needs the store and the nodes' agents, which this control plane runs without")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// trainMaxData bounds a job's pairs.
|
|
const trainMaxData = 64 << 20
|
|
|
|
var adapterName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$`)
|
|
|
|
// trainParams are the trainer's settings, with what each may be.
|
|
var trainParams = map[string][2]float64{
|
|
"rank": {1, 256}, "alpha": {1, 1024}, "dropout": {0, 0.9}, "epochs": {1, 100},
|
|
"lr": {1e-7, 1e-2}, "batch": {1, 128}, "max_len": {16, 32768}, "seed": {0, 1 << 31},
|
|
}
|
|
|
|
func (a *API) trainCreate(w http.ResponseWriter, r *http.Request) {
|
|
if !a.trainReady(w) {
|
|
return
|
|
}
|
|
var in TrainRequest
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, trainMaxData+1<<20)).Decode(&in); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
bad := func(format string, args ...any) {
|
|
writeError(w, http.StatusBadRequest, fmt.Sprintf(format, args...))
|
|
}
|
|
if !adapterName.MatchString(in.Name) {
|
|
bad("an adapter's name is letters, digits, . _ and -, up to 100: %q is not", in.Name)
|
|
return
|
|
}
|
|
m, ok := a.ctl.Catalog().Resolve(in.Model)
|
|
if !ok {
|
|
bad("no model %s in the catalog", in.Model)
|
|
return
|
|
}
|
|
if _, _, _, taken := a.ctl.Catalog().ResolveAdapter(in.Name); taken || in.Name == m.Name {
|
|
bad("%s is already a model's or an adapter's name", in.Name)
|
|
return
|
|
}
|
|
if strings.Count(in.BaseRepo, "/") != 1 || strings.ContainsAny(in.BaseRepo, " :") {
|
|
bad("base_repo is the weights' registry repository, owner/name: %q is not", in.BaseRepo)
|
|
return
|
|
}
|
|
if _, ok := a.ctl.Inventory().Get(in.Node); !ok {
|
|
bad("no node %s", in.Node)
|
|
return
|
|
}
|
|
if in.GPUMemoryMiB <= 0 {
|
|
bad("gpu_memory_mib is what the job needs of a card, in MiB")
|
|
return
|
|
}
|
|
pairs, colls, err := countPairs(in.Data)
|
|
if err != nil {
|
|
bad("%v", err)
|
|
return
|
|
}
|
|
owner, err := a.trainOwner(r.Context(), in, colls)
|
|
if err != nil {
|
|
bad("%v", err)
|
|
return
|
|
}
|
|
for k, v := range in.Params {
|
|
lim, known := trainParams[k]
|
|
f, isNum := v.(float64)
|
|
if !known || !isNum || f < lim[0] || f > lim[1] {
|
|
bad("params.%s: the trainer takes rank, alpha, dropout, epochs, lr, batch, max_len and seed, each a number in its range", k)
|
|
return
|
|
}
|
|
}
|
|
params := ""
|
|
if len(in.Params) > 0 {
|
|
b, _ := json.Marshal(in.Params)
|
|
params = string(b)
|
|
}
|
|
gguf := false
|
|
for _, p := range m.Profiles {
|
|
if p.Engine == "llamacpp" {
|
|
gguf = true
|
|
}
|
|
}
|
|
if in.GGUF != nil {
|
|
gguf = *in.GGUF
|
|
}
|
|
j := store.TrainJob{ID: store.NewID("train-"), Name: in.Name, Model: m.Name, BaseRepo: in.BaseRepo,
|
|
BaseRevision: in.BaseRevision, Node: in.Node, Owner: owner, GPUMemoryMiB: in.GPUMemoryMiB, Params: params, GGUF: gguf,
|
|
Data: []byte(in.Data), Pairs: pairs, CreatedBy: actorOf(r)}
|
|
if err := a.train.Store.CreateTrainJob(r.Context(), j); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
// At once rather than at the next look, when the node is here.
|
|
go a.train.Service.Dispatch(context.WithoutCancel(r.Context()))
|
|
j.State = store.TrainQueued
|
|
writeJSON(w, http.StatusCreated, trainView(j))
|
|
}
|
|
|
|
// trainOwner is who a job's adapter will belong to, as scope:ID: one that
|
|
// may read every knowledge base the pairs come from. Pairs from a knowledge
|
|
// base never make an adapter everyone's.
|
|
func (a *API) trainOwner(ctx context.Context, in TrainRequest, colls []string) (string, error) {
|
|
if in.Public {
|
|
if len(colls) > 0 {
|
|
return "", errors.New("these pairs come from a knowledge base, and an adapter trained on them " +
|
|
"carries some of what it says: it belongs to a part of the tree that may read it, not to everyone")
|
|
}
|
|
if in.Owner != "" {
|
|
return "", errors.New("an adapter is public or has an owner, not both")
|
|
}
|
|
return "", nil
|
|
}
|
|
if len(colls) > 0 && a.kb == nil {
|
|
return "", errors.New("the pairs name knowledge bases, which this control plane cannot read")
|
|
}
|
|
var found []store.KBCollection
|
|
for _, id := range colls {
|
|
c, err := a.kb.Store.Collection(ctx, id)
|
|
if err != nil {
|
|
return "", fmt.Errorf("the pairs come from collection %s, which is not here: %w", id, err)
|
|
}
|
|
found = append(found, c)
|
|
}
|
|
if in.Owner == "" {
|
|
if len(found) == 1 {
|
|
return string(found[0].OwnerScope) + ":" + found[0].OwnerID, nil
|
|
}
|
|
return "", errors.New("name the adapter's owner (org:, team:, project:, user: or key:), or make it public " +
|
|
"when its pairs are not a knowledge base's")
|
|
}
|
|
scope, id, err := a.kbTarget(ctx, in.Owner)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, c := range found {
|
|
e, err := a.kb.Store.EffectiveFor(ctx, scope, id, c.OrgID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("%s: %w", in.Owner, err)
|
|
}
|
|
readable, err := a.kb.Store.CollectionsFor(ctx, e, false)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !slices.ContainsFunc(readable, func(x store.KBCollection) bool { return x.ID == c.ID }) {
|
|
return "", fmt.Errorf("%s may not read collection %s, which the pairs come from: "+
|
|
"an adapter trained on it would tell its callers what it says", in.Owner, c.Name)
|
|
}
|
|
}
|
|
return string(scope) + ":" + id, nil
|
|
}
|
|
|
|
// countPairs checks a job's data: NDJSON of {"q", "a"}, at least one, and
|
|
// says which knowledge bases the pairs come from.
|
|
func countPairs(data string) (int, []string, error) {
|
|
if len(data) > trainMaxData {
|
|
return 0, nil, fmt.Errorf("the data is over %d MiB", trainMaxData>>20)
|
|
}
|
|
seen := map[string]bool{}
|
|
var colls []string
|
|
n := 0
|
|
sc := bufio.NewScanner(strings.NewReader(data))
|
|
sc.Buffer(make([]byte, 64<<10), 1<<20)
|
|
line := 0
|
|
for sc.Scan() {
|
|
line++
|
|
b := bytes.TrimSpace(sc.Bytes())
|
|
if len(b) == 0 {
|
|
continue
|
|
}
|
|
var p struct{ Q, A, Collection string }
|
|
if err := json.Unmarshal(b, &p); err != nil {
|
|
return 0, nil, fmt.Errorf("data line %d is not JSON: %v", line, err)
|
|
}
|
|
if strings.TrimSpace(p.Q) == "" || strings.TrimSpace(p.A) == "" {
|
|
return 0, nil, fmt.Errorf("data line %d has no q or no a", line)
|
|
}
|
|
if p.Collection != "" && !seen[p.Collection] {
|
|
seen[p.Collection] = true
|
|
colls = append(colls, p.Collection)
|
|
}
|
|
n++
|
|
}
|
|
if err := sc.Err(); err != nil {
|
|
return 0, nil, err
|
|
}
|
|
if n == 0 {
|
|
return 0, nil, errors.New("the data holds no pairs: NDJSON of {\"q\", \"a\"}, as llmgw ctl kb dataset writes")
|
|
}
|
|
return n, colls, nil
|
|
}
|
|
|
|
func (a *API) trainList(w http.ResponseWriter, r *http.Request) {
|
|
if !a.trainReady(w) {
|
|
return
|
|
}
|
|
limit := 50
|
|
if n, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && n > 0 && n <= 500 {
|
|
limit = n
|
|
}
|
|
jobs, err := a.train.Store.TrainJobs(r.Context(), limit)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
out := []TrainJobView{}
|
|
for _, j := range jobs {
|
|
out = append(out, trainView(j))
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (a *API) trainJob(w http.ResponseWriter, r *http.Request) (store.TrainJob, bool) {
|
|
if !a.trainReady(w) {
|
|
return store.TrainJob{}, false
|
|
}
|
|
j, err := a.train.Store.TrainJob(r.Context(), r.PathValue("id"))
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "no training job "+r.PathValue("id"))
|
|
return j, false
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return j, false
|
|
}
|
|
return j, true
|
|
}
|
|
|
|
func (a *API) trainGet(w http.ResponseWriter, r *http.Request) {
|
|
j, ok := a.trainJob(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
v := trainView(j)
|
|
v.Artifacts, _ = a.train.Store.TrainArtifactKinds(r.Context(), j.ID)
|
|
writeJSON(w, http.StatusOK, v)
|
|
}
|
|
|
|
func (a *API) trainCancel(w http.ResponseWriter, r *http.Request) {
|
|
j, ok := a.trainJob(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := a.train.Service.Cancel(r.Context(), j.ID); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
j, _ = a.train.Store.TrainJob(r.Context(), j.ID)
|
|
writeJSON(w, http.StatusOK, trainView(j))
|
|
}
|
|
|
|
func (a *API) trainDelete(w http.ResponseWriter, r *http.Request) {
|
|
j, ok := a.trainJob(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !store.TrainOver(j.State) {
|
|
writeError(w, http.StatusConflict, "the job has not ended: cancel it first")
|
|
return
|
|
}
|
|
if err := a.train.Store.DeleteTrainJob(r.Context(), j.ID); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"id": j.ID, "deleted": true})
|
|
}
|
|
|
|
func (a *API) trainArtifact(w http.ResponseWriter, r *http.Request) {
|
|
j, ok := a.trainJob(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
kind := r.PathValue("kind")
|
|
b, err := a.train.Store.TrainArtifact(r.Context(), j.ID, kind)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "the job made no "+kind)
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
name := j.Name + ".tar.gz"
|
|
w.Header().Set("Content-Type", "application/gzip")
|
|
if kind == "gguf" {
|
|
name = j.Name + ".gguf"
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
}
|
|
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(b)))
|
|
_, _ = w.Write(b)
|
|
}
|
|
|
|
// trainPublish puts what a job made on the mirror and makes it the model's
|
|
// adapter: the GGUF for a model llama.cpp serves, the PEFT directory for
|
|
// one vLLM serves.
|
|
func (a *API) trainPublish(w http.ResponseWriter, r *http.Request) {
|
|
j, ok := a.trainJob(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if a.train.Mirror == nil {
|
|
writeError(w, http.StatusNotImplemented, "publishing needs the model mirror (mirror.api in the settings); "+
|
|
"llmgw ctl train download, then llmgw mirror import there, does it by hand")
|
|
return
|
|
}
|
|
if a.models == nil {
|
|
writeError(w, http.StatusNotImplemented, "this control plane has no store for the catalog")
|
|
return
|
|
}
|
|
if j.State != store.TrainDone {
|
|
writeError(w, http.StatusConflict, "the job has not finished: it is "+j.State)
|
|
return
|
|
}
|
|
m, found := a.ctl.Catalog().Resolve(j.Model)
|
|
if !found {
|
|
writeError(w, http.StatusConflict, "the model "+j.Model+" is no longer in the catalog")
|
|
return
|
|
}
|
|
for _, ad := range m.Adapters {
|
|
if ad.Name == j.Name {
|
|
writeError(w, http.StatusConflict, j.Name+" is already "+m.Name+"'s adapter; train under another name")
|
|
return
|
|
}
|
|
}
|
|
llama := slices.ContainsFunc(m.Profiles, func(p catalog.Profile) bool { return p.Engine == "llamacpp" })
|
|
source := "trained by llmgw job " + j.ID + " on " + j.Node + " from " + j.BaseRepo
|
|
var url string
|
|
if llama {
|
|
b, err := a.train.Store.TrainArtifact(r.Context(), j.ID, "gguf")
|
|
if err != nil {
|
|
writeError(w, http.StatusConflict, "the job made no GGUF, which llama.cpp needs; train again without --no-gguf")
|
|
return
|
|
}
|
|
res, err := a.train.Mirror.Upload(r.Context(), j.Name, j.ID, j.Name+".gguf", b, source)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "keeping it on the mirror: "+err.Error())
|
|
return
|
|
}
|
|
url = res.URL
|
|
} else {
|
|
b, err := a.train.Store.TrainArtifact(r.Context(), j.ID, "peft")
|
|
if err != nil {
|
|
writeError(w, http.StatusConflict, "the job made no PEFT adapter")
|
|
return
|
|
}
|
|
files, err := untarAll(b)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
for name, data := range files {
|
|
res, err := a.train.Mirror.Upload(r.Context(), j.Name, j.ID, name, data, source)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "keeping "+name+" on the mirror: "+err.Error())
|
|
return
|
|
}
|
|
url = res.URL[:strings.LastIndex(res.URL, "/")+1]
|
|
}
|
|
}
|
|
ad := catalog.Adapter{Name: j.Name, Source: url, Owner: j.Owner}
|
|
if err := checkAdapters(r.Context(), m, []catalog.Adapter{ad}); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if _, err := a.models.SetAdapters(r.Context(), m.Name, append(slices.Clone(m.Adapters), ad), actorOf(r)); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
v := TrainPublishView{Adapter: j.Name, Model: m.Name, Source: url, Owner: j.Owner}
|
|
if llama {
|
|
v.Note = "llama.cpp loads adapters when a replica starts: restart " + m.Name + "'s replicas (llmgw ctl restart " +
|
|
m.Name + ") and ask for model " + j.Name
|
|
} else {
|
|
v.Note = "ask for model " + j.Name + "; a replica loads it on the first request"
|
|
}
|
|
writeJSON(w, http.StatusOK, v)
|
|
}
|
|
|
|
// untarAll reads a gzipped tar of a PEFT directory's files.
|
|
func untarAll(b []byte) (map[string][]byte, error) {
|
|
gz, err := gzip.NewReader(bytes.NewReader(b))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tr := tar.NewReader(gz)
|
|
out := map[string][]byte{}
|
|
for {
|
|
h, err := tr.Next()
|
|
if errors.Is(err, io.EOF) {
|
|
return out, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if h.Typeflag != tar.TypeReg || strings.ContainsAny(h.Name, "/\\") {
|
|
continue
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(tr, 4<<30))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[h.Name] = data
|
|
}
|
|
}
|